/** * Copyright 2017 The AMP HTML Authors. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS-IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import * as consent from '../../../../src/consent'; import * as utils from '../utils'; import { Action, AmpStoryStoreService, StateProperty, UIType, } from '../amp-story-store-service'; import {ActionTrust} from '../../../../src/action-constants'; import {AdvancementMode} from '../story-analytics'; import {AmpStory} from '../amp-story'; import {AmpStoryBookend} from '../bookend/amp-story-bookend'; import {AmpStoryConsent} from '../amp-story-consent'; import {CommonSignals} from '../../../../src/common-signals'; import {Keys} from '../../../../src/utils/key-codes'; import {LocalizationService} from '../../../../src/service/localization'; import {MediaType} from '../media-pool'; import {PageState} from '../amp-story-page'; import {Services} from '../../../../src/services'; import {VisibilityState} from '../../../../src/visibility-state'; import {createElementWithAttributes} from '../../../../src/dom'; import {registerServiceBuilder} from '../../../../src/service'; import {toggleExperiment} from '../../../../src/experiments'; import {waitFor} from '../../../../testing/test-helper'; // Represents the correct value of KeyboardEvent.which for the Right Arrow const KEYBOARD_EVENT_WHICH_RIGHT_ARROW = 39; describes.realWin( 'amp-story', { amp: { runtimeOn: true, extensions: ['amp-story:1.0'], }, }, (env) => { let ampdoc; let element; let hasSwipeCapability = false; let isEmbedded = false; let story; let replaceStateStub; let win; /** * @param {number} count * @param {Array=} ids * @return {!Array} */ async function createStoryWithPages(count, ids = [], autoAdvance = false) { element = win.document.createElement('amp-story'); Array(count) .fill(undefined) .map((unused, i) => { const page = win.document.createElement('amp-story-page'); if (autoAdvance) { page.setAttribute('auto-advance-after', '2s'); } page.id = ids && ids[i] ? ids[i] : `-page-${i}`; element.appendChild(page); return page; }); win.document.body.appendChild(element); story = await element.getImpl(); } /** * @param {string} eventType * @return {!Event} */ function createEvent(eventType) { const eventObj = document.createEventObject ? document.createEventObject() : document.createEvent('Events'); if (eventObj.initEvent) { eventObj.initEvent(eventType, true, true); } return eventObj; } beforeEach(() => { win = env.win; ampdoc = env.ampdoc; replaceStateStub = env.sandbox.stub(win.history, 'replaceState'); // Required by the bookend code. win.document.title = 'Story'; env.ampdoc.defaultView = env.win; const localizationService = new LocalizationService(win.document.body); env.sandbox .stub(Services, 'localizationForDoc') .returns(localizationService); const viewer = Services.viewerForDoc(env.ampdoc); env.sandbox .stub(viewer, 'hasCapability') .withArgs('swipe') .returns(hasSwipeCapability); env.sandbox.stub(viewer, 'isEmbedded').withArgs().returns(isEmbedded); env.sandbox.stub(Services, 'viewerForDoc').returns(viewer); registerServiceBuilder(win, 'performance', function () { return { isPerformanceTrackingOn: () => false, }; }); const storeService = new AmpStoryStoreService(win); registerServiceBuilder(win, 'story-store', function () { return storeService; }); AmpStory.isBrowserSupported = () => true; }); afterEach(() => { element.remove(); }); it('should build with the expected number of pages', async () => { const pagesCount = 2; await createStoryWithPages(pagesCount, ['cover', 'page-1']); await story.layoutCallback(); expect(story.getPageCount()).to.equal(pagesCount); }); it('should activate the first page when built', async () => { await createStoryWithPages(2, ['cover', 'page-1']); await story.layoutCallback(); // Getting all the AmpStoryPage objets. const pageElements = story.element.getElementsByTagName('amp-story-page'); let pages = Array.from(pageElements).map((el) => el.getImpl()); pages = await Promise.all(pages); // Only the first page should be active. for (let i = 0; i < pages.length; i++) { i === 0 ? expect(pages[i].isActive()).to.be.true : expect(pages[i].isActive()).to.be.false; } }); it('should remove text child nodes when built', async () => { await createStoryWithPages(1, ['cover']); const textToRemove = 'this should be removed'; const textNode = win.document.createTextNode(textToRemove); story.element.appendChild(textNode); story.buildCallback(); await story.layoutCallback(); expect(story.element.innerText).to.not.have.string(textToRemove); }); it('should preload the bookend if navigating to the last page', async () => { await createStoryWithPages(1, ['cover']); const buildBookendStub = env.sandbox.stub( story, 'buildAndPreloadBookend_' ); await story.layoutCallback(); expect(buildBookendStub).to.have.been.calledOnce; }); it('should not preload the bookend if not on the last page', async () => { await createStoryWithPages(2, ['cover']); const buildBookendStub = env.sandbox.stub( story, 'buildAndPreloadBookend_' ); await story.layoutCallback(); expect(buildBookendStub).to.not.have.been.called; }); it('should prerender/load the share menu', async () => { await createStoryWithPages(2); const buildShareMenuStub = env.sandbox.stub(story.shareMenu_, 'build'); await story.layoutCallback(); expect(buildShareMenuStub).to.have.been.calledOnce; }); it('should return a valid page index', async () => { await createStoryWithPages(4, ['cover', 'page-1', 'page-2', 'page-3']); await story.layoutCallback(); // Getting all the AmpStoryPage objets. const pageElements = story.element.getElementsByTagName('amp-story-page'); let pages = Array.from(pageElements).map((el) => el.getImpl()); pages = await Promise.all(pages); // Only the first page should be active. for (let i = 0; i < pages.length; i++) { expect(story.getPageIndex(pages[i])).to.equal(i); } }); it('should pause/resume pages when switching pages', async () => { await createStoryWithPages(2, ['cover', 'page-1']); env.sandbox.stub(story, 'maybePreloadBookend_').returns(); await story.layoutCallback(); // Getting all the AmpStoryPage objects. const pageElements = story.element.getElementsByTagName('amp-story-page'); let pages = Array.from(pageElements).map((el) => el.getImpl()); pages = await Promise.all(pages); const oldPage = pages[0]; const newPage = pages[1]; const setStateOldPageStub = env.sandbox.stub(oldPage, 'setState'); const setStateNewPageStub = env.sandbox.stub(newPage, 'setState'); await story.switchTo_('page-1'); expect(setStateOldPageStub).to.have.been.calledOnceWithExactly( PageState.NOT_ACTIVE ); expect(setStateNewPageStub).to.have.been.calledOnceWithExactly( PageState.PLAYING ); }); // TODO(#11639): Re-enable this test. it.skip('should go to next page on right arrow keydown', async () => { await createStoryWithPages(); const pages = story.element.querySelectorAll('amp-story-page'); element.build(); expect(pages[0].hasAttribute('active')).to.be.true; expect(pages[1].hasAttribute('active')).to.be.false; // Stubbing because we need to assert synchronously env.sandbox .stub(element.implementation_, 'mutateElement') .callsFake((mutator) => { mutator(); return Promise.resolve(); }); const eventObj = createEvent('keydown'); eventObj.key = Keys.RIGHT_ARROW; eventObj.which = KEYBOARD_EVENT_WHICH_RIGHT_ARROW; const docEl = win.document.documentElement; docEl.dispatchEvent ? docEl.dispatchEvent(eventObj) : docEl.fireEvent('onkeydown', eventObj); expect(pages[0].hasAttribute('active')).to.be.false; expect(pages[1].hasAttribute('active')).to.be.true; }); it('lock body when amp-story is initialized', async () => { await createStoryWithPages(2, ['cover', 'page-1']); await story.layoutCallback(); story.lockBody_(); expect(win.document.body.style.getPropertyValue('overflow')).to.be.equal( 'hidden' ); expect( win.document.documentElement.style.getPropertyValue('overflow') ).to.be.equal('hidden'); }); it('checks if pagination buttons exist ', async () => { await createStoryWithPages(2, ['cover', 'page-1']); await story.layoutCallback(); expect( story.element.querySelectorAll('.i-amphtml-story-button-container') .length ).to.equal(2); }); it.skip('toggles `i-amphtml-story-landscape` based on height and width', () => { story.element.style.width = '11px'; story.element.style.height = '10px'; const isDesktopStub = env.sandbox .stub(story, 'isDesktop_') .returns(false); story.vsync_ = { run: (task, state) => { if (task.measure) { task.measure(state); } if (task.mutate) { task.mutate(state); } }, }; story.onResize(); expect(isDesktopStub).to.be.calledOnce; expect(story.element.classList.contains('i-amphtml-story-landscape')).to .be.true; story.element.style.width = '10px'; story.element.style.height = '11px'; story.onResize(); expect(isDesktopStub).to.be.calledTwice; expect(story.element.classList.contains('i-amphtml-story-landscape')).to .be.false; }); it('should update page id in store', async () => { const firstPageId = 'page-one'; const pageCount = 2; await createStoryWithPages(pageCount, [firstPageId, 'page-1']); const dispatchSpy = env.sandbox.spy(story.storeService_, 'dispatch'); await story.layoutCallback(); expect(dispatchSpy).to.have.been.calledWith(Action.CHANGE_PAGE, { id: firstPageId, index: 0, }); }); it('should update page id in browser history', async () => { const firstPageId = 'page-zero'; const pageCount = 2; await createStoryWithPages(pageCount, [firstPageId, 'page-1']); await story.layoutCallback(); expect(replaceStateStub).to.have.been.calledWith( {ampStoryNavigationPath: [firstPageId]}, '' ); }); it('should not block layoutCallback when bookend xhr fails', async () => { await createStoryWithPages(1, ['page-1']); env.sandbox.stub(AmpStoryBookend.prototype, 'build'); const bookendXhr = env.sandbox .stub(AmpStoryBookend.prototype, 'loadConfigAndMaybeRenderBookend') .returns(Promise.reject()); story.buildCallback(); return story .layoutCallback() .then(() => { expect(bookendXhr).to.have.been.calledOnce; }) .catch((error) => { expect(error).to.be.undefined; }); }); it('should NOT update page id in browser history if ad', async () => { const firstPageId = 'i-amphtml-ad-page-1'; const pageCount = 2; await createStoryWithPages(pageCount, [firstPageId, 'page-1']); const pages = story.element.querySelectorAll('amp-story-page'); const firstPage = pages[0]; firstPage.setAttribute('ad', ''); await story.layoutCallback(); expect(replaceStateStub).to.not.have.been.called; }); it('should not set first page to active when rendering paused story', async () => { await createStoryWithPages(2, ['cover', 'page-1']); story.storeService_.dispatch(Action.TOGGLE_PAUSED, true); await story.layoutCallback(); expect(story.getPageById('cover').state_).to.equal(PageState.NOT_ACTIVE); }); it('should default to the three panels UI desktop experience', async () => { await createStoryWithPages(4, ['cover', '1', '2', '3']); // Don't do this at home. :( story.desktopMedia_ = {matches: true}; story.buildCallback(); await story.layoutCallback(); expect(story.storeService_.get(StateProperty.UI_STATE)).to.equals( UIType.DESKTOP_PANELS ); }); it('should detect landscape opt in', async () => { await createStoryWithPages(4, ['cover', '1', '2', '3']); story.element.setAttribute('supports-landscape', ''); // Don't do this at home. :( story.desktopMedia_ = {matches: true}; story.buildCallback(); await story.layoutCallback(); expect(story.storeService_.get(StateProperty.UI_STATE)).to.equals( UIType.DESKTOP_FULLBLEED ); }); it('should add a desktop attribute', async () => { await createStoryWithPages(2, ['cover', '1']); story.desktopMedia_ = {matches: true}; story.buildCallback(); await story.layoutCallback(); expect(story.element).to.have.attribute('desktop'); }); it('should have a meta tag that sets the theme color', async () => { await createStoryWithPages(2); story.buildCallback(); await story.layoutCallback(); const metaTag = win.document.querySelector('meta[name=theme-color]'); expect(metaTag).to.not.be.null; }); it('should set the orientation portrait attribute on render', async () => { await createStoryWithPages(2, ['cover', 'page-1']); story.landscapeOrientationMedia_ = {matches: false}; story.element.setAttribute('standalone', ''); story.element.setAttribute('supports-landscape', ''); story.buildCallback(); await story.layoutCallback(); expect(story.element).to.have.attribute('orientation'); expect(story.element.getAttribute('orientation')).to.equal('portrait'); }); it('should set the orientation landscape attribute on render', async () => { await createStoryWithPages(2, ['cover', 'page-1']); story.landscapeOrientationMedia_ = {matches: true}; story.element.setAttribute('standalone', ''); story.element.setAttribute('supports-landscape', ''); story.buildCallback(); await story.layoutCallback(); expect(story.element).to.have.attribute('orientation'); expect(story.element.getAttribute('orientation')).to.equal('landscape'); }); it('should not set orientation landscape if no supports-landscape', async () => { await createStoryWithPages(2, ['cover', 'page-1']); story.landscapeOrientationMedia_ = {matches: true}; story.element.setAttribute('standalone', ''); story.buildCallback(); await story.layoutCallback(); expect(story.element).to.have.attribute('orientation'); expect(story.element.getAttribute('orientation')).to.equal('portrait'); }); it('should update the orientation landscape attribute', async () => { await createStoryWithPages(2, ['cover', 'page-1']); story.landscapeOrientationMedia_ = {matches: true}; story.element.setAttribute('standalone', ''); story.element.setAttribute('supports-landscape', ''); env.sandbox.stub(story, 'mutateElement').callsFake((fn) => fn()); story.buildCallback(); await story.layoutCallback(); story.landscapeOrientationMedia_ = {matches: false}; story.onResize(); await Promise.resolve(); expect(story.element).to.have.attribute('orientation'); expect(story.element.getAttribute('orientation')).to.equal('portrait'); }); it('should deduplicate amp-story-page ids', async () => { expectAsyncConsoleError(/Duplicate amp-story-page ID/, 3); await createStoryWithPages(6, [ 'cover', 'page-1', 'cover', 'page-1', 'page-1', 'page-2', ]); const pages = story.element.querySelectorAll('amp-story-page'); const pageIds = Array.prototype.map.call(pages, (page) => page.id); expect(pageIds).to.deep.equal([ 'cover', 'page-1', 'cover__1', 'page-1__1', 'page-1__2', 'page-2', ]); }); it('should deduplicate amp-story-page ids and cache them', async () => { expectAsyncConsoleError(/Duplicate amp-story-page ID/, 3); await createStoryWithPages(6, [ 'cover', 'page-1', 'cover', 'page-1', 'page-1', 'page-2', ]); expect(story.storeService_.get(StateProperty.PAGE_IDS)).to.deep.equal([ 'cover', 'page-1', 'cover__1', 'page-1__1', 'page-1__2', 'page-2', ]); }); describe('amp-story consent', () => { it('should pause the story if there is a consent', async () => { await createStoryWithPages(2, ['cover', 'page-1']); env.sandbox .stub(Services, 'actionServiceForDoc') .returns({setAllowlist: () => {}, trigger: () => {}}); // Prevents amp-story-consent element from running code that is irrelevant // to this test. env.sandbox.stub(AmpStoryConsent.prototype, 'buildCallback'); const consentEl = win.document.createElement('amp-consent'); const storyConsentEl = win.document.createElement('amp-story-consent'); storyConsentEl.setAttribute('layout', 'nodisplay'); consentEl.appendChild(storyConsentEl); element.appendChild(consentEl); // Never resolving consent promise, emulating a user looking at the // consent prompt. const promise = new Promise(() => {}); env.sandbox.stub(consent, 'getConsentPolicyState').returns(promise); const coverEl = element.querySelector('amp-story-page'); const cover = await coverEl.getImpl(); const setStateStub = env.sandbox.stub(cover, 'setState'); await story.layoutCallback(); // These assertions ensure we don't spam the page state. We want to // avoid a situation where we set the page to active, then paused, // which would spam the media pool with expensive operations. expect(setStateStub).to.have.been.calledOnce; expect(setStateStub.getCall(0)).to.have.been.calledWithExactly( PageState.NOT_ACTIVE ); }); it('should play the story after the consent is resolved', async () => { env.sandbox .stub(Services, 'actionServiceForDoc') .returns({setAllowlist: () => {}, trigger: () => {}}); // Prevents amp-story-consent element from running code that is irrelevant // to this test. env.sandbox.stub(AmpStoryConsent.prototype, 'buildCallback'); const consentEl = win.document.createElement('amp-consent'); const storyConsentEl = win.document.createElement('amp-story-consent'); consentEl.appendChild(storyConsentEl); element.appendChild(consentEl); await createStoryWithPages(2, ['cover', 'page-1']); // In a real scenario, promise is resolved when the user accepted or // rejected the consent. let resolver; const promise = new Promise((resolve) => { resolver = resolve; }); env.sandbox.stub(consent, 'getConsentPolicyState').returns(promise); const coverEl = element.querySelector('amp-story-page'); const cover = await coverEl.getImpl(); const setStateStub = env.sandbox.stub(cover, 'setState'); await story.layoutCallback(); await resolver(); // Resolving the consent. // These assertions ensure we don't spam the page state. We want to // avoid a situation where we set the page to active, then paused, // then back to active, which would spam the media pool with // expensive operations. expect(setStateStub).to.have.been.calledTwice; expect(setStateStub.getCall(0)).to.have.been.calledWithExactly( PageState.NOT_ACTIVE ); expect(setStateStub.getCall(1)).to.have.been.calledWithExactly( PageState.PLAYING ); }); it('should play the story if the consent was already resolved', async () => { env.sandbox .stub(Services, 'actionServiceForDoc') .returns({setAllowlist: () => {}, trigger: () => {}}); // Prevents amp-story-consent element from running code that is irrelevant // to this test. env.sandbox.stub(AmpStoryConsent.prototype, 'buildCallback'); const consentEl = win.document.createElement('amp-consent'); const storyConsentEl = win.document.createElement('amp-story-consent'); consentEl.appendChild(storyConsentEl); element.appendChild(consentEl); await createStoryWithPages(2, ['cover', 'page-1']); // Returns an already resolved promised: the user already accepted or // rejected the consent in a previous session. env.sandbox.stub(consent, 'getConsentPolicyState').resolves(); const coverEl = element.querySelector('amp-story-page'); const cover = await coverEl.getImpl(); const setStateStub = env.sandbox.stub(cover, 'setState'); await story.layoutCallback(); // These assertions ensure we don't spam the page state. We want to // avoid a situation where we set the page to active, then paused, // then back to active, which would spam the media pool with // expensive operations. expect(setStateStub).to.have.been.calledTwice; expect(setStateStub.getCall(0)).to.have.been.calledWithExactly( PageState.NOT_ACTIVE ); expect(setStateStub.getCall(1)).to.have.been.calledWithExactly( PageState.PLAYING ); }); }); describe('amp-story pause/resume callbacks', () => { it('should pause the story when tab becomes inactive', async () => { await createStoryWithPages(2, ['cover', 'page-1']); env.sandbox.stub(ampdoc, 'isVisible').returns(false); const onVisibilityChangedStub = env.sandbox.stub( ampdoc, 'onVisibilityChanged' ); story.buildCallback(); await story.layoutCallback(); // Execute the callback passed to onVisibilityChanged. expect(onVisibilityChangedStub).to.have.been.calledOnce; onVisibilityChangedStub.getCall(0).args[0](); // Paused state has been changed to true. expect(story.storeService_.get(StateProperty.PAUSED_STATE)).to.be.true; }); it('should play the story when tab becomes active', async () => { await createStoryWithPages(2, ['cover', 'page-1']); env.sandbox.stub(ampdoc, 'isVisible').returns(true); const onVisibilityChangedStub = env.sandbox.stub( ampdoc, 'onVisibilityChanged' ); story.storeService_.dispatch(Action.TOGGLE_PAUSED, true); story.buildCallback(); await story.layoutCallback(); // Execute the callback passed to onVisibilityChanged. expect(onVisibilityChangedStub).to.have.been.calledOnce; onVisibilityChangedStub.getCall(0).args[0](); // Paused state has been changed to false. expect(story.storeService_.get(StateProperty.PAUSED_STATE)).to.be.false; }); it('should pause the story when viewer becomes inactive', async () => { await createStoryWithPages(2, ['cover', 'page-1']); await story.layoutCallback(); story.getAmpDoc().overrideVisibilityState(VisibilityState.INACTIVE); expect(story.storeService_.get(StateProperty.PAUSED_STATE)).to.be.true; }); it('should rewind the story page when viewer becomes inactive', async () => { await createStoryWithPages(2, ['cover', 'page-1']); await story.layoutCallback(); const setStateStub = window.sandbox.stub(story.activePage_, 'setState'); story.getAmpDoc().overrideVisibilityState(VisibilityState.INACTIVE); expect(setStateStub.getCall(1)).to.have.been.calledWithExactly( PageState.NOT_ACTIVE ); }); it('should pause the story when viewer becomes hidden', async () => { await createStoryWithPages(2, ['cover', 'page-1']); await story.layoutCallback(); story.getAmpDoc().overrideVisibilityState(VisibilityState.HIDDEN); expect(story.storeService_.get(StateProperty.PAUSED_STATE)).to.be.true; }); it('should pause the story page when viewer becomes hidden', async () => { await createStoryWithPages(2, ['cover', 'page-1']); await story.layoutCallback(); const setStateStub = window.sandbox.stub(story.activePage_, 'setState'); story.getAmpDoc().overrideVisibilityState(VisibilityState.HIDDEN); expect(setStateStub).to.have.been.calledOnceWithExactly( PageState.PAUSED ); }); it('should pause the story when viewer becomes paused', async () => { await createStoryWithPages(2, ['cover', 'page-1']); await story.layoutCallback(); story.getAmpDoc().overrideVisibilityState(VisibilityState.PAUSED); expect(story.storeService_.get(StateProperty.PAUSED_STATE)).to.be.true; }); it('should pause the story page when viewer becomes paused', async () => { await createStoryWithPages(2, ['cover', 'page-1']); await story.layoutCallback(); const setStateStub = window.sandbox.stub(story.activePage_, 'setState'); story.getAmpDoc().overrideVisibilityState(VisibilityState.PAUSED); expect(setStateStub).to.have.been.calledOnceWithExactly( PageState.PAUSED ); }); it('should play the story when viewer becomes active after paused', async () => { await createStoryWithPages(2, ['cover', 'page-1']); await story.layoutCallback(); story.getAmpDoc().overrideVisibilityState(VisibilityState.PAUSED); story.getAmpDoc().overrideVisibilityState(VisibilityState.ACTIVE); expect(story.storeService_.get(StateProperty.PAUSED_STATE)).to.be.false; }); it('should play the story page when viewer becomes active after paused', async () => { await createStoryWithPages(2, ['cover', 'page-1']); await story.layoutCallback(); const setStateStub = window.sandbox.stub(story.activePage_, 'setState'); story.getAmpDoc().overrideVisibilityState(VisibilityState.PAUSED); story.getAmpDoc().overrideVisibilityState(VisibilityState.ACTIVE); expect(setStateStub.getCall(1)).to.have.been.calledWithExactly( PageState.PLAYING ); }); it('should play the story page when viewer becomes active after paused + inactive', async () => { await createStoryWithPages(2, ['cover', 'page-1']); await story.layoutCallback(); const setStateStub = window.sandbox.stub(story.activePage_, 'setState'); story.getAmpDoc().overrideVisibilityState(VisibilityState.PAUSED); story.getAmpDoc().overrideVisibilityState(VisibilityState.INACTIVE); story.getAmpDoc().overrideVisibilityState(VisibilityState.ACTIVE); expect(setStateStub.getCall(0)).to.have.been.calledWithExactly( PageState.PAUSED ); expect(setStateStub.getCall(1)).to.have.been.calledWithExactly( PageState.NOT_ACTIVE ); expect(setStateStub.getCall(2)).to.have.been.calledWithExactly( PageState.PLAYING ); }); it('should keep the story paused on resume when previously paused', async () => { await createStoryWithPages(2, ['cover', 'page-1']); story.storeService_.dispatch(Action.TOGGLE_PAUSED, true); await story.layoutCallback(); story.getAmpDoc().overrideVisibilityState(VisibilityState.PAUSED); story.getAmpDoc().overrideVisibilityState(VisibilityState.ACTIVE); expect(story.storeService_.get(StateProperty.PAUSED_STATE)).to.be.true; }); it('should keep the story paused on resume when previously paused + inactive', async () => { await createStoryWithPages(2, ['cover', 'page-1']); story.storeService_.dispatch(Action.TOGGLE_PAUSED, true); await story.layoutCallback(); story.getAmpDoc().overrideVisibilityState(VisibilityState.PAUSED); story.getAmpDoc().overrideVisibilityState(VisibilityState.INACTIVE); story.getAmpDoc().overrideVisibilityState(VisibilityState.ACTIVE); expect(story.storeService_.get(StateProperty.PAUSED_STATE)).to.be.true; }); describe('amp-story continue anyway', () => { it('should not display layout', async () => { await createStoryWithPages(2, ['cover', 'page-4']); AmpStory.isBrowserSupported = () => false; story = new AmpStory(element); const dispatchSpy = env.sandbox.spy(story.storeService_, 'dispatch'); await story.layoutCallback(); expect(dispatchSpy).to.have.been.calledWith( Action.TOGGLE_SUPPORTED_BROWSER, false ); }); it('should display the story after clicking "continue" button', async () => { await createStoryWithPages(2, ['cover', 'page-1']); AmpStory.isBrowserSupported = () => false; story = new AmpStory(element); const dispatchSpy = env.sandbox.spy( story.unsupportedBrowserLayer_.storeService_, 'dispatch' ); story.buildCallback(); await story.layoutCallback(); story.unsupportedBrowserLayer_.continueButton_.click(); expect(dispatchSpy).to.have.been.calledWith( Action.TOGGLE_SUPPORTED_BROWSER, true ); }); }); describe('amp-story custom sidebar', () => { it('should show the sidebar control if a sidebar exists', async () => { await createStoryWithPages(2, ['cover', 'page-1']); const sidebar = win.document.createElement('amp-sidebar'); story.element.appendChild(sidebar); await story.layoutCallback(); expect(story.storeService_.get(StateProperty.HAS_SIDEBAR_STATE)).to.be .true; }); }); it('should open the sidebar on button click', async () => { await createStoryWithPages(2, ['cover', 'page-1']); const sidebar = win.document.createElement('amp-sidebar'); story.element.appendChild(sidebar); const executeSpy = env.sandbox.spy(); env.sandbox.stub(Services, 'actionServiceForDoc').returns({ setAllowlist: () => {}, trigger: () => {}, execute: executeSpy, }); story.buildCallback(); await story.layoutCallback(); story.storeService_.dispatch(Action.TOGGLE_SIDEBAR, true); expect(executeSpy).to.have.been.calledWith( story.sidebar_, 'open', null, null, null, null, ActionTrust.HIGH ); }); it('should unpause the story when the sidebar is closed', async () => { await createStoryWithPages(2, ['cover', 'page-1']); const sidebar = win.document.createElement('amp-sidebar'); story.element.appendChild(sidebar); env.sandbox.stub(Services, 'actionServiceForDoc').returns({ setAllowlist: () => {}, trigger: () => {}, execute: () => { sidebar.setAttribute('open', ''); }, }); story.buildCallback(); await story.layoutCallback(); story.storeService_.dispatch(Action.TOGGLE_SIDEBAR, true); await Promise.resolve(); story.sidebar_.removeAttribute('open'); await Promise.resolve(); expect(story.storeService_.get(StateProperty.SIDEBAR_STATE)).to.be .false; }); describe('desktop attributes', () => { it('should add desktop-position attributes', async () => { const desktopAttribute = 'i-amphtml-desktop-position'; await createStoryWithPages(4, ['cover', '1', '2', '3']); const pages = story.element.querySelectorAll('amp-story-page'); story.buildCallback(); story.storeService_.dispatch(Action.TOGGLE_UI, UIType.DESKTOP_PANELS); await story.layoutCallback(); expect(pages[0].getAttribute(desktopAttribute)).to.equal('0'); expect(pages[1].getAttribute(desktopAttribute)).to.equal('1'); expect(pages[2].getAttribute(desktopAttribute)).to.equal('2'); expect(pages[3].hasAttribute(desktopAttribute)).to.be.false; }); }); it('should update desktop-position attributes upon navigation', async () => { const desktopAttribute = 'i-amphtml-desktop-position'; await createStoryWithPages(4, ['cover', '1', '2', '3']); const pages = story.element.querySelectorAll('amp-story-page'); story.buildCallback(); story.storeService_.dispatch(Action.TOGGLE_UI, UIType.DESKTOP_PANELS); await story.layoutCallback(); await story.switchTo_('2'); expect(pages[0].getAttribute(desktopAttribute)).to.equal('-2'); expect(pages[1].getAttribute(desktopAttribute)).to.equal('-1'); expect(pages[2].getAttribute(desktopAttribute)).to.equal('0'); expect(pages[3].getAttribute(desktopAttribute)).to.equal('1'); }); it('should add previous visited attribute', async () => { env.sandbox.stub(story, 'maybePreloadBookend_').returns(); env.sandbox .stub(utils, 'setAttributeInMutate') .callsFake((el, attr) => el.element.setAttribute(attr, '')); await createStoryWithPages(2, ['page-0', 'page-1']); const pages = story.element.querySelectorAll('amp-story-page'); const page0 = pages[0]; await story.layoutCallback(); await story.switchTo_('page-1'); expect(page0.hasAttribute('i-amphtml-visited')).to.be.true; }); describe('amp-story audio', () => { it('should register and preload the background audio', async () => { const src = 'https://example.com/foo.mp3'; story.element.setAttribute('background-audio', src); const registerStub = env.sandbox.stub(story.mediaPool_, 'register'); const preloadStub = env.sandbox .stub(story.mediaPool_, 'preload') .resolves(); await createStoryWithPages(2, ['cover', 'page-1']); story .layoutCallback() .then(() => story.activePage_.element .signals() .whenSignal(CommonSignals.LOAD_END) ) .then(() => { expect(story.backgroundAudioEl_).to.exist; expect(story.backgroundAudioEl_.src).to.equal(src); expect(registerStub).to.have.been.calledOnce; expect(preloadStub).to.have.been.calledOnce; }); }); it('should bless the media on unmute', async () => { await createStoryWithPages(2, ['cover', 'page-1']); const blessAllStub = env.sandbox .stub(story.mediaPool_, 'blessAll') .resolves(); await story.layoutCallback(); story.storeService_.dispatch(Action.TOGGLE_MUTED, false); expect(blessAllStub).to.have.been.calledOnce; }); it('should pause the background audio on ad state if not muted', async () => { await createStoryWithPages(2, ['cover', 'page-1']); const backgroundAudioEl = win.document.createElement('audio'); backgroundAudioEl.setAttribute('id', 'foo'); story.backgroundAudioEl_ = backgroundAudioEl; await story.layoutCallback(); const pauseStub = env.sandbox.stub(story.mediaPool_, 'pause'); story.storeService_.dispatch(Action.TOGGLE_MUTED, false); story.storeService_.dispatch(Action.TOGGLE_AD, true); expect(pauseStub).to.have.been.calledOnce; expect(pauseStub).to.have.been.calledWith(backgroundAudioEl); }); it('should play the background audio when hiding ad if not muted', async () => { await createStoryWithPages(2, ['cover', 'page-1']); const backgroundAudioEl = win.document.createElement('audio'); backgroundAudioEl.setAttribute('id', 'foo'); story.backgroundAudioEl_ = backgroundAudioEl; await story.layoutCallback(); // Displaying an ad and not muted. story.storeService_.dispatch(Action.TOGGLE_AD, true); story.storeService_.dispatch(Action.TOGGLE_MUTED, false); const unmuteStub = env.sandbox.stub(story.mediaPool_, 'unmute'); const playStub = env.sandbox.stub(story.mediaPool_, 'play'); story.storeService_.dispatch(Action.TOGGLE_AD, false); expect(unmuteStub).to.have.been.calledOnce; expect(unmuteStub).to.have.been.calledWith(backgroundAudioEl); expect(playStub).to.have.been.calledOnce; expect(playStub).to.have.been.calledWith(backgroundAudioEl); }); it('should not play the background audio when hiding ad if muted', async () => { const backgroundAudioEl = win.document.createElement('audio'); backgroundAudioEl.setAttribute('id', 'foo'); story.backgroundAudioEl_ = backgroundAudioEl; await createStoryWithPages(2, ['cover', 'page-1']); await story.layoutCallback(); story.storeService_.dispatch(Action.TOGGLE_AD, true); const unmuteStub = env.sandbox.stub(story.mediaPool_, 'unmute'); const playStub = env.sandbox.stub(story.mediaPool_, 'play'); story.storeService_.dispatch(Action.TOGGLE_AD, false); expect(unmuteStub).not.to.have.been.called; expect(playStub).not.to.have.been.called; }); }); it('should remove the muted attribute on unmuted state change', async () => { await createStoryWithPages(2, ['cover', 'page-1']); await story.layoutCallback(); expect(story.element.hasAttribute('muted')).to.be.true; story.storeService_.dispatch(Action.TOGGLE_MUTED, false); expect(story.element.hasAttribute('muted')).to.be.false; }); it('should add the muted attribute on unmuted state change', async () => { await createStoryWithPages(2, ['cover', 'page-1']); await story.layoutCallback(); story.storeService_.dispatch(Action.TOGGLE_MUTED, true); expect(story.element.hasAttribute('muted')).to.be.true; }); describe('#getMaxMediaElementCounts', () => { it('should create 2 audio & video elements when no elements found', async () => { await createStoryWithPages(2, ['cover', 'page-1']); await story.layoutCallback(); const expected = { [MediaType.AUDIO]: 2, [MediaType.VIDEO]: 2, }; expect(story.getMaxMediaElementCounts()).to.deep.equal(expected); }); it('should create 2 extra audio & video elements for ads', async () => { await createStoryWithPages(2, ['cover', 'page-1']); await story.layoutCallback(); const ampVideoEl = win.document.createElement('amp-video'); const ampAudoEl = createElementWithAttributes( win.document, 'amp-audio', {'background-audio': ''} ); story.element.appendChild(ampVideoEl); story.element.appendChild(ampAudoEl); const expected = { [MediaType.AUDIO]: 3, [MediaType.VIDEO]: 3, }; expect(story.getMaxMediaElementCounts()).to.deep.equal(expected); }); it('never have more than the defined maximums', async () => { await createStoryWithPages(2, ['cover', 'page-1']); await story.layoutCallback(); for (let i = 0; i < 7; i++) { const el = createElementWithAttributes(win.document, 'amp-audio', { 'background-audio': '', }); story.element.appendChild(el); } for (let i = 0; i < 8; i++) { const el = win.document.createElement('amp-video'); story.element.appendChild(el); } const expected = { [MediaType.AUDIO]: 4, [MediaType.VIDEO]: 8, }; expect(story.getMaxMediaElementCounts()).to.deep.equal(expected); }); }); describe('amp-story NO_NEXT_PAGE', () => { describe('without #cap=swipe', () => { it('should open the bookend when tapping on the last page', async () => { await createStoryWithPages(1, ['cover']); await story.layoutCallback(); // Click on right side of the screen to trigger page advancement. const clickEvent = new MouseEvent('click', {clientX: 200}); story.activePage_.element.dispatchEvent(clickEvent); await waitFor(() => { return !!story.storeService_.get(StateProperty.BOOKEND_STATE); }, 'BOOKEND_STATE should be true'); }); }); describe('with #cap=swipe', () => { before(() => { hasSwipeCapability = true; isEmbedded = true; }); after(() => { hasSwipeCapability = false; isEmbedded = false; }); it('should send a message when tapping on last page in viewer', async () => { await createStoryWithPages(1, ['cover']); const sendMessageStub = env.sandbox.stub( story.viewerMessagingHandler_, 'send' ); await story.layoutCallback(); // Click on right side of the screen to trigger page advancement. const clickEvent = new MouseEvent('click', {clientX: 200}); story.activePage_.element.dispatchEvent(clickEvent); await waitFor(() => { if (sendMessageStub.calledOnce) { expect(sendMessageStub).to.be.calledWithExactly( 'selectDocument', { next: true, advancementMode: AdvancementMode.MANUAL_ADVANCE, } ); return true; } return false; }, 'sendMessageStub should be called'); }); it('should send a message when auto-advancing on last page in viewer', async () => { await createStoryWithPages(1, ['cover'], true /** autoAdvance */); const sendMessageStub = env.sandbox.stub( story.viewerMessagingHandler_, 'send' ); await story.layoutCallback(); story.activePage_.advancement_.onAdvance(); await waitFor(() => { if (sendMessageStub.calledOnce) { expect(sendMessageStub).to.be.calledWithExactly( 'selectDocument', { next: true, advancementMode: AdvancementMode.AUTO_ADVANCE_TIME, } ); return true; } return false; }, 'sendMessageStub should be called'); }); }); }); describe('amp-story NO_PREVIOUS_PAGE', () => { describe('without #cap=swipe', () => { it('should open the bookend when tapping on the last page', async () => { await createStoryWithPages(1, ['cover']); const showPageHintStub = env.sandbox.stub( story.ampStoryHint_, 'showFirstPageHintOverlay' ); await story.layoutCallback(); // Click on left side of the screen to trigger page advancement. const clickEvent = new MouseEvent('click', {clientX: 10}); story.activePage_.element.dispatchEvent(clickEvent); await waitFor(() => { return showPageHintStub.calledOnce; }, 'showPageHintStub should be called'); }); }); describe('with #cap=swipe', () => { before(() => { hasSwipeCapability = true; isEmbedded = true; }); after(() => { hasSwipeCapability = false; isEmbedded = false; }); it('should send a message when tapping on last page in viewer', async () => { await createStoryWithPages(1, ['cover']); const sendMessageStub = env.sandbox.stub( story.viewerMessagingHandler_, 'send' ); await story.layoutCallback(); // Click on left side of the screen to trigger page advancement. const clickEvent = new MouseEvent('click', {clientX: 10}); story.activePage_.element.dispatchEvent(clickEvent); await waitFor(() => { if (sendMessageStub.calledOnce) { expect(sendMessageStub).to.be.calledWithExactly( 'selectDocument', { previous: true, advancementMode: AdvancementMode.MANUAL_ADVANCE, } ); return true; } return false; }, 'sendMessageStub should be called'); }); }); }); describe('amp-story navigation', () => { it('should navigate when performing a navigational click', async () => { await createStoryWithPages(4, [ 'cover', 'page-1', 'page-2', 'page-3', ]); await story.layoutCallback(); // Click on right side of the screen to trigger page advancement. const clickEvent = new MouseEvent('click', {clientX: 200}); story.activePage_.element.dispatchEvent(clickEvent); expect(story.activePage_.element.id).to.equal('page-1'); }); it('should NOT navigate when clicking on a tappable element', async () => { await createStoryWithPages(4, [ 'cover', 'page-1', 'page-2', 'page-3', ]); await story.layoutCallback(); const tappableEl = win.document.createElement('target'); tappableEl.setAttribute('on', 'tap:cover.hide'); story.activePage_.element.appendChild(tappableEl); const clickEvent = new MouseEvent('click', {clientX: 200}); tappableEl.dispatchEvent(clickEvent); expect(story.activePage_.element.id).to.equal('cover'); }); it('should NOT navigate when clicking on a shadow DOM element', async () => { await createStoryWithPages(4, [ 'cover', 'page-1', 'page-2', 'page-3', ]); await story.layoutCallback(); const clickEvent = new MouseEvent('click', {clientX: 200}); story.shareMenu_.element_.dispatchEvent(clickEvent); expect(story.activePage_.element.id).to.equal('cover'); }); it('should NOT navigate when clicking on a CTA link', async () => { await createStoryWithPages(4, [ 'cover', 'page-1', 'page-2', 'page-3', ]); await story.layoutCallback(); const ctaLink = win.document.createElement('a'); ctaLink.setAttribute('role', 'link'); story.activePage_.element.appendChild(ctaLink); const clickEvent = new MouseEvent('click', {clientX: 200}); ctaLink.dispatchEvent(clickEvent); expect(story.activePage_.element.id).to.equal('cover'); }); }); describe('amp-access navigation', () => { it('should set the access state to true if next page blocked', async () => { await createStoryWithPages(4, [ 'cover', 'page-1', 'page-2', 'page-3', ]); await story.layoutCallback(); story .getPageById('page-1') .element.setAttribute('amp-access-hide', ''); await story.switchTo_('page-1'); expect(story.storeService_.get(StateProperty.ACCESS_STATE)).to.be .true; }); it('should not navigate if next page is blocked by paywall', async () => { await createStoryWithPages(4, [ 'cover', 'page-1', 'page-2', 'page-3', ]); await story.layoutCallback(); story .getPageById('page-1') .element.setAttribute('amp-access-hide', ''); await story.switchTo_('page-1'); expect(story.activePage_.element.id).to.equal('cover'); }); it('should navigate once the doc is reauthorized', async () => { await createStoryWithPages(4, [ 'cover', 'page-1', 'page-2', 'page-3', ]); let authorizedCallback; const fakeAccessService = { areFirstAuthorizationsCompleted: () => true, onApplyAuthorizations: (fn) => (authorizedCallback = fn), }; env.sandbox .stub(Services, 'accessServiceForDocOrNull') .resolves(fakeAccessService); // Navigates to a paywall protected page, and waits until the document // is successfuly reauthorized to navigate. await story.layoutCallback(); story .getPageById('page-1') .element.setAttribute('amp-access-hide', ''); await story.switchTo_('page-1'); story .getPageById('page-1') .element.removeAttribute('amp-access-hide'); authorizedCallback(); expect(story.activePage_.element.id).to.equal('page-1'); }); it('should hide the paywall once the doc is reauthorized', async () => { await createStoryWithPages(4, [ 'cover', 'page-1', 'page-2', 'page-3', ]); let authorizedCallback; const fakeAccessService = { areFirstAuthorizationsCompleted: () => true, onApplyAuthorizations: (fn) => (authorizedCallback = fn), }; env.sandbox .stub(Services, 'accessServiceForDocOrNull') .resolves(fakeAccessService); // Navigates to a paywall protected page, and waits until the document // is successfuly reauthorized to hide the access UI. await story.layoutCallback(); story .getPageById('page-1') .element.setAttribute('amp-access-hide', ''); await story.switchTo_('page-1'); expect(story.activePage_.element.id).to.equal('cover'); story .getPageById('page-1') .element.removeAttribute('amp-access-hide'); authorizedCallback(); expect(story.storeService_.get(StateProperty.ACCESS_STATE)).to.be .false; }); it('should not navigate on doc reauthorized if page still blocked', async () => { await createStoryWithPages(4, [ 'cover', 'page-1', 'page-2', 'page-3', ]); let authorizedCallback; const fakeAccessService = { areFirstAuthorizationsCompleted: () => true, onApplyAuthorizations: (fn) => (authorizedCallback = fn), }; env.sandbox .stub(Services, 'accessServiceForDocOrNull') .resolves(fakeAccessService); // Navigates to a paywall protected page, and does not navigate to that // page if the document has been reauthorized with insuficient rights. await story.layoutCallback(); story .getPageById('page-1') .element.setAttribute('amp-access-hide', ''); await story.switchTo_('page-1'); authorizedCallback(); expect(story.activePage_.element.id).to.equal('cover'); }); it('should show paywall on doc reauthorized if page still blocked', async () => { await createStoryWithPages(4, [ 'cover', 'page-1', 'page-2', 'page-3', ]); let authorizedCallback; const fakeAccessService = { areFirstAuthorizationsCompleted: () => true, onApplyAuthorizations: (fn) => (authorizedCallback = fn), }; env.sandbox .stub(Services, 'accessServiceForDocOrNull') .resolves(fakeAccessService); // Navigates to a paywall protected page, and does not hide the access UI // if the document has been reauthorized with insuficient rights. await story.layoutCallback(); story .getPageById('page-1') .element.setAttribute('amp-access-hide', ''); await story.switchTo_('page-1'); authorizedCallback(); expect(story.storeService_.get(StateProperty.ACCESS_STATE)).to.be .true; }); it('should block navigation if doc authorizations are pending', async () => { await createStoryWithPages(4, [ 'cover', 'page-1', 'page-2', 'page-3', ]); const fakeAccessService = { areFirstAuthorizationsCompleted: () => false, onApplyAuthorizations: () => {}, }; env.sandbox .stub(Services, 'accessServiceForDocOrNull') .resolves(fakeAccessService); // Navigates to a maybe protected page (has amp-access="" rule), but the // document authorizations are still pending. Asserts that it blocks the // navigation. await story.layoutCallback(); story .getPageById('page-1') .element.setAttribute('amp-access', 'random condition'); await story.switchTo_('page-1'); expect(story.activePage_.element.id).to.equal('cover'); }); it('should navigate only after the doc is first authorized', async () => { await createStoryWithPages(4, [ 'cover', 'page-1', 'page-2', 'page-3', ]); let authorizedCallback; const fakeAccessService = { areFirstAuthorizationsCompleted: () => false, onApplyAuthorizations: (fn) => (authorizedCallback = fn), }; env.sandbox .stub(Services, 'accessServiceForDocOrNull') .resolves(fakeAccessService); // Navigation to a maybe protected page (has amp-access="" rule) is // blocked until the authorizations are completed. await story.layoutCallback(); story .getPageById('page-1') .element.setAttribute('amp-access', 'random condition'); await story.switchTo_('page-1'); authorizedCallback(); expect(story.activePage_.element.id).to.equal('page-1'); }); }); describe('touch events handlers', () => { const getTouchOptions = (x, y) => { const touch = new Touch({ target: story.element, identifier: Date.now(), clientX: x, clientY: y, }); return {touches: [touch], bubbles: true}; }; const dispatchSwipeEvent = (deltaX, deltaY) => { story.element.dispatchEvent( new TouchEvent('touchstart', getTouchOptions(-10, -10)) ); story.element.dispatchEvent( new TouchEvent('touchmove', getTouchOptions(0, 0)) ); story.element.dispatchEvent( new TouchEvent('touchmove', getTouchOptions(deltaX, deltaY)) ); story.element.dispatchEvent( new TouchEvent('touchend', getTouchOptions(deltaX, deltaY)) ); }; describe('without #cap=swipe', () => { it('should handle h touch events at the story level', async () => { await createStoryWithPages(2); const touchmoveSpy = env.sandbox.spy(); story.win.document.addEventListener('touchmove', touchmoveSpy); dispatchSwipeEvent(100, 0); expect(touchmoveSpy).to.not.have.been.called; }); it('should handle v touch events at the story level', async () => { await createStoryWithPages(2); const touchmoveSpy = env.sandbox.spy(); story.win.document.addEventListener('touchmove', touchmoveSpy); dispatchSwipeEvent(0, 100); expect(touchmoveSpy).to.not.have.been.called; }); it('should trigger the navigation overlay', async () => { await createStoryWithPages(2); dispatchSwipeEvent(100, 0); await story.mutateElement(() => { const hintEl = story.element.querySelector( '.i-amphtml-story-hint-container' ); expect(hintEl).to.not.have.class('i-amphtml-hidden'); }); }); }); describe('with #cap=swipe', () => { before(() => (hasSwipeCapability = true)); after(() => (hasSwipeCapability = false)); it('should let h touch events bubble up to be forwarded', async () => { await createStoryWithPages(2); const touchmoveSpy = env.sandbox.spy(); story.win.document.addEventListener('touchmove', touchmoveSpy); dispatchSwipeEvent(100, 0); expect(touchmoveSpy).to.have.been.called; }); it('should let v touch events bubble up to be forwarded', async () => { await createStoryWithPages(2); const touchmoveSpy = env.sandbox.spy(); story.win.document.addEventListener('touchmove', touchmoveSpy); dispatchSwipeEvent(0, 100); expect(touchmoveSpy).to.have.been.called; }); it('should not trigger the navigation education overlay', async () => { await createStoryWithPages(2); dispatchSwipeEvent(100, 0); await story.mutateElement(() => { const hintEl = story.element.querySelector( '.i-amphtml-story-hint-container' ); expect(hintEl).to.not.exist; }); }); }); }); describe('amp-story rewriteStyles', () => { beforeEach(() => {}); afterEach(() => { /* toggleExperiment(win, 'amp-story-responsive-units', false) // launched: true */ false; }); it('should rewrite vw styles', async () => { await createStoryWithPages(1, ['cover']); const styleEl = win.document.createElement('style'); styleEl.setAttribute('amp-custom', ''); styleEl.textContent = 'foo {transform: translate3d(100vw, 0, 0);}'; win.document.head.appendChild(styleEl); story.buildCallback(); await story.layoutCallback(); expect(styleEl.textContent).to.equal( 'foo {transform: ' + 'translate3d(calc(100 * var(--story-page-vw)), 0, 0);}' ); }); it('should rewrite negative vh styles', async () => { await createStoryWithPages(1, ['cover']); const styleEl = win.document.createElement('style'); styleEl.setAttribute('amp-custom', ''); styleEl.textContent = 'foo {transform: translate3d(-100vh, 0, 0);}'; win.document.head.appendChild(styleEl); story.buildCallback(); await story.layoutCallback(); expect(styleEl.textContent).to.equal( 'foo {transform: ' + 'translate3d(calc(-100 * var(--story-page-vh)), 0, 0);}' ); }); }); }); describe('amp-story branching', () => { it('should advance to specified page with advanced-to attribute', async () => { toggleExperiment(win, 'amp-story-branching', true); await createStoryWithPages(4, ['cover', 'page-1', 'page-2', 'page-3']); await story.layoutCallback(); expect(story.activePage_.element.id).to.equal('cover'); story.getPageById('cover').element.setAttribute('advance-to', 'page-3'); story.activePage_.element.dispatchEvent( new MouseEvent('click', {clientX: 200}) ); expect(story.activePage_.element.id).to.equal('page-3'); toggleExperiment(win, 'amp-story-branching', false); }); it('should navigate to the target page when a goToPage action is executed', async () => { toggleExperiment(win, 'amp-story-branching', true); await createStoryWithPages(4, ['cover', 'page-1', 'page-2', 'page-3']); story.buildCallback(); await story.layoutCallback(); story.element.setAttribute('id', 'story'); const actionButton = createElementWithAttributes( win.document, 'button', {'id': 'actionButton', 'on': 'tap:story.goToPage(id=page-2)'} ); element.querySelector('#cover').appendChild(actionButton); // Click on the actionButton to trigger the goToPage action. actionButton.click(); // Next tick. await Promise.resolve(); expect(story.activePage_.element.id).to.equal('page-2'); toggleExperiment(win, 'amp-story-branching', false); }); it('should navigate back to the correct previous page after goToPage', async () => { toggleExperiment(win, 'amp-story-branching', true); await createStoryWithPages(4, ['cover', 'page-1', 'page-2', 'page-3']); story.buildCallback(); await story.layoutCallback(); story.element.setAttribute('id', 'story'); const actionButton = createElementWithAttributes( win.document, 'button', {'id': 'actionButton', 'on': 'tap:story.goToPage(id=page-2)'} ); element.querySelector('#cover').appendChild(actionButton); // Click on the actionButton to trigger the goToPage action. actionButton.click(); // Moves backwards. story.activePage_.element.dispatchEvent( new MouseEvent('click', {clientX: 0}) ); expect(story.activePage_.element.id).to.equal('cover'); toggleExperiment(win, 'amp-story-branching', false); }); it('should navigate back to the correct previous page after advance-to', async () => { toggleExperiment(win, 'amp-story-branching', true); await createStoryWithPages(4, ['cover', 'page-1', 'page-2', 'page-3']); await story.layoutCallback(); story.getPageById('cover').element.setAttribute('advance-to', 'page-3'); expect(story.activePage_.element.id).to.equal('cover'); story.activePage_.element.dispatchEvent( new MouseEvent('click', {clientX: 200}) ); // Move backwards. story.activePage_.element.dispatchEvent( new MouseEvent('click', {clientX: 0}) ); expect(story.activePage_.element.id).to.equal('cover'); toggleExperiment(win, 'amp-story-branching', false); }); it('should begin at the specified page fragment parameter value', async () => { win.location.hash = 'page=page-1'; await createStoryWithPages(4, ['cover', 'page-1', 'page-2', 'page-3']); await story.layoutCallback(); expect(story.activePage_.element.id).to.equal('page-1'); }); it('should begin at initial page when fragment parameter value is wrong', async () => { win.location.hash = 'page=BADVALUE'; await createStoryWithPages(4, ['cover', 'page-1', 'page-2', 'page-3']); await story.layoutCallback(); expect(story.activePage_.element.id).to.equal('cover'); }); it('should update browser history with the story navigation path', async () => { const pageCount = 2; await createStoryWithPages(pageCount, ['cover', 'page-1']); await story.layoutCallback(); story.activePage_.element.dispatchEvent( new MouseEvent('click', {clientX: 200}) ); expect(replaceStateStub).to.have.been.calledWith({ ampStoryNavigationPath: ['cover', 'page-1'], }); }); it('should correctly mark goToPage pages are distance 1', async () => { toggleExperiment(win, 'amp-story-branching', true); await createStoryWithPages(4, ['cover', 'page-1', 'page-2', 'page-3']); story.buildCallback(); await story.layoutCallback(); story.element.setAttribute('id', 'story'); const actionButton = createElementWithAttributes( win.document, 'button', {'id': 'actionButton', 'on': 'tap:story.goToPage(id=page-2)'} ); story.element.querySelector('#cover').appendChild(actionButton); const distanceGraph = story.getPagesByDistance_(); expect(distanceGraph[1].includes('page-2')).to.be.true; toggleExperiment(win, 'amp-story-branching', false); }); it('should correctly mark previous pages in the stack as distance 1', async () => { toggleExperiment(win, 'amp-story-branching', true); await createStoryWithPages(4, ['cover', 'page-1', 'page-2', 'page-3']); await story.layoutCallback(); story.getPageById('cover').element.setAttribute('advance-to', 'page-3'); story.activePage_.element.dispatchEvent( new MouseEvent('click', {clientX: 200}) ); const distanceGraph = story.getPagesByDistance_(); expect(distanceGraph[1].includes('cover')).to.be.true; toggleExperiment(win, 'amp-story-branching', false); }); }); describe('amp-story play/pause', () => { it('should set playable to true if page has autoadvance', async () => { await createStoryWithPages(1, ['cover'], true /** autoAdvance */); await story.layoutCallback(); await story.activePage_.element .signals() .whenSignal(CommonSignals.LOAD_END); expect( story.storeService_.get(StateProperty.STORY_HAS_PLAYBACK_UI_STATE) ).to.be.true; expect( story.storeService_.get( StateProperty.PAGE_HAS_ELEMENTS_WITH_PLAYBACK_STATE ) ).to.be.true; }); it('should set playable to false if page does not have playable', async () => { await createStoryWithPages(1, ['cover'], false /** autoAdvance */); await story.layoutCallback(); await story.activePage_.element .signals() .whenSignal(CommonSignals.LOAD_END); expect( story.storeService_.get(StateProperty.STORY_HAS_PLAYBACK_UI_STATE) ).to.be.false; expect( story.storeService_.get( StateProperty.PAGE_HAS_ELEMENTS_WITH_PLAYBACK_STATE ) ).to.be.false; }); }); } );