diff --git a/build-system/global-configs/experiments-const.json b/build-system/global-configs/experiments-const.json index e612c4096064..edac63a5101c 100644 --- a/build-system/global-configs/experiments-const.json +++ b/build-system/global-configs/experiments-const.json @@ -1,5 +1,6 @@ { "BENTO_AUTO_UPGRADE": false, "INI_LOAD_INOB": false, + "V1_IMG_DEFERRED_BUILD": false, "WITHIN_VIEWPORT_INOB": false } diff --git a/build-system/tasks/presubmit-checks.js b/build-system/tasks/presubmit-checks.js index 8000d21ae158..07b0567a35b0 100644 --- a/build-system/tasks/presubmit-checks.js +++ b/build-system/tasks/presubmit-checks.js @@ -147,6 +147,18 @@ const forbiddenTerms = { 'extensions/amp-analytics/0.1/requests.js', ], }, + '\\.buildInternal': { + message: 'can only be called by the framework', + allowlist: [ + 'src/service/builder.js', + 'src/service/resource.js', + 'testing/iframe.js', + ], + }, + 'getBuilderForDoc': { + message: 'can only be used by the runtime', + allowlist: ['src/custom-element.js', 'src/service/builder.js'], + }, // Service factories that should only be installed once. 'installActionServiceForDoc': { message: privateServiceFactory, @@ -310,6 +322,7 @@ const forbiddenTerms = { 'src/chunk.js', 'src/element-service.js', 'src/service.js', + 'src/service/builder.js', 'src/service/cid-impl.js', 'src/service/origin-experiments-impl.js', 'src/service/template-impl.js', @@ -1387,6 +1400,7 @@ function hasAnyTerms(srcFile) { /^test-/.test(basename) || /^_init_tests/.test(basename) || /_test\.js$/.test(basename) || + /testing\//.test(srcFile) || /storybook\/[^/]+\.js$/.test(srcFile); if (!isTestFile) { hasSrcInclusiveTerms = matchTerms(srcFile, forbiddenTermsSrcInclusive); diff --git a/builtins/amp-img.js b/builtins/amp-img.js index 85c579d2c02d..fc5480b7cc1d 100644 --- a/builtins/amp-img.js +++ b/builtins/amp-img.js @@ -16,6 +16,7 @@ import {BaseElement} from '../src/base-element'; import {Layout, isLayoutSizeDefined} from '../src/layout'; +import {ReadyState} from '../src/ready-state'; import {Services} from '../src/services'; import {dev} from '../src/log'; import {guaranteeSrcForSrcsetUnsupportedBrowsers} from '../src/utils/img'; @@ -45,11 +46,39 @@ const ATTRIBUTES_TO_PROPAGATE = [ ]; export class AmpImg extends BaseElement { + /** @override @nocollapse */ + static V1() { + return V1_IMG_DEFERRED_BUILD; + } + /** @override @nocollapse */ static prerenderAllowed() { return true; } + /** @override @nocollapse */ + static getPreconnects(element) { + const src = element.getAttribute('src'); + if (src) { + return [src]; + } + + // NOTE(@wassgha): since parseSrcset is computationally expensive and can + // not be inside the `buildCallback`, we went with preconnecting to the + // `src` url if it exists or the first srcset url. + const srcset = element.getAttribute('srcset'); + if (srcset) { + // We try to find the first url in the srcset + const srcseturl = /\S+/.exec(srcset); + // Connect to the first url if it exists + if (srcseturl) { + return [srcseturl[0]]; + } + } + + return null; + } + /** @param {!AmpElement} element */ constructor(element) { super(element); @@ -106,6 +135,10 @@ export class AmpImg extends BaseElement { if (!IS_ESM) { guaranteeSrcForSrcsetUnsupportedBrowsers(this.img_); } + + if (AmpImg.V1() && !this.img_.complete) { + this.setReadyState(ReadyState.LOADING); + } } } @@ -260,6 +293,38 @@ export class AmpImg extends BaseElement { return false; } + /** @override */ + buildCallback() { + if (!AmpImg.V1()) { + return; + } + + // A V1 amp-img loads and reloads automatically. + this.setReadyState(ReadyState.LOADING); + this.initialize_(); + const img = dev().assertElement(this.img_); + if (img.complete) { + this.setReadyState(ReadyState.COMPLETE); + this.firstLayoutCompleted(); + this.hideFallbackImg_(); + } + listen(img, 'load', () => { + this.setReadyState(ReadyState.COMPLETE); + this.firstLayoutCompleted(); + this.hideFallbackImg_(); + }); + listen(img, 'error', (reason) => { + this.setReadyState(ReadyState.ERROR, reason); + this.onImgLoadingError_(); + }); + } + + /** @override */ + ensureLoaded() { + const img = dev().assertElement(this.img_); + img.loading = 'eager'; + } + /** @override */ layoutCallback() { this.initialize_(); @@ -275,6 +340,12 @@ export class AmpImg extends BaseElement { /** @override */ unlayoutCallback() { + if (AmpImg.V1()) { + // TODO(#31915): Reconsider if this is still desired for V1. This helps + // with network interruption when a document is inactivated. + return; + } + if (this.unlistenError_) { this.unlistenError_(); this.unlistenError_ = null; @@ -319,10 +390,8 @@ export class AmpImg extends BaseElement { !this.allowImgLoadFallback_ && this.img_.classList.contains('i-amphtml-ghost') ) { - this.getVsync().mutate(() => { - this.img_.classList.remove('i-amphtml-ghost'); - this.toggleFallback(false); - }); + this.img_.classList.remove('i-amphtml-ghost'); + this.toggleFallback(false); } } @@ -332,13 +401,11 @@ export class AmpImg extends BaseElement { */ onImgLoadingError_() { if (this.allowImgLoadFallback_) { - this.getVsync().mutate(() => { - this.img_.classList.add('i-amphtml-ghost'); - this.toggleFallback(true); - // Hide placeholders, as browsers that don't support webp - // Would show the placeholder underneath a transparent fallback - this.togglePlaceholder(false); - }); + this.img_.classList.add('i-amphtml-ghost'); + this.toggleFallback(true); + // Hide placeholders, as browsers that don't support webp + // Would show the placeholder underneath a transparent fallback + this.togglePlaceholder(false); this.allowImgLoadFallback_ = false; } } diff --git a/extensions/amp-auto-ads/0.1/placement.js b/extensions/amp-auto-ads/0.1/placement.js index 270e94a7349f..8ec4a7ff8d37 100644 --- a/extensions/amp-auto-ads/0.1/placement.js +++ b/extensions/amp-auto-ads/0.1/placement.js @@ -213,7 +213,7 @@ export class Placement { return ( whenUpgradedToCustomElement(this.getAdElement()) // Responsive ads set their own size when built. - .then(() => this.getAdElement().whenBuilt()) + .then(() => this.getAdElement().build()) .then(() => { const resized = !this.getAdElement().classList.contains( 'i-amphtml-layout-awaiting-size' @@ -231,7 +231,7 @@ export class Placement { // synchronously. So we explicitly wait for CustomElement to be // ready. return whenUpgradedToCustomElement(this.getAdElement()) - .then(() => this.getAdElement().whenBuilt()) + .then(() => this.getAdElement().build()) .then(() => { return this.mutator_.requestChangeSize( this.getAdElement(), diff --git a/extensions/amp-consent/0.1/consent-ui.js b/extensions/amp-consent/0.1/consent-ui.js index 4abe53a861f0..be6c110cc4f7 100644 --- a/extensions/amp-consent/0.1/consent-ui.js +++ b/extensions/amp-consent/0.1/consent-ui.js @@ -295,7 +295,7 @@ export class ConsentUI { // at build time. (see #18841). if (isAmpElement(this.ui_)) { whenUpgradedToCustomElement(this.ui_) - .then(() => this.ui_.whenBuilt()) + .then(() => this.ui_.build()) .then(() => show()); } else { show(); diff --git a/extensions/amp-form/0.1/amp-form.js b/extensions/amp-form/0.1/amp-form.js index 7e6b34ff4a95..a3e5f61b3e8e 100644 --- a/extensions/amp-form/0.1/amp-form.js +++ b/extensions/amp-form/0.1/amp-form.js @@ -399,7 +399,7 @@ export class AmpForm { EXTERNAL_DEPS.join(',') ); // Wait for an element to be built to make sure it is ready. - const promises = toArray(depElements).map((el) => el.whenBuilt()); + const promises = toArray(depElements).map((el) => el.build()); return (this.dependenciesPromise_ = this.waitOnPromisesOrTimeout_( promises, 2000 diff --git a/extensions/amp-form/0.1/test/test-amp-form.js b/extensions/amp-form/0.1/test/test-amp-form.js index 33d19ef481d6..1de796b9f27f 100644 --- a/extensions/amp-form/0.1/test/test-amp-form.js +++ b/extensions/amp-form/0.1/test/test-amp-form.js @@ -15,7 +15,6 @@ */ import '../../../amp-mustache/0.1/amp-mustache'; -import '../../../amp-selector/0.1/amp-selector'; import * as xhrUtils from '../../../../src/utils/xhr-utils'; import {ActionService} from '../../../../src/service/action-impl'; import {ActionTrust} from '../../../../src/action-constants'; @@ -25,6 +24,7 @@ import { AmpFormService, checkUserValidityAfterInteraction_, } from '../amp-form'; +import {AmpSelector} from '../../../amp-selector/0.1/amp-selector'; import { AsyncInputAttributes, AsyncInputClasses, @@ -2287,74 +2287,70 @@ describes.repeated( }); }); - it('should submit after timeout of waiting for amp-selector', function () { + it('should submit after timeout of waiting for amp-selector', async function () { expectAsyncConsoleError(/Form submission failed/); this.timeout(3000); - return getAmpForm(getForm()).then((ampForm) => { - const form = ampForm.form_; - const selector = createElement('amp-selector'); - selector.setAttribute('name', 'color'); - form.appendChild(selector); - env.sandbox - .stub(selector, 'whenBuilt') - .returns(new Promise((unusedResolve) => {})); - env.sandbox.spy(ampForm, 'handleSubmitAction_'); + const ampForm = await getAmpForm(getForm()); + const form = ampForm.form_; + const selector = createElement('amp-selector'); + selector.setAttribute('name', 'color'); - const submitPromise = ampForm.actionHandler_({ - method: 'submit', - satisfiesTrust: () => true, - }); - expect(ampForm.handleSubmitAction_).to.have.not.been.called; - return timer - .promise(1) - .then(() => { - expect(ampForm.handleSubmitAction_).to.have.not.been.called; - return timer.promise(2000); - }) - .then(() => { - expect(ampForm.handleSubmitAction_).to.have.been.calledOnce; - return submitPromise; - }); + env.sandbox + .stub(AmpSelector.prototype, 'buildCallback') + .returns(new Promise((unusedResolve) => {})); + env.sandbox.spy(ampForm, 'handleSubmitAction_'); + + form.appendChild(selector); + + const submitPromise = ampForm.actionHandler_({ + method: 'submit', + satisfiesTrust: () => true, }); + expect(ampForm.handleSubmitAction_).to.have.not.been.called; + + await timer.promise(1); + expect(ampForm.handleSubmitAction_).to.have.not.been.called; + + await timer.promise(2000); + expect(ampForm.handleSubmitAction_).to.have.been.calledOnce; + + await submitPromise; }); - it('should wait for amp-selector to build before submitting', () => { - return getAmpForm(getForm()).then((ampForm) => { - let builtPromiseResolver_; - const form = ampForm.form_; - const selector = createElement('amp-selector'); - selector.setAttribute('name', 'color'); - form.appendChild(selector); + it('should wait for amp-selector to build before submitting', async () => { + const ampForm = await getAmpForm(getForm()); - env.sandbox.stub(selector, 'whenBuilt').returns( - new Promise((resolve) => { - builtPromiseResolver_ = resolve; - }) - ); - env.sandbox.stub(ampForm.xhr_, 'fetch').resolves(); - env.sandbox.spy(ampForm, 'handleSubmitAction_'); + let builtPromiseResolver_; + const form = ampForm.form_; + const selector = createElement('amp-selector'); + selector.setAttribute('name', 'color'); - ampForm.actionHandler_({ - method: 'submit', - satisfiesTrust: () => true, - }); - expect(ampForm.handleSubmitAction_).to.have.not.been.called; - return timer - .promise(1) - .then(() => { - expect(ampForm.handleSubmitAction_).to.have.not.been.called; - return timer.promise(100); - }) - .then(() => { - expect(ampForm.handleSubmitAction_).to.have.not.been.called; - builtPromiseResolver_(); - return timer.promise(1); - }) - .then(() => { - expect(ampForm.handleSubmitAction_).to.have.been.calledOnce; - }); + env.sandbox.stub(AmpSelector.prototype, 'buildCallback').returns( + new Promise((resolve) => { + builtPromiseResolver_ = resolve; + }) + ); + env.sandbox.stub(ampForm.xhr_, 'fetch').resolves(); + env.sandbox.spy(ampForm, 'handleSubmitAction_'); + + form.appendChild(selector); + + ampForm.actionHandler_({ + method: 'submit', + satisfiesTrust: () => true, }); + expect(ampForm.handleSubmitAction_).to.have.not.been.called; + + await timer.promise(1); + expect(ampForm.handleSubmitAction_).to.have.not.been.called; + + await timer.promise(100); + expect(ampForm.handleSubmitAction_).to.have.not.been.called; + builtPromiseResolver_(); + + await timer.promise(1); + expect(ampForm.handleSubmitAction_).to.have.been.calledOnce; }); describe('Var Substitution', () => { diff --git a/extensions/amp-sticky-ad/1.0/amp-sticky-ad.js b/extensions/amp-sticky-ad/1.0/amp-sticky-ad.js index 3cd32f5ef118..143dbeffb66e 100644 --- a/extensions/amp-sticky-ad/1.0/amp-sticky-ad.js +++ b/extensions/amp-sticky-ad/1.0/amp-sticky-ad.js @@ -71,7 +71,7 @@ class AmpStickyAd extends AMP.BaseElement { dev().assertElement(this.ad_) ) .then((ad) => { - return ad.whenBuilt(); + return ad.build(); }) .then(() => { return this.mutateElement(() => { @@ -188,7 +188,7 @@ class AmpStickyAd extends AMP.BaseElement { */ scheduleLayoutForAd_() { whenUpgradedToCustomElement(dev().assertElement(this.ad_)).then((ad) => { - ad.whenBuilt().then(this.layoutAd_.bind(this)); + ad.build().then(() => this.layoutAd_()); }); } diff --git a/extensions/amp-sticky-ad/1.0/test/test-amp-sticky-ad.js b/extensions/amp-sticky-ad/1.0/test/test-amp-sticky-ad.js index 0cc015272179..62fb1bfe3672 100644 --- a/extensions/amp-sticky-ad/1.0/test/test-amp-sticky-ad.js +++ b/extensions/amp-sticky-ad/1.0/test/test-amp-sticky-ad.js @@ -210,27 +210,26 @@ describes.realWin( }); }); - it('should wait for built and render-start signals', () => { + it('should wait for built and render-start signals', async () => { impl.vsync_.mutate = function (callback) { callback(); }; const layoutAdSpy = env.sandbox.spy(impl, 'layoutAd_'); impl.scheduleLayoutForAd_(); expect(layoutAdSpy).to.not.been.called; - impl.ad_.signals().signal('built'); - return adUpgradedToCustomElementPromise.then(() => { - return impl.ad_ - .signals() - .whenSignal('built') - .then(() => { - expect(layoutAdSpy).to.be.called; - expect(ampStickyAd).to.not.have.attribute('visible'); - impl.ad_.signals().signal('render-start'); - return poll('visible attribute must be set', () => { - return ampStickyAd.hasAttribute('visible'); - }); - }); - }); + + await adUpgradedToCustomElementPromise; + const ad = impl.ad_; + ad.signals().signal('built'); + await ad.signals().whenSignal('built'); + await new Promise(setTimeout); + expect(layoutAdSpy).to.be.called; + expect(ampStickyAd).to.not.have.attribute('visible'); + + ad.signals().signal('render-start'); + await poll('visible attribute must be set', () => + ampStickyAd.hasAttribute('visible') + ); }); it('should not allow container to be set semi-transparent', () => { diff --git a/extensions/amp-story/1.0/amp-story.js b/extensions/amp-story/1.0/amp-story.js index a5cc33cac5e3..d8bb03b4ceba 100644 --- a/extensions/amp-story/1.0/amp-story.js +++ b/extensions/amp-story/1.0/amp-story.js @@ -1047,7 +1047,7 @@ export class AmpStory extends AMP.BaseElement { ); if (!this.getAmpDoc().hasBeenVisible()) { return whenUpgradedToCustomElement(initialPageEl).then(() => { - return initialPageEl.whenBuilt(); + return initialPageEl.build(); }); } diff --git a/extensions/amp-story/1.0/page-advancement.js b/extensions/amp-story/1.0/page-advancement.js index 557365170e61..44611121ab2c 100644 --- a/extensions/amp-story/1.0/page-advancement.js +++ b/extensions/amp-story/1.0/page-advancement.js @@ -1011,10 +1011,9 @@ export class MediaBasedAdvancement extends AdvancementConfig { super.start(); // Prevents race condition when checking for video interface classname. - (this.element_.whenBuilt - ? this.element_.whenBuilt() - : Promise.resolve() - ).then(() => this.startWhenBuilt_()); + (this.element_.build ? this.element_.build() : Promise.resolve()).then(() => + this.startWhenBuilt_() + ); } /** @private */ diff --git a/src/base-element.js b/src/base-element.js index 3f7975e37bab..250775fb89c3 100644 --- a/src/base-element.js +++ b/src/base-element.js @@ -104,6 +104,39 @@ import {isArray, toWin} from './types'; * @implements {BaseElementInterface} */ export class BaseElement { + /** + * Whether this element supports V1 protocol, which includes: + * 1. Layout/unlayout are not managed by the runtime, but instead are + * implemented by the element as needed. + * 2. The element can defer its build until later. See `deferredBuild`. + * 3. The construction of the element is delayed until build. + * + * Notice, in this mode `layoutCallback`, `pauseCallback`, `onLayoutMeasure`, + * `getLayoutSize`, and other methods are deprecated. The element must + * independently handle each of these states internally. + * + * @return {boolean} + * @nocollapse + */ + static V1() { + return false; + } + + /** + * Whether this element supports deferred-build mode. In this mode, the + * element's build will be deferred roughly based on the + * `content-visibility: auto` rules. + * + * Only used for V1 elements. + * + * @param {!AmpElement} unusedElement + * @return {boolean} + * @nocollapse + */ + static deferredBuild(unusedElement) { + return true; + } + /** * Subclasses can override this method to opt-in into being called to * prerender when document itself is not yet visible (pre-render mode). @@ -135,6 +168,36 @@ export class BaseElement { return {}; } + /** + * This is the element's build priority. + * + * The lower the number, the higher the priority. + * + * The default priority for base elements is LayoutPriority.CONTENT. + * + * @param {!AmpElement} unusedElement + * @return {number} + * @nocollapse + */ + static getBuildPriority(unusedElement) { + return LayoutPriority.CONTENT; + } + + /** + * Called by the framework to give the element a chance to preconnect to + * hosts and prefetch resources it is likely to need. May be called + * multiple times because connections can time out. + * + * Returns an array of URLs to be preconnected. + * + * @param {!AmpElement} unusedElement + * @return {?Array} + * @nocollapse + */ + static getPreconnects(unusedElement) { + return null; + } + /** @param {!AmpElement} element */ constructor(element) { /** @public @const {!Element} */ @@ -194,6 +257,7 @@ export class BaseElement { * * The default priority for base elements is LayoutPriority.CONTENT. * @return {number} + * TODO(#31915): remove once V1 migration is complete. */ getLayoutPriority() { return LayoutPriority.CONTENT; @@ -226,6 +290,7 @@ export class BaseElement { * mainly affects fixed-position elements that are adjusted to be always * relative to the document position in the viewport. * @return {!./layout-rect.LayoutRectDef} + * TODO(#31915): remove once V1 migration is complete. */ getLayoutBox() { return this.element.getLayoutBox(); @@ -234,6 +299,7 @@ export class BaseElement { /** * Returns a previously measured layout size. * @return {!./layout-rect.LayoutSizeDef} + * TODO(#31915): remove once V1 migration is complete. */ getLayoutSize() { return this.element.getLayoutSize(); @@ -344,6 +410,7 @@ export class BaseElement { * hosts and prefetch resources it is likely to need. May be called * multiple times because connections can time out. * @param {boolean=} opt_onLayout + * TODO(#31915): remove once V1 migration is complete. */ preconnectCallback(opt_onLayout) { // Subclasses may override. @@ -414,6 +481,26 @@ export class BaseElement { return false; } + /** + * Ensure that the element is being eagerly loaded. + * + * Only used for V1 elements. + */ + ensureLoaded() {} + + /** + * Update the current `readyState`. + * + * Only used for V1 elements. + * + * @param {!./ready-state.ReadyState} state + * @param {*=} opt_failure + * @final + */ + setReadyState(state, opt_failure) { + this.element.setReadyStateInternal(state, opt_failure); + } + /** * Subclasses can override this method to opt-in into receiving additional * {@link layoutCallback} calls. Note that this method is not consulted for @@ -435,6 +522,7 @@ export class BaseElement { * {@link isRelayoutNeeded} method. * * @return {!Promise} + * TODO(#31915): remove once V1 migration is complete. */ layoutCallback() { return Promise.resolve(); @@ -457,6 +545,7 @@ export class BaseElement { * Requests the element to stop its activity when the document goes into * inactive state. The scope is up to the actual component. Among other * things the active playback of video or audio content must be stopped. + * TODO(#31915): remove once V1 migration is complete. */ pauseCallback() {} @@ -464,6 +553,7 @@ export class BaseElement { * Requests the element to resume its activity when the document returns from * an inactive state. The scope is up to the actual component. Among other * things the active playback of video or audio content may be resumed. + * TODO(#31915): remove once V1 migration is complete. */ resumeCallback() {} @@ -474,6 +564,7 @@ export class BaseElement { * {@link layoutCallback} in case document becomes active again. * * @return {boolean} + * TODO(#31915): remove once V1 migration is complete. */ unlayoutCallback() { return false; @@ -483,6 +574,7 @@ export class BaseElement { * Subclasses can override this method to opt-in into calling * {@link unlayoutCallback} when paused. * @return {boolean} + * TODO(#31915): remove once V1 migration is complete. */ unlayoutOnPause() { return false; @@ -947,6 +1039,7 @@ export class BaseElement { * This may currently not work with extended elements. Please file * an issue if that is required. * @public + * TODO(#31915): remove once V1 migration is complete. */ onLayoutMeasure() {} diff --git a/src/custom-element.js b/src/custom-element.js index 01f9b26b3f2c..9b68127f14c9 100644 --- a/src/custom-element.js +++ b/src/custom-element.js @@ -26,6 +26,7 @@ import { isLoadingAllowed, } from './layout'; import {MediaQueryProps} from './utils/media-query-props'; +import {ReadyState} from './ready-state'; import {ResourceState} from './service/resource'; import {Services} from './services'; import {Signals} from './utils/signals'; @@ -36,6 +37,7 @@ import { reportError, } from './error'; import {dev, devAssert, rethrowAsync, user, userAssert} from './log'; +import {getBuilderForDoc} from './service/builder'; import {getIntersectionChangeEntry} from './utils/intersection-observer-3p-host'; import {getMode} from './mode'; import {setStyle} from './style'; @@ -138,8 +140,8 @@ function createBaseCustomElementClass(win) { /** @private {?Promise} */ this.buildingPromise_ = null; - /** @type {string} */ - this.readyState = 'loading'; + /** @private {!ReadyState} */ + this.readyState_ = ReadyState.UPGRADING; /** @type {boolean} */ this.everAttached = false; @@ -266,6 +268,11 @@ function createBaseCustomElementClass(win) { } } + /** @return {!ReadyState} */ + get readyState() { + return this.readyState_; + } + /** @return {!Signals} */ signals() { return this.signals_; @@ -339,7 +346,7 @@ function createBaseCustomElementClass(win) { // attached to the DOM. But, if it hadn't yet upgraded from // ElementStub, we couldn't. Now that it's upgraded from a stub, go // ahead and do the full upgrade. - this.tryUpgrade_(); + this.upgradeOrSchedule_(); } } @@ -362,11 +369,14 @@ function createBaseCustomElementClass(win) { this.impl_ = newImpl; this.upgradeDelayMs_ = win.Date.now() - upgradeStartTime; this.upgradeState_ = UpgradeState.UPGRADED; + this.setReadyStateInternal(ReadyState.BUILDING); this.classList.remove('amp-unresolved'); this.classList.remove('i-amphtml-unresolved'); this.assertLayout_(); this.dispatchCustomEventForTesting(AmpEvents.ATTACHED); - this.getResources().upgraded(this); + if (!this.V1()) { + this.getResources().upgraded(this); + } this.signals_.signal(CommonSignals.UPGRADED); } @@ -389,27 +399,19 @@ function createBaseCustomElementClass(win) { } /** - * Whether the element has been built. A built element had its - * {@link buildCallback} method successfully invoked. - * @return {boolean} - * @final - */ - isBuilt() { - return this.built_; - } - - /** - * Returns the promise that's resolved when the element has been built. If - * the build fails, the resulting promise is rejected. - * @return {!Promise} + * Get the priority to build the element. + * @return {number} */ - whenBuilt() { - return this.signals_.whenSignal(CommonSignals.BUILT); + getBuildPriority() { + return this.implClass_ + ? this.implClass_.getBuildPriority(this) + : LayoutPriority.BACKGROUND; } /** * Get the priority to load the element. * @return {number} + * TODO(#31915): remove once V1 migration is complete. */ getLayoutPriority() { return this.impl_ @@ -434,6 +436,25 @@ function createBaseCustomElementClass(win) { return !!this.buildingPromise_; } + /** + * Whether the element has been built. A built element had its + * {@link buildCallback} method successfully invoked. + * @return {boolean} + * @final + */ + isBuilt() { + return this.built_; + } + + /** + * Returns the promise that's resolved when the element has been built. If + * the build fails, the resulting promise is rejected. + * @return {!Promise} + */ + whenBuilt() { + return this.signals_.whenSignal(CommonSignals.BUILT); + } + /** * Requests or requires the element to be built. The build is done by * invoking {@link BaseElement.buildCallback} method. @@ -443,45 +464,69 @@ function createBaseCustomElementClass(win) { * * @return {?Promise} * @final + * @restricted */ buildInternal() { assertNotTemplate(this); - devAssert(this.isUpgraded(), 'Cannot build unupgraded element'); + devAssert(this.implClass_, 'Cannot build unupgraded element'); if (this.buildingPromise_) { return this.buildingPromise_; } - return (this.buildingPromise_ = new Promise((resolve, reject) => { - const impl = this.impl_; + + this.setReadyStateInternal(ReadyState.BUILDING); + + // Create the instance. + const implPromise = this.createImpl_(); + + // Wait for consent. + const consentPromise = implPromise.then(() => { const policyId = this.getConsentPolicy_(); if (!policyId) { - resolve(impl.buildCallback()); - } else { - Services.consentPolicyServiceForDocOrNull(this) - .then((policy) => { - if (!policy) { - return true; - } - return policy.whenPolicyUnblock(/** @type {string} */ (policyId)); - }) - .then((shouldUnblock) => { - if (shouldUnblock) { - resolve(impl.buildCallback()); - } else { - reject(blockedByConsentError()); - } - }); + return; } - }).then( + return Services.consentPolicyServiceForDocOrNull(this) + .then((policy) => { + if (!policy) { + return true; + } + return policy.whenPolicyUnblock(policyId); + }) + .then((shouldUnblock) => { + if (!shouldUnblock) { + throw blockedByConsentError(); + } + }); + }); + + // Build callback. + const buildPromise = consentPromise.then(() => + devAssert(this.impl_).buildCallback() + ); + + // Build the element. + return (this.buildingPromise_ = buildPromise.then( () => { - this.preconnect(/* onLayout */ false); this.built_ = true; this.classList.add('i-amphtml-built'); this.classList.remove('i-amphtml-notbuilt'); this.classList.remove('amp-notbuilt'); this.signals_.signal(CommonSignals.BUILT); + + if (this.V1()) { + // If the implementation hasn't changed the readyState to, e.g., + // "loading", then update the state to "complete". + if (this.readyState_ == ReadyState.BUILDING) { + this.setReadyStateInternal(ReadyState.COMPLETE); + } + } else { + this.setReadyStateInternal(ReadyState.LOADING); + this.preconnect(/* onLayout */ false); + } + if (this.isConnected_) { this.connected_(); } + if (this.actionQueue_) { // Only schedule when the queue is not empty, which should be // the case 99% of the time. @@ -502,6 +547,11 @@ function createBaseCustomElementClass(win) { CommonSignals.BUILT, /** @type {!Error} */ (reason) ); + + if (this.V1()) { + this.setReadyStateInternal(ReadyState.ERROR, reason); + } + if (!isBlockedByConsent(reason)) { reportError(reason, this); } @@ -510,6 +560,26 @@ function createBaseCustomElementClass(win) { )); } + /** + * @return {!Promise} + */ + build() { + if (this.buildingPromise_) { + return this.buildingPromise_; + } + + const readyPromise = this.signals_.whenSignal( + CommonSignals.READY_TO_UPGRADE + ); + return readyPromise.then(() => { + if (this.V1()) { + const builder = getBuilderForDoc(this.getAmpDoc()); + builder.scheduleAsap(this); + } + return this.whenBuilt(); + }); + } + /** * @return {!Promise} * @final @@ -519,14 +589,19 @@ function createBaseCustomElementClass(win) { } /** - * Ensure that element is eagerly loaded. + * Ensure that the element is eagerly loaded. * * @param {number=} opt_parentPriority * @return {!Promise} * @final */ ensureLoaded(opt_parentPriority) { - return this.whenBuilt().then(() => { + return this.build().then(() => { + if (this.V1()) { + this.impl_.ensureLoaded(); + return this.whenLoaded(); + } + const resource = this.getResource_(); if (resource.getState() == ResourceState.LAYOUT_COMPLETE) { return; @@ -550,10 +625,57 @@ function createBaseCustomElementClass(win) { }); } + /** + * Update the internal ready state. + * + * @param {!ReadyState} state + * @param {*=} opt_failure + * @protected + * @final + */ + setReadyStateInternal(state, opt_failure) { + if (state === this.readyState_) { + return; + } + + this.readyState_ = state; + + if (!this.V1()) { + return; + } + + switch (state) { + case ReadyState.LOADING: + this.signals_.signal(CommonSignals.LOAD_START); + this.signals_.reset(CommonSignals.UNLOAD); + this.classList.add('i-amphtml-layout'); + // Potentially start the loading indicator. + this.toggleLoading(true); + this.dispatchCustomEventForTesting(AmpEvents.LOAD_START); + return; + case ReadyState.COMPLETE: + this.signals_.signal(CommonSignals.LOAD_END); + this.classList.add('i-amphtml-layout'); + this.toggleLoading(false); + dom.dispatchCustomEvent(this, 'load'); + this.dispatchCustomEventForTesting(AmpEvents.LOAD_END); + return; + case ReadyState.ERROR: + this.signals_.rejectSignal( + CommonSignals.LOAD_END, + /** @type {!Error} */ (opt_failure) + ); + this.toggleLoading(false); + dom.dispatchCustomEvent(this, 'error'); + return; + } + } + /** * Called to instruct the element to preconnect to hosts it uses during * layout. * @param {boolean} onLayout Whether this was called after a layout. + * TODO(#31915): remove once V1 migration is complete. */ preconnect(onLayout) { devAssert(this.isUpgraded()); @@ -572,6 +694,26 @@ function createBaseCustomElementClass(win) { } } + /** + * See `BaseElement.V1()`. + * + * @return {boolean} + * @final + */ + V1() { + return this.implClass_ ? this.implClass_.V1() : false; + } + + /** + * See `BaseElement.deferredBuild()`. + * + * @return {boolean} + * @final + */ + deferredBuild() { + return this.implClass_ ? this.implClass_.deferredBuild(this) : false; + } + /** * Whether the custom element declares that it has to be fixed. * @return {boolean} @@ -829,6 +971,8 @@ function createBaseCustomElementClass(win) { } this.connected_(); this.dispatchCustomEventForTesting(AmpEvents.ATTACHED); + } else if (this.implClass_ && this.V1()) { + this.upgradeOrSchedule_(); } } else { this.everAttached = true; @@ -843,7 +987,7 @@ function createBaseCustomElementClass(win) { reportError(e, this); } if (this.implClass_) { - this.tryUpgrade_(); + this.upgradeOrSchedule_(); } if (!this.isUpgraded()) { this.classList.add('amp-unresolved'); @@ -870,6 +1014,45 @@ function createBaseCustomElementClass(win) { this.classList.remove('i-amphtml-layout-awaiting-size'); } + /** + * Upgrade or schedule element based on V1. + * @private @final + */ + upgradeOrSchedule_() { + if (!this.V1()) { + this.tryUpgrade_(); + return; + } + if (this.buildingPromise_) { + // Already building. + return; + } + + // Schedule build. + this.setReadyStateInternal(ReadyState.BUILDING); + const builder = getBuilderForDoc(this.getAmpDoc()); + builder.schedule(this); + + // Schedule preconnects. + const urls = this.implClass_.getPreconnects(this); + if (urls && urls.length > 0) { + // If we do early preconnects we delay them a bit. This is kind of + // an unfortunate trade off, but it seems faster, because the DOM + // operations themselves are not free and might delay + const ampdoc = this.getAmpDoc(); + startupChunk(ampdoc, () => { + const {win} = ampdoc; + if (!win) { + return; + } + const preconnect = Services.preconnectFor(win); + urls.forEach((url) => + preconnect.url(ampdoc, url, /* alsoConnecting */ false) + ); + }); + } + } + /** * Try to upgrade the element with the provided implementation. * @return {!Promise|undefined} @@ -888,6 +1071,7 @@ function createBaseCustomElementClass(win) { this.implClass_, 'Implementation must not be a stub' ); + const impl = new Ctor(this); // The `upgradeCallback` only allows redirect once for the top-level @@ -968,6 +1152,10 @@ function createBaseCustomElementClass(win) { if (this.impl_) { this.impl_.detachedCallback(); } + if (!this.built_ && this.V1()) { + const builder = getBuilderForDoc(this.getAmpDoc()); + builder.unschedule(this); + } this.toggleLoading(false); this.disposeMediaAttrs_(); } @@ -1129,10 +1317,23 @@ function createBaseCustomElementClass(win) { * @return {!Promise} */ getImpl(waitForBuild = true) { - const waitFor = waitForBuild ? this.whenBuilt() : this.whenUpgraded(); + const waitFor = waitForBuild ? this.build() : this.createImpl_(); return waitFor.then(() => this.impl_); } + /** + * @return {!Promise} + * @private + */ + createImpl_() { + return this.signals_ + .whenSignal(CommonSignals.READY_TO_UPGRADE) + .then(() => { + this.tryUpgrade_(); + return this.whenUpgraded(); + }); + } + /** * Returns the object which holds the API surface (the thing we add the * custom methods/properties onto). In Bento, this is the imperative API @@ -1197,7 +1398,7 @@ function createBaseCustomElementClass(win) { if (isLoadEvent) { this.signals_.signal(CommonSignals.LOAD_END); } - this.readyState = 'complete'; + this.setReadyStateInternal(ReadyState.COMPLETE); this.layoutCount_++; this.toggleLoading(false); // Check if this is the first success layout that needs @@ -1219,6 +1420,7 @@ function createBaseCustomElementClass(win) { /** @type {!Error} */ (reason) ); } + this.setReadyStateInternal(ReadyState.ERROR, reason); this.layoutCount_++; this.toggleLoading(false); throw reason; @@ -1387,6 +1589,8 @@ function createBaseCustomElementClass(win) { this.actionQueue_ = []; } devAssert(this.actionQueue_).push(invocation); + // Schedule build sooner. + this.build(); } else { this.executionAction_(invocation, false); } @@ -1446,11 +1650,11 @@ function createBaseCustomElementClass(win) { return null; } } - if ((policyId == '' || policyId == 'default') && this.impl_) { + if (policyId == '' || policyId == 'default') { // data-block-on-consent value not set, up to individual element // Note: data-block-on-consent and data-block-on-consent='default' is // treated exactly the same - return this.impl_.getConsentPolicy(); + return devAssert(this.impl_).getConsentPolicy(); } return policyId; } @@ -1772,6 +1976,15 @@ export function resetStubsForTesting() { stubbedElements.length = 0; } +/** + * @param {!AmpElement} element + * @return {typeof BaseElement} + * @visibleForTesting + */ +export function getImplClassSyncForTesting(element) { + return element.implClass_; +} + /** * @param {!AmpElement} element * @return {!BaseElement} @@ -1780,3 +1993,12 @@ export function resetStubsForTesting() { export function getImplSyncForTesting(element) { return element.impl_; } + +/** + * @param {!AmpElement} element + * @return {?Array|undefined} + * @visibleForTesting + */ +export function getActionQueueForTesting(element) { + return element.actionQueue_; +} diff --git a/src/ready-state.js b/src/ready-state.js new file mode 100644 index 000000000000..3dd7e4206b4b --- /dev/null +++ b/src/ready-state.js @@ -0,0 +1,47 @@ +/** + * Copyright 2021 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. + */ + +/** + * An AMP element's ready state. + * + * @enum {string} + */ +export const ReadyState = { + /** + * The element has not been upgraded yet. + */ + UPGRADING: 'upgrading', + + /** + * The element has been upgraded and waiting to be built. + */ + BUILDING: 'building', + + /** + * The element has been built and waiting to be loaded. + */ + LOADING: 'loading', + + /** + * The element has been built and loaded. + */ + COMPLETE: 'complete', + + /** + * The element is in an error state. + */ + ERROR: 'error', +}; diff --git a/src/service/builder.js b/src/service/builder.js new file mode 100644 index 000000000000..0459615507db --- /dev/null +++ b/src/service/builder.js @@ -0,0 +1,241 @@ +/** + * Copyright 2020 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 {LayoutPriority} from '../layout'; +import {READY_SCAN_SIGNAL} from './resources-interface'; +import {VisibilityState} from '../visibility-state'; +import {getServiceForDoc, registerServiceBuilderForDoc} from '../service'; +import {hasNextNodeInDocumentOrder, isIframed} from '../dom'; +import {removeItem} from '../utils/array'; + +const ID = 'builder'; + +/** @implements {../service.Disposable} */ +export class Builder { + /** @param {!./ampdoc-impl.AmpDoc} ampdoc */ + constructor(ampdoc) { + /** @private @const */ + this.ampdoc_ = ampdoc; + + const {win} = ampdoc; + + /** @private @const {!IntersectionObserver} */ + this.observer_ = new win.IntersectionObserver((e) => this.observed_(e), { + // Root bounds are not important, so we can use the `root:null` for a + // top-level window. + root: isIframed(win) ? win.document : null, + rootMargin: '250% 31.25%', + threshold: 0.001, + }); + + /** @private @const {!Map} */ + this.targets_ = new Map(); + + /** @private {?Array} */ + this.parsingTargets_ = []; + + /** @private {boolean} */ + ampdoc.whenReady().then(() => this.checkParsing_()); + + /** @private {?UnlistenDef} */ + this.visibilityUnlisten_ = ampdoc.onVisibilityChanged(() => + this.docVisibilityChanged_() + ); + } + + /** @override */ + dispose() { + this.observer_.disconnect(); + this.targets_.clear(); + if (this.visibilityUnlisten_) { + this.visibilityUnlisten_(); + this.visibilityUnlisten_ = null; + } + } + + /** + * @param {!AmpElement} target + */ + scheduleAsap(target) { + this.targets_.set(target, {asap: true, isIntersecting: false}); + this.waitParsing_(target); + } + + /** + * @param {!AmpElement} target + */ + schedule(target) { + if (this.targets_.has(target)) { + return; + } + + if (target.deferredBuild()) { + this.targets_.set(target, {asap: false, isIntersecting: false}); + this.observer_.observe(target); + } else { + this.targets_.set(target, {asap: false, isIntersecting: true}); + } + + this.waitParsing_(target); + } + + /** + * @param {!AmpElement} target + */ + unschedule(target) { + if (!this.targets_.has(target)) { + return; + } + + this.targets_.delete(target); + + this.observer_.unobserve(target); + + if (this.parsingTargets_) { + removeItem(this.parsingTargets_, target); + this.checkParsing_(); + } + } + + /** @private*/ + signalScanReady_() { + if (this.ampdoc_.isReady() && !this.scheduledReady_) { + this.scheduledReady_ = true; + const {win} = this.ampdoc_; + win.setTimeout(() => { + // This signal mainly signifies that some of the elements have been + // discovered and scheduled. + this.ampdoc_.signals().signal(READY_SCAN_SIGNAL); + }, 50); + } + } + + /** @private */ + docVisibilityChanged_() { + const vs = this.ampdoc_.getVisibilityState(); + if ( + vs == VisibilityState.VISIBLE || + vs == VisibilityState.HIDDEN || + vs == VisibilityState.PRERENDER + ) { + this.targets_.forEach((_, target) => this.maybeBuild_(target)); + } + } + + /** + * @param {!AmpElement} target + * @private + */ + waitParsing_(target) { + const parsingTargets = this.parsingTargets_; + if (parsingTargets) { + if (!parsingTargets.includes(target)) { + parsingTargets.push(target); + } + this.checkParsing_(); + } else { + this.maybeBuild_(target); + } + } + + /** @private */ + checkParsing_() { + const documentReady = this.ampdoc_.isReady(); + const parsingTargets = this.parsingTargets_; + if (parsingTargets) { + for (let i = 0; i < parsingTargets.length; i++) { + const target = parsingTargets[i]; + if ( + documentReady || + hasNextNodeInDocumentOrder(target, this.ampdoc_.getRootNode()) + ) { + parsingTargets.splice(i--, 1); + + this.maybeBuild_(target); + } + } + } + if (documentReady) { + this.parsingTargets_ = null; + this.signalScanReady_(); + } + } + + /** + * @param {!Array} entries + * @private + */ + observed_(entries) { + for (let i = 0; i < entries.length; i++) { + const {target, isIntersecting} = entries[i]; + + const current = this.targets_.get(target); + if (!current) { + continue; + } + + this.targets_.set(target, {asap: current.asap, isIntersecting}); + if (isIntersecting) { + this.maybeBuild_(target); + } + } + } + + /** + * @param {!AmpElement} target + * @private + */ + maybeBuild_(target) { + const parsingTargets = this.parsingTargets_; + const parsed = !(parsingTargets && parsingTargets.includes(target)); + const {asap, isIntersecting} = this.targets_.get(target) || { + asap: false, + isIntersecting: false, + }; + const vs = this.ampdoc_.getVisibilityState(); + const toBuild = + parsed && + (asap || isIntersecting) && + (vs == VisibilityState.VISIBLE || + // Hidden (hidden tab) allows full build. + vs == VisibilityState.HIDDEN || + // Prerender can only proceed when allowed. + (vs == VisibilityState.PRERENDER && target.prerenderAllowed())); + if (!toBuild) { + return; + } + + this.unschedule(target); + + // The high-priority elements are scheduled via `setTimeout`. All other + // elements are scheduled via the `requestIdleCallback`. + const {win} = this.ampdoc_; + const scheduler = + asap || target.getBuildPriority() <= LayoutPriority.CONTENT + ? win.setTimeout + : win.requestIdleCallback || win.setTimeout; + scheduler(() => target.buildInternal()); + } +} + +/** + * @param {!./ampdoc-impl.AmpDoc} ampdoc + * @return {!Builder} + */ +export function getBuilderForDoc(ampdoc) { + registerServiceBuilderForDoc(ampdoc, ID, Builder); + return /** @type {!Builder} */ (getServiceForDoc(ampdoc, ID)); +} diff --git a/src/service/resource.js b/src/service/resource.js index bd43b2839895..98111cd6543a 100644 --- a/src/service/resource.js +++ b/src/service/resource.js @@ -1020,6 +1020,9 @@ export class Resource { * @return {!Promise} */ loadedOnce() { + if (this.element.V1()) { + return this.element.whenLoaded(); + } return this.loadPromise_; } diff --git a/src/service/resources-impl.js b/src/service/resources-impl.js index ac694f5cff3c..4688e8bbd892 100644 --- a/src/service/resources-impl.js +++ b/src/service/resources-impl.js @@ -1205,7 +1205,11 @@ export class ResourcesImpl { let remeasureCount = 0; for (let i = 0; i < this.resources_.length; i++) { const r = this.resources_[i]; - if (r.getState() == ResourceState.NOT_BUILT && !r.isBuilding()) { + if ( + r.getState() == ResourceState.NOT_BUILT && + !r.isBuilding() && + !r.element.V1() + ) { this.buildOrScheduleBuildForResource_(r, /* checkForDupes */ true); } if (this.intersectionObserver_) { @@ -1271,7 +1275,7 @@ export class ResourcesImpl { ) { for (let i = 0; i < this.resources_.length; i++) { const r = this.resources_[i]; - if (r.hasOwner() && !r.isMeasureRequested()) { + if ((r.hasOwner() && !r.isMeasureRequested()) || r.element.V1()) { // If element has owner, and measure is not requested, do nothing. continue; } @@ -1332,7 +1336,11 @@ export class ResourcesImpl { // Phase 3: Set inViewport status for resources. for (let i = 0; i < this.resources_.length; i++) { const r = this.resources_[i]; - if (r.getState() == ResourceState.NOT_BUILT || r.hasOwner()) { + if ( + r.getState() == ResourceState.NOT_BUILT || + r.hasOwner() || + r.element.V1() + ) { continue; } // Note that when the document is not visible, neither are any of its @@ -1357,6 +1365,7 @@ export class ResourcesImpl { !r.isBuilt() && !r.isBuilding() && !r.hasOwner() && + !r.element.V1() && r.hasBeenMeasured() && r.isDisplayed() && r.overlaps(loadRect) @@ -1392,6 +1401,7 @@ export class ResourcesImpl { if ( r.getState() == ResourceState.READY_FOR_LAYOUT && !r.hasOwner() && + !r.element.V1() && r.isDisplayed() && r.idleRenderOutsideViewport() ) { @@ -1411,6 +1421,7 @@ export class ResourcesImpl { if ( r.getState() == ResourceState.READY_FOR_LAYOUT && !r.hasOwner() && + !r.element.V1() && r.isDisplayed() ) { dev().fine(TAG_, 'idle layout:', r.debugid); @@ -1716,6 +1727,9 @@ export class ResourcesImpl { opt_parentPriority, opt_forceOutsideViewport ) { + if (resource.element.V1()) { + return; + } const isBuilt = resource.getState() != ResourceState.NOT_BUILT; const isDisplayed = resource.isDisplayed(); if (!isBuilt || !isDisplayed) { diff --git a/test/fixtures/images.html b/test/fixtures/images.html index dea5cb3e7d04..9d4948362372 100644 --- a/test/fixtures/images.html +++ b/test/fixtures/images.html @@ -40,7 +40,7 @@

AMP #0

Lorem ipsum dolor sit amet

- AMP #0

- +

- +

- +

Lorem ipsum dolor sit amet, has nisl nihil convenire et, vim at aeque inermis reprehendunt. Propriae tincidunt id nec, elit nusquam te mea, ius noster platonem in. Mea an idque minim, sit sale deleniti apeirian et. Omnium legendos tractatos cu mea. Vix in stet dolorem accusamus. Iisque rationibus consetetur in cum, quo unum nulla legere ut. Simul numquam saperet no sit.

- +

- +

- +

- +

Lorem ipsum dolor sit amet, has nisl nihil convenire et, vim at aeque inermis reprehendunt. Propriae tincidunt id nec, elit nusquam te mea, ius noster platonem in. Mea an idque minim, sit sale deleniti apeirian et. Omnium legendos tractatos cu mea. Vix in stet dolorem accusamus. Iisque rationibus consetetur in cum, quo unum nulla legere ut. Simul numquam saperet no sit. @@ -116,7 +116,7 @@

AMP #0

- { diff --git a/test/unit/test-amp-img-intrinsic.js b/test/unit/test-amp-img-intrinsic.js new file mode 100644 index 000000000000..8eaf0c880b31 --- /dev/null +++ b/test/unit/test-amp-img-intrinsic.js @@ -0,0 +1,284 @@ +/** + * Copyright 2021 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 {BrowserController} from '../../testing/test-helper'; +import {applyStaticLayout} from '../../src/layout'; +import {createElementWithAttributes} from '../../src/dom'; +import {createIframePromise} from '../../testing/iframe'; +import {installImg} from '../../builtins/amp-img'; +import {toArray} from '../../src/types'; + +describes.sandboxed('amp-img layout intrinsic', {}, () => { + let fixture; + + beforeEach(() => { + return createIframePromise().then((iframeFixture) => { + fixture = iframeFixture; + }); + }); + + function getImg(attributes, children) { + installImg(fixture.win); + + const img = fixture.doc.createElement('amp-img'); + for (const key in attributes) { + img.setAttribute(key, attributes[key]); + } + + if (children != null) { + for (let i = 0; i < children.length; i++) { + img.appendChild(children[i]); + } + } + return Promise.resolve(fixture.addElement(img)); + } + + // Firefox misbehaves on Windows for this test because getBoundingClientRect + // returns 0x0 for width and height. Strangely Firefox on MacOS will return + // reasonable values for getBoundingClientRect if we add an explicit wait + // for laid out attributes via waitForElementLayout. If we change the test to + // test for client or offset values, Safari yields 0px measurements. + // For details, see: https://github.com/ampproject/amphtml/pull/24574 + describe + .configure() + .skipFirefox() + .run('layout intrinsic', () => { + let browser; + beforeEach(() => { + fixture.iframe.height = 800; + fixture.iframe.width = 800; + browser = new BrowserController(fixture.win); + }); + it('should not exceed given width and height even if image\ + natural size is larger', () => { + let ampImg; + return getImg({ + src: '/examples/img/sample.jpg', // 641 x 481 + width: 100, + height: 100, + layout: 'intrinsic', + }) + .then((image) => { + ampImg = image; + return browser.waitForElementLayout('amp-img'); + }) + .then(() => { + expect(ampImg.getBoundingClientRect()).to.include({ + width: 100, + height: 100, + }); + const img = ampImg.querySelector('img'); + expect(img.getBoundingClientRect()).to.include({ + width: 100, + height: 100, + }); + }); + }); + + it('should reach given width and height even if image\ + natural size is smaller', () => { + let ampImg; + return getImg({ + src: '/examples/img/sample.jpg', // 641 x 481 + width: 800, + height: 600, + layout: 'intrinsic', + }) + .then((image) => { + ampImg = image; + return browser.waitForElementLayout('amp-img'); + }) + .then(() => { + expect(ampImg.getBoundingClientRect()).to.include({ + width: 800, + height: 600, + }); + const img = ampImg.querySelector('img'); + expect(img.getBoundingClientRect()).to.include({ + width: 800, + height: 600, + }); + }); + }); + + it('expands a parent div with no explicit dimensions', () => { + let ampImg; + const parentDiv = fixture.doc.getElementById('parent'); + // inline-block to force width and height to size of children + // font-size 0 to get rid of the 4px added by inline-block for whitespace + parentDiv.setAttribute('style', 'display: inline-block; font-size: 0;'); + return getImg({ + src: '/examples/img/sample.jpg', // 641 x 481 + width: 600, + height: 400, + layout: 'intrinsic', + }) + .then((image) => { + ampImg = image; + return browser.waitForElementLayout('amp-img'); + }) + .then(() => { + expect(ampImg.getBoundingClientRect()).to.include({ + width: 600, + height: 400, + }); + const parentDiv = fixture.doc.getElementById('parent'); + expect(parentDiv.getBoundingClientRect()).to.include({ + width: 600, + height: 400, + }); + }); + }); + + it('is bounded by explicit dimensions of a parent container', () => { + let ampImg; + const parentDiv = fixture.doc.getElementById('parent'); + parentDiv.setAttribute('style', 'width: 80px; height: 80px'); + return getImg({ + src: '/examples/img/sample.jpg', // 641 x 481 + width: 800, + height: 600, + layout: 'intrinsic', + }) + .then((image) => { + ampImg = image; + return browser.waitForElementLayout('amp-img'); + }) + .then(() => { + expect(ampImg.getBoundingClientRect()).to.include({ + width: 80, + height: 60, + }); + const parentDiv = fixture.doc.getElementById('parent'); + expect(parentDiv.getBoundingClientRect()).to.include({ + width: 80, + height: 80, + }); + }); + }); + + it('SSR sizer does not interfere with img creation', () => { + let ampImg; + const parentDiv = fixture.doc.getElementById('parent'); + parentDiv.setAttribute('style', 'width: 80px; height: 80px'); + + // Hack so we don't duplicate intrinsic's layout code here. + const tmp = createElementWithAttributes(fixture.doc, 'div', { + src: '/examples/img/sample.jpg', // 641 x 481 + width: 800, + height: 600, + layout: 'intrinsic', + }); + applyStaticLayout(tmp); + const attributes = { + 'i-amphtml-ssr': '', + }; + for (let i = 0; i < tmp.attributes.length; i++) { + attributes[tmp.attributes[i].name] = tmp.attributes[i].value; + } + + return getImg(attributes, toArray(tmp.children)) + .then((image) => { + ampImg = image; + return browser.waitForElementLayout('amp-img'); + }) + .then(() => { + expect(ampImg.querySelector('img[src*="sample.jpg"]')).to.exist; + expect(ampImg.querySelector('img[src*="image/svg+xml"]')).to.exist; + }); + }); + + it('SSR sizer does not interfere with SSR img before', () => { + let ampImg; + const parentDiv = fixture.doc.getElementById('parent'); + parentDiv.setAttribute('style', 'width: 80px; height: 80px'); + + // Hack so we don't duplicate intrinsic's layout code here. + const tmp = createElementWithAttributes(fixture.doc, 'div', { + src: '/examples/img/sample.jpg', // 641 x 481 + width: 800, + height: 600, + layout: 'intrinsic', + }); + applyStaticLayout(tmp); + const attributes = { + 'i-amphtml-ssr': '', + }; + for (let i = 0; i < tmp.attributes.length; i++) { + attributes[tmp.attributes[i].name] = tmp.attributes[i].value; + } + + const children = toArray(tmp.children); + children.unshift( + createElementWithAttributes(fixture.doc, 'img', { + decoding: 'async', + class: 'i-amphtml-fill-content i-amphtml-replaced-content', + src: tmp.getAttribute('src'), + }) + ); + + return getImg(attributes, children) + .then((image) => { + ampImg = image; + return browser.waitForElementLayout('amp-img'); + }) + .then(() => { + expect(ampImg.querySelector('img[src*="sample.jpg"]')).to.exist; + expect(ampImg.querySelector('img[src*="image/svg+xml"]')).to.exist; + }); + }); + + it('SSR sizer does not interfere with SSR img after', () => { + let ampImg; + const parentDiv = fixture.doc.getElementById('parent'); + parentDiv.setAttribute('style', 'width: 80px; height: 80px'); + + // Hack so we don't duplicate intrinsic's layout code here. + const tmp = createElementWithAttributes(fixture.doc, 'div', { + src: '/examples/img/sample.jpg', // 641 x 481 + width: 800, + height: 600, + layout: 'intrinsic', + }); + applyStaticLayout(tmp); + const attributes = { + 'i-amphtml-ssr': '', + }; + for (let i = 0; i < tmp.attributes.length; i++) { + attributes[tmp.attributes[i].name] = tmp.attributes[i].value; + } + + const children = toArray(tmp.children); + children.push( + createElementWithAttributes(fixture.doc, 'img', { + decoding: 'async', + class: 'i-amphtml-fill-content i-amphtml-replaced-content', + src: tmp.getAttribute('src'), + }) + ); + + return getImg(attributes, children) + .then((image) => { + ampImg = image; + return browser.waitForElementLayout('amp-img'); + }) + .then(() => { + expect(ampImg.querySelector('img[src*="sample.jpg"]')).to.exist; + expect(ampImg.querySelector('img[src*="image/svg+xml"]')).to.exist; + }); + }); + }); +}); diff --git a/test/unit/test-amp-img-v1.js b/test/unit/test-amp-img-v1.js new file mode 100644 index 000000000000..b818def67f5b --- /dev/null +++ b/test/unit/test-amp-img-v1.js @@ -0,0 +1,619 @@ +/** + * Copyright 2021 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 {AmpImg} from '../../builtins/amp-img'; +import {BaseElement} from '../../src/base-element'; +import {Layout, LayoutPriority} from '../../src/layout'; +import {createElementWithAttributes, dispatchCustomEvent} from '../../src/dom'; +import {testElementV1} from '../../testing/element-v1'; + +describes.realWin('amp-img V1', {amp: true}, (env) => { + let win, doc; + let sandbox; + let windowWidth; + + const SRCSET_STRING = `/examples/img/hero@1x.jpg 641w, + /examples/img/hero@2x.jpg 1282w`; + + beforeEach(() => { + win = env.win; + doc = win.document; + sandbox = env.sandbox; + + sandbox.stub(AmpImg, 'V1').returns(true); + + windowWidth = 320; + sandbox.stub(BaseElement.prototype, 'getViewport').callsFake(() => { + return { + getWidth: () => windowWidth, + }; + }); + }); + + function createImg(attributes, children) { + const img = createElementWithAttributes(doc, 'amp-img', attributes); + + if (children != null) { + for (let i = 0; i < children.length; i++) { + img.appendChild(children[i]); + } + } + return img; + } + + async function getImg(attributes, children) { + const img = createImg(attributes, children); + + img.onload = sandbox.spy(); + img.onerror = sandbox.spy(); + + doc.body.appendChild(img); + await img.build(); + return img; + } + + it('testElementV1', () => { + testElementV1(AmpImg, { + exceptions: ['Must not use getLayoutSize'], + }); + }); + + it('getBuildPriority', () => { + expect(AmpImg.getBuildPriority()).to.equal(LayoutPriority.CONTENT); + }); + + it('should load an img', async () => { + const ampImg = await getImg({ + src: '/examples/img/sample.jpg', + width: 300, + height: 200, + alt: 'An image', + title: 'Image title', + referrerpolicy: 'origin', + }); + + const img = ampImg.querySelector('img'); + expect(img.getAttribute('src')).to.equal('/examples/img/sample.jpg'); + expect(img.getAttribute('alt')).to.equal('An image'); + expect(img.getAttribute('title')).to.equal('Image title'); + expect(img.getAttribute('referrerpolicy')).to.equal('origin'); + expect(img.getAttribute('decoding')).to.equal('async'); + + const toggleFallbackSpy = sandbox.spy(ampImg, 'toggleFallback'); + const togglePlaceholderSpy = sandbox.spy(ampImg, 'togglePlaceholder'); + + expect(ampImg.readyState).to.equal('loading'); + expect(ampImg.onload).to.not.be.called; + + dispatchCustomEvent(img, 'load', null, {bubbles: false}); + expect(ampImg.readyState).to.equal('complete'); + expect(ampImg.onload).to.be.calledOnce; + expect(ampImg.onerror).to.not.be.called; + expect(toggleFallbackSpy).to.not.be.called; + expect(togglePlaceholderSpy).to.be.calledOnce.calledWith(false); + }); + + it('should fail when img fails', async () => { + const ampImg = await getImg({ + src: 'non-existent.jpg', + width: 300, + height: 200, + }); + + const img = ampImg.querySelector('img'); + expect(img.getAttribute('src')).to.equal('non-existent.jpg'); + + const toggleFallbackSpy = sandbox.spy(ampImg, 'toggleFallback'); + const togglePlaceholderSpy = sandbox.spy(ampImg, 'togglePlaceholder'); + + expect(ampImg.readyState).to.equal('loading'); + expect(ampImg.onerror).to.not.be.called; + + dispatchCustomEvent(img, 'error', null, {bubbles: false}); + expect(ampImg.readyState).to.equal('error'); + expect(ampImg.onerror).to.be.calledOnce; + expect(toggleFallbackSpy).to.be.calledOnce.calledWith(true); + expect(togglePlaceholderSpy).to.be.calledOnce.calledWith(false); + expect(ampImg.onload).to.not.be.called; + }); + + it('should fallback once and remove fallback once image loads', async () => { + const ampImg = await getImg({ + src: 'non-existent.jpg', + width: 300, + height: 200, + }); + const toggleFallbackSpy = sandbox.spy(ampImg, 'toggleFallback'); + + const img = ampImg.querySelector('img'); + dispatchCustomEvent(img, 'error', null, {bubbles: false}); + expect(ampImg.readyState).to.equal('error'); + expect(ampImg.onerror).to.be.calledOnce; + expect(ampImg.onload).to.not.be.called; + expect(toggleFallbackSpy).to.be.calledOnce.calledWith(true); + expect(img).to.have.class('i-amphtml-ghost'); + + dispatchCustomEvent(img, 'load', null, {bubbles: false}); + expect(ampImg.readyState).to.equal('complete'); + expect(ampImg.onload).to.be.calledOnce; + expect(ampImg.onerror).to.be.calledOnce; // no change. + expect(toggleFallbackSpy).to.be.calledTwice.calledWith(false); + expect(img).to.not.have.class('i-amphtml-ghost'); + + // 2nd error doesn't toggle fallback. + dispatchCustomEvent(img, 'error', null, {bubbles: false}); + expect(ampImg.readyState).to.equal('error'); + expect(ampImg.onerror).to.be.calledTwice; + expect(toggleFallbackSpy).to.be.calledTwice; // no change. + expect(img).to.not.have.class('i-amphtml-ghost'); + }); + + it('should not remove the fallback if fetching fails', async () => { + const ampImg = await getImg({ + src: 'non-existent.jpg', + width: 300, + height: 200, + }); + const toggleFallbackSpy = sandbox.spy(ampImg, 'toggleFallback'); + + const img = ampImg.querySelector('img'); + expect(img).to.not.have.class('i-amphtml-ghost'); + + dispatchCustomEvent(img, 'error', null, {bubbles: false}); + expect(ampImg.readyState).to.equal('error'); + expect(ampImg.onerror).to.be.calledOnce; + expect(ampImg.onload).to.not.be.called; + expect(toggleFallbackSpy).to.be.calledOnce.calledWith(true); + expect(img).to.have.class('i-amphtml-ghost'); + + dispatchCustomEvent(img, 'error', null, {bubbles: false}); + expect(toggleFallbackSpy).to.be.calledOnce; // no change. + expect(img).to.have.class('i-amphtml-ghost'); + }); + + it('should preconnect the src url', () => { + const element = createImg({src: '/examples/img/sample.jpg'}); + expect(AmpImg.getPreconnects(element)).to.deep.equal([ + '/examples/img/sample.jpg', + ]); + }); + + it('should preconnect to the the first srcset url if src is not set', () => { + const element = createImg({srcset: SRCSET_STRING}); + expect(AmpImg.getPreconnects(element)).to.deep.equal([ + '/examples/img/hero@1x.jpg', + ]); + }); + + it('should allow prerender by default', () => { + const el = createImg({src: '/examples/img/sample.jpg'}); + expect(AmpImg.prerenderAllowed(el)).to.equal(true); + }); + + it('should load an img with srcset', async () => { + const ampImg = await getImg({ + srcset: SRCSET_STRING, + width: 300, + height: 200, + }); + const img = ampImg.querySelector('img'); + expect(img.getAttribute('srcset')).to.equal(SRCSET_STRING); + }); + + it('should handle attribute mutations', async () => { + const ampImg = await getImg({ + src: '/examples/img/sample.jpg', + srcset: SRCSET_STRING, + width: 300, + height: 200, + }); + const impl = await ampImg.getImpl(); + + const img = ampImg.querySelector('img'); + expect(img.getAttribute('src')).to.equal('/examples/img/sample.jpg'); + expect(img.getAttribute('srcset')).to.equal(SRCSET_STRING); + + dispatchCustomEvent(img, 'load', null, {bubbles: false}); + expect(ampImg.readyState).to.equal('complete'); + expect(ampImg.onload).to.be.calledOnce; + + ampImg.setAttribute('src', 'foo.jpg'); + impl.mutatedAttributesCallback({src: 'foo.jpg'}); + + expect(img.getAttribute('src')).to.equal('foo.jpg'); + // src mutation should override existing srcset attribute. + expect(img.hasAttribute('srcset')).to.be.false; + + expect(ampImg.readyState).to.equal('loading'); + expect(ampImg.onload).to.be.calledOnce; // no change. + + dispatchCustomEvent(img, 'load', null, {bubbles: false}); + expect(ampImg.readyState).to.equal('complete'); + expect(ampImg.onload).to.be.calledTwice; + }); + + it('should propagate srcset and sizes', async () => { + const ampImg = await getImg({ + src: '/examples/img/sample.jpg', + srcset: SRCSET_STRING, + sizes: '(max-width: 320px) 640px, 100vw', + width: 320, + height: 240, + }); + const img = ampImg.querySelector('img'); + expect(img.getAttribute('srcset')).to.equal(SRCSET_STRING); + expect(img.getAttribute('sizes')).to.equal( + '(max-width: 320px) 640px, 100vw' + ); + }); + + it('should propagate data attributes', async () => { + const ampImg = await getImg({ + src: '/examples/img/sample.jpg', + width: 320, + height: 240, + 'data-foo': 'abc', + }); + const img = ampImg.querySelector('img'); + expect(img.getAttribute('data-foo')).to.equal('abc'); + }); + + it('should not propagate bind attributes', async () => { + const ampImg = await getImg({ + src: '/examples/img/sample.jpg', + width: 320, + height: 240, + 'data-amp-bind': 'abc', + 'data-amp-bind-foo': '123', + }); + const img = ampImg.querySelector('img'); + expect(img.getAttribute('data-amp-bind')).to.equal('abc'); + expect(img.getAttribute('data-amp-bind-foo')).to.be.null; + }); + + it('should propagate srcset and sizes with disable-inline-width', async () => { + const ampImg = await getImg({ + src: '/examples/img/sample.jpg', + srcset: SRCSET_STRING, + sizes: '(max-width: 320px) 640px, 100vw', + width: 320, + height: 240, + 'disable-inline-width': null, + }); + const img = ampImg.querySelector('img'); + expect(img.getAttribute('srcset')).to.equal(SRCSET_STRING); + expect(img.getAttribute('sizes')).to.equal( + '(max-width: 320px) 640px, 100vw' + ); + }); + + it('should propagate crossorigin attribute', async () => { + const ampImg = await getImg({ + src: '/examples/img/sample.jpg', + width: 320, + height: 240, + crossorigin: 'anonymous', + }); + const img = ampImg.querySelector('img'); + expect(img.getAttribute('crossorigin')).to.equal('anonymous'); + }); + + it('should propagate ARIA attributes', async () => { + const ampImg = await getImg({ + src: 'test.jpg', + width: 100, + height: 100, + 'aria-label': 'Hello', + 'aria-labelledby': 'id2', + 'aria-describedby': 'id3', + }); + const img = ampImg.querySelector('img'); + expect(img.getAttribute('aria-label')).to.equal('Hello'); + expect(img.getAttribute('aria-labelledby')).to.equal('id2'); + expect(img.getAttribute('aria-describedby')).to.equal('id3'); + }); + + it('should propagate the object-fit attribute', async () => { + const ampImg = await getImg({ + src: '/examples/img/sample.jpg', + width: 300, + height: 200, + 'object-fit': 'cover', + }); + const img = ampImg.querySelector('img'); + expect(img.style.objectFit).to.equal('cover'); + }); + + it('should not propagate the object-fit attribute if invalid', async () => { + const ampImg = await getImg({ + src: '/examples/img/sample.jpg', + width: 300, + height: 200, + 'object-fit': 'foo 80%', + }); + const img = ampImg.querySelector('img'); + expect(img.style.objectFit).to.be.empty; + }); + + it('should propagate the object-position attribute', async () => { + const ampImg = await getImg({ + src: '/examples/img/sample.jpg', + width: 300, + height: 200, + 'object-position': '20% 80%', + }); + const img = ampImg.querySelector('img'); + expect(img.style.objectPosition).to.equal('20% 80%'); + }); + + it('should not propagate the object-position attribute if invalid', async () => { + const ampImg = await getImg({ + src: '/examples/img/sample.jpg', + width: 300, + height: 200, + 'object-position': 'url("example.com")', + }); + const img = ampImg.querySelector('img'); + expect(img.style.objectPosition).to.be.empty; + }); + + describe('blurred image placeholder', () => { + /** + * Creates an amp-img with an image child that could potentially be a + * blurry placeholder. + * @param {boolean} addPlaceholder Whether the child should have a + * placeholder attribute. + * @param {boolean} addBlurClass Whether the child should have the + * class that allows it to be a blurred placeholder. + * @return {AmpImg} An amp-img object potentially with a blurry placeholder + */ + function getImgWithBlur(addPlaceholder, addBlurClass) { + const el = createImg({ + src: '/examples/img/sample.jpg', + id: 'img1', + width: 100, + height: 100, + }); + sandbox.stub(el, 'togglePlaceholder'); + const img = doc.createElement('img'); + img.setAttribute( + 'src', + 'data:image/svg+xml;charset=utf-8,%3Csvg%3E%3C/svg%3E' + ); + if (addPlaceholder) { + img.setAttribute('placeholder', ''); + } + if (addBlurClass) { + img.classList.add('i-amphtml-blurry-placeholder'); + } + el.appendChild(img); + doc.body.appendChild(el); + return el; + } + + it('should set placeholder opacity to 0 on image load', async () => { + let el, img; + + el = getImgWithBlur(true, true); + await el.build(); + dispatchCustomEvent(el.querySelector('img[amp-img-id]'), 'load', null, { + bubbles: false, + }); + img = el.firstChild; + expect(img.style.opacity).to.equal('0'); + expect(el.togglePlaceholder).to.not.be.called; + + el = getImgWithBlur(true, false); + await el.build(); + dispatchCustomEvent(el.querySelector('img[amp-img-id]'), 'load', null, { + bubbles: false, + }); + img = el.firstChild; + expect(img.style.opacity).to.be.equal(''); + expect(el.togglePlaceholder).to.have.been.calledWith(false); + + el = getImgWithBlur(false, true); + await el.build(); + dispatchCustomEvent(el.querySelector('img[amp-img-id]'), 'load', null, { + bubbles: false, + }); + img = el.firstChild; + expect(img.style.opacity).to.be.equal(''); + expect(el.togglePlaceholder).to.have.been.calledWith(false); + + el = getImgWithBlur(false, false); + await el.build(); + dispatchCustomEvent(el.querySelector('img[amp-img-id]'), 'load', null, { + bubbles: false, + }); + expect(el.togglePlaceholder).to.have.been.calledWith(false); + }); + + it('does not interfere with SSR img creation', async () => { + const ampImg = getImgWithBlur(true, true); + ampImg.setAttribute('i-amphtml-ssr', ''); + await ampImg.build(); + + expect(ampImg.querySelector('img[src*="sample.jpg"]')).to.exist; + expect(ampImg.querySelector('img[src*="image/svg+xml"]')).to.exist; + }); + + it('does not interfere with SSR img before placeholder', async () => { + const ampImg = getImgWithBlur(true, true); + ampImg.setAttribute('i-amphtml-ssr', ''); + + const img = doc.createElement('img'); + img.setAttribute('src', ampImg.getAttribute('src')); + ampImg.insertBefore(img, ampImg.querySelector('[placeholder]')); + + await ampImg.build(); + + expect(ampImg.querySelector('img[src*="sample.jpg"]')).to.exist; + expect(ampImg.querySelectorAll('img[src*="sample.jpg"]')).to.have.length( + 1 + ); + expect(ampImg.querySelector('img[src*="sample.jpg"]')).to.equal(img); + expect(ampImg.querySelector('img[src*="image/svg+xml"]')).to.exist; + }); + + it('does not interfere with SSR img after placeholder', async () => { + const ampImg = getImgWithBlur(true, true); + ampImg.setAttribute('i-amphtml-ssr', ''); + + const img = document.createElement('img'); + img.setAttribute('src', ampImg.getAttribute('src')); + ampImg.appendChild(img); + + await ampImg.build(); + + expect(ampImg.querySelector('img[src*="sample.jpg"]')).to.exist; + expect(ampImg.querySelectorAll('img[src*="sample.jpg"]')).to.have.length( + 1 + ); + expect(ampImg.querySelector('img[src*="sample.jpg"]')).to.equal(img); + expect(ampImg.querySelector('img[src*="image/svg+xml"]')).to.exist; + }); + }); + + describe('auto-generate sizes', () => { + async function getStubbedImg(attributes, layoutWidth) { + const img = createImg(attributes); + sandbox + .stub(img, 'getLayoutSize') + .returns({width: layoutWidth, height: 100}); + doc.body.appendChild(img); + await img.build(); + return img; + } + + it('should not generate sizes for amp-imgs that already have sizes', async () => { + const ampImg = await getImg({ + src: '/examples/img/sample.jpg', + srcset: SRCSET_STRING, + sizes: '50vw', + width: 300, + height: 200, + }); + const img = ampImg.querySelector('img'); + expect(img.getAttribute('sizes')).to.equal('50vw'); + }); + + it('should not generate sizes for amp-imgs without srcset', async () => { + const ampImg = await getImg({ + src: '/examples/img/sample.jpg', + width: 300, + height: 200, + }); + const img = ampImg.querySelector('img'); + expect(img.getAttribute('sizes')).to.be.null; + }); + + it('should not generate sizes for amp-imgs with x descriptors', async () => { + const ampImg = await getImg({ + srcset: '/examples/img/hero@1x.jpg, /examples/img/hero@2x.jpg 2x', + width: 300, + height: 200, + }); + const img = ampImg.querySelector('img'); + expect(img.getAttribute('sizes')).to.be.null; + }); + + it('should generate correct sizes for layout fixed', async () => { + const ampImg = await getStubbedImg( + { + layout: Layout.FIXED, + src: 'test.jpg', + srcset: 'large.jpg 2000w, small.jpg 1000w', + width: 300, + height: 200, + }, + 300 + ); + const img = ampImg.querySelector('img'); + expect(img.getAttribute('sizes')).to.equal( + '(max-width: 320px) 300px, 300px' + ); + }); + + it('should generate correct sizes for layout responsive', async () => { + const ampImg = await getStubbedImg( + { + layout: Layout.RESPONSIVE, + src: 'test.jpg', + srcset: 'large.jpg 2000w, small.jpg 1000w', + width: 300, + height: 200, + }, + 160 + ); + const img = ampImg.querySelector('img'); + expect(img.getAttribute('sizes')).to.equal( + '(max-width: 320px) 160px, 100vw' + ); + }); + + it('should generate correct sizes for layout fixed-height', async () => { + const ampImg = await getStubbedImg( + { + layout: Layout.FIXED_HEIGHT, + src: 'test.jpg', + srcset: 'large.jpg 2000w, small.jpg 1000w', + width: 300, + height: 200, + }, + 160 + ); + const img = ampImg.querySelector('img'); + expect(img.getAttribute('sizes')).to.equal( + '(max-width: 320px) 160px, 100vw' + ); + }); + + it('should generate correct sizes for layout fill', async () => { + const ampImg = await getStubbedImg( + { + layout: Layout.FILL, + src: 'test.jpg', + srcset: 'large.jpg 2000w, small.jpg 1000w', + width: 300, + height: 200, + }, + 160 + ); + const img = ampImg.querySelector('img'); + expect(img.getAttribute('sizes')).to.equal( + '(max-width: 320px) 160px, 100vw' + ); + }); + + it('should generate correct sizes for layout flex-item', async () => { + const ampImg = await getStubbedImg( + { + layout: Layout.FLEX_ITEM, + src: 'test.jpg', + srcset: 'large.jpg 2000w, small.jpg 1000w', + width: 300, + height: 200, + }, + 160 + ); + const img = ampImg.querySelector('img'); + expect(img.getAttribute('sizes')).to.equal( + '(max-width: 320px) 160px, 100vw' + ); + }); + }); +}); diff --git a/test/unit/test-amp-img.js b/test/unit/test-amp-img.js index 2df6594a8e84..4c9d10df53e9 100644 --- a/test/unit/test-amp-img.js +++ b/test/unit/test-amp-img.js @@ -16,13 +16,10 @@ import {AmpImg, installImg} from '../../builtins/amp-img'; import {BaseElement} from '../../src/base-element'; -import {BrowserController} from '../../testing/test-helper'; -import {Layout, LayoutPriority, applyStaticLayout} from '../../src/layout'; +import {Layout, LayoutPriority} from '../../src/layout'; import {Services} from '../../src/services'; import {createCustomEvent} from '../../src/event-helper'; -import {createElementWithAttributes} from '../../src/dom'; import {createIframePromise} from '../../testing/iframe'; -import {toArray} from '../../src/types'; describes.sandboxed('amp-img', {}, (env) => { let sandbox; @@ -818,240 +815,4 @@ describes.sandboxed('amp-img', {}, (env) => { ); }); }); - - // Firefox misbehaves on Windows for this test because getBoundingClientRect - // returns 0x0 for width and height. Strangely Firefox on MacOS will return - // reasonable values for getBoundingClientRect if we add an explicit wait - // for laid out attributes via waitForElementLayout. If we change the test to - // test for client or offset values, Safari yields 0px measurements. - // For details, see: https://github.com/ampproject/amphtml/pull/24574 - describe - .configure() - .skipFirefox() - .run('layout intrinsic', () => { - let browser; - beforeEach(() => { - fixture.iframe.height = 800; - fixture.iframe.width = 800; - browser = new BrowserController(fixture.win); - }); - it('should not exceed given width and height even if image\ - natural size is larger', () => { - let ampImg; - return getImg({ - src: '/examples/img/sample.jpg', // 641 x 481 - width: 100, - height: 100, - layout: 'intrinsic', - }) - .then((image) => { - ampImg = image; - return browser.waitForElementLayout('amp-img'); - }) - .then(() => { - expect(ampImg.getBoundingClientRect()).to.include({ - width: 100, - height: 100, - }); - const img = ampImg.querySelector('img'); - expect(img.getBoundingClientRect()).to.include({ - width: 100, - height: 100, - }); - }); - }); - - it('should reach given width and height even if image\ - natural size is smaller', () => { - let ampImg; - return getImg({ - src: '/examples/img/sample.jpg', // 641 x 481 - width: 800, - height: 600, - layout: 'intrinsic', - }) - .then((image) => { - ampImg = image; - return browser.waitForElementLayout('amp-img'); - }) - .then(() => { - expect(ampImg.getBoundingClientRect()).to.include({ - width: 800, - height: 600, - }); - const img = ampImg.querySelector('img'); - expect(img.getBoundingClientRect()).to.include({ - width: 800, - height: 600, - }); - }); - }); - - it('expands a parent div with no explicit dimensions', () => { - let ampImg; - const parentDiv = fixture.doc.getElementById('parent'); - // inline-block to force width and height to size of children - // font-size 0 to get rid of the 4px added by inline-block for whitespace - parentDiv.setAttribute('style', 'display: inline-block; font-size: 0;'); - return getImg({ - src: '/examples/img/sample.jpg', // 641 x 481 - width: 600, - height: 400, - layout: 'intrinsic', - }) - .then((image) => { - ampImg = image; - return browser.waitForElementLayout('amp-img'); - }) - .then(() => { - expect(ampImg.getBoundingClientRect()).to.include({ - width: 600, - height: 400, - }); - const parentDiv = fixture.doc.getElementById('parent'); - expect(parentDiv.getBoundingClientRect()).to.include({ - width: 600, - height: 400, - }); - }); - }); - - it('is bounded by explicit dimensions of a parent container', () => { - let ampImg; - const parentDiv = fixture.doc.getElementById('parent'); - parentDiv.setAttribute('style', 'width: 80px; height: 80px'); - return getImg({ - src: '/examples/img/sample.jpg', // 641 x 481 - width: 800, - height: 600, - layout: 'intrinsic', - }) - .then((image) => { - ampImg = image; - return browser.waitForElementLayout('amp-img'); - }) - .then(() => { - expect(ampImg.getBoundingClientRect()).to.include({ - width: 80, - height: 60, - }); - const parentDiv = fixture.doc.getElementById('parent'); - expect(parentDiv.getBoundingClientRect()).to.include({ - width: 80, - height: 80, - }); - }); - }); - - it('SSR sizer does not interfere with img creation', () => { - let ampImg; - const parentDiv = fixture.doc.getElementById('parent'); - parentDiv.setAttribute('style', 'width: 80px; height: 80px'); - - // Hack so we don't duplicate intrinsic's layout code here. - const tmp = createElementWithAttributes(fixture.doc, 'div', { - src: '/examples/img/sample.jpg', // 641 x 481 - width: 800, - height: 600, - layout: 'intrinsic', - }); - applyStaticLayout(tmp); - const attributes = { - 'i-amphtml-ssr': '', - }; - for (let i = 0; i < tmp.attributes.length; i++) { - attributes[tmp.attributes[i].name] = tmp.attributes[i].value; - } - - return getImg(attributes, toArray(tmp.children)) - .then((image) => { - ampImg = image; - return browser.waitForElementLayout('amp-img'); - }) - .then(() => { - expect(ampImg.querySelector('img[src*="sample.jpg"]')).to.exist; - expect(ampImg.querySelector('img[src*="image/svg+xml"]')).to.exist; - }); - }); - - it('SSR sizer does not interfere with SSR img before', () => { - let ampImg; - const parentDiv = fixture.doc.getElementById('parent'); - parentDiv.setAttribute('style', 'width: 80px; height: 80px'); - - // Hack so we don't duplicate intrinsic's layout code here. - const tmp = createElementWithAttributes(fixture.doc, 'div', { - src: '/examples/img/sample.jpg', // 641 x 481 - width: 800, - height: 600, - layout: 'intrinsic', - }); - applyStaticLayout(tmp); - const attributes = { - 'i-amphtml-ssr': '', - }; - for (let i = 0; i < tmp.attributes.length; i++) { - attributes[tmp.attributes[i].name] = tmp.attributes[i].value; - } - - const children = toArray(tmp.children); - children.unshift( - createElementWithAttributes(fixture.doc, 'img', { - decoding: 'async', - class: 'i-amphtml-fill-content i-amphtml-replaced-content', - src: tmp.getAttribute('src'), - }) - ); - - return getImg(attributes, children) - .then((image) => { - ampImg = image; - return browser.waitForElementLayout('amp-img'); - }) - .then(() => { - expect(ampImg.querySelector('img[src*="sample.jpg"]')).to.exist; - expect(ampImg.querySelector('img[src*="image/svg+xml"]')).to.exist; - }); - }); - - it('SSR sizer does not interfere with SSR img after', () => { - let ampImg; - const parentDiv = fixture.doc.getElementById('parent'); - parentDiv.setAttribute('style', 'width: 80px; height: 80px'); - - // Hack so we don't duplicate intrinsic's layout code here. - const tmp = createElementWithAttributes(fixture.doc, 'div', { - src: '/examples/img/sample.jpg', // 641 x 481 - width: 800, - height: 600, - layout: 'intrinsic', - }); - applyStaticLayout(tmp); - const attributes = { - 'i-amphtml-ssr': '', - }; - for (let i = 0; i < tmp.attributes.length; i++) { - attributes[tmp.attributes[i].name] = tmp.attributes[i].value; - } - - const children = toArray(tmp.children); - children.push( - createElementWithAttributes(fixture.doc, 'img', { - decoding: 'async', - class: 'i-amphtml-fill-content i-amphtml-replaced-content', - src: tmp.getAttribute('src'), - }) - ); - - return getImg(attributes, children) - .then((image) => { - ampImg = image; - return browser.waitForElementLayout('amp-img'); - }) - .then(() => { - expect(ampImg.querySelector('img[src*="sample.jpg"]')).to.exist; - expect(ampImg.querySelector('img[src*="image/svg+xml"]')).to.exist; - }); - }); - }); }); diff --git a/test/unit/test-builder.js b/test/unit/test-builder.js new file mode 100644 index 000000000000..e6ffd9ed4aba --- /dev/null +++ b/test/unit/test-builder.js @@ -0,0 +1,413 @@ +/** + * Copyright 2021 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 fakeTimers from '@sinonjs/fake-timers'; +import {Builder} from '../../src/service/builder'; +import {LayoutPriority} from '../../src/layout'; +import {READY_SCAN_SIGNAL} from '../../src/service/resources-interface'; +import {createElementWithAttributes} from '../../src/dom'; +import {installIntersectionObserverStub} from '../../testing/intersection-observer-stub'; + +describes.realWin('Builder', {amp: true}, (env) => { + let win, doc, ampdoc; + let setAmpdocReady; + let clock; + let intersectionObserverStub; + let builder; + + beforeEach(() => { + win = env.win; + doc = win.document; + ampdoc = env.ampdoc; + + let ampdocReady = false; + let ampdocReadyResolver; + const ampdocReadyPromise = new Promise((resolve) => { + ampdocReadyResolver = resolve; + }); + setAmpdocReady = () => { + ampdocReady = true; + ampdocReadyResolver(); + return ampdocReadyPromise.then(() => {}); + }; + env.sandbox.stub(ampdoc, 'whenReady').returns(ampdocReadyPromise); + env.sandbox.stub(ampdoc, 'isReady').callsFake(() => ampdocReady); + + delete win.requestIdleCallback; + clock = fakeTimers.withGlobal(win).install(); + win.requestIdleCallback = (callback) => { + win.setTimeout(callback, 100); + }; + + intersectionObserverStub = installIntersectionObserverStub( + env.sandbox, + win + ); + + builder = new Builder(ampdoc); + }); + + afterEach(() => { + clock.uninstall(); + }); + + function createAmpElement(options = {}) { + const element = createElementWithAttributes(doc, 'amp-el', {}); + element.deferredBuild = () => options.deferredBuild || false; + element.prerenderAllowed = () => options.prerenderAllowed || false; + element.getBuildPriority = () => + options.buildPriority || LayoutPriority.CONTENT; + element.buildInternal = env.sandbox.stub(); + return element; + } + + describe('schedule', () => { + it('should schedule a deferredBuild element', () => { + const element = createAmpElement({deferredBuild: true}); + builder.schedule(element); + expect(intersectionObserverStub.isObserved(element)).to.be.true; + + builder.unschedule(element); + expect(intersectionObserverStub.isObserved(element)).to.be.false; + }); + + it('should schedule a non-deferredBuild element', () => { + const element = createAmpElement({deferredBuild: false}); + builder.schedule(element); + expect(intersectionObserverStub.isObserved(element)).to.be.false; + }); + + it('should unschedule when built', async () => { + const element = createAmpElement({deferredBuild: true}); + builder.schedule(element); + expect(intersectionObserverStub.isObserved(element)).to.be.true; + + await setAmpdocReady(); + intersectionObserverStub.notifySync({ + target: element, + isIntersecting: true, + }); + expect(intersectionObserverStub.isObserved(element)).to.be.false; + }); + + it('should NOT signal READY_SCAN_SIGNAL until document is ready', async () => { + ampdoc.signals().reset(READY_SCAN_SIGNAL); + const element = createAmpElement({deferredBuild: false}); + builder.schedule(element); + expect(ampdoc.signals().get(READY_SCAN_SIGNAL)).to.be.null; + + clock.tick(50); + expect(ampdoc.signals().get(READY_SCAN_SIGNAL)).to.be.null; + }); + + it('should signal READY_SCAN_SIGNAL after document ready', async () => { + ampdoc.signals().reset(READY_SCAN_SIGNAL); + await setAmpdocReady(); + clock.tick(50); + expect(ampdoc.signals().get(READY_SCAN_SIGNAL)).to.exist; + }); + }); + + describe('wait for parsing', () => { + it('should build when document ready', async () => { + await setAmpdocReady(); + const element = createAmpElement({deferredBuild: false}); + builder.schedule(element); + clock.tick(1); + expect(element.buildInternal).to.be.calledOnce; + }); + + it('should build when document becomes ready', async () => { + const element = createAmpElement({deferredBuild: false}); + builder.schedule(element); + clock.tick(1); + expect(element.buildInternal).to.be.not.called; + + await setAmpdocReady(); + clock.tick(1); + expect(element.buildInternal).to.be.calledOnce; + }); + + it('should build asap when document ready', async () => { + await setAmpdocReady(); + const element = createAmpElement({deferredBuild: true}); + builder.scheduleAsap(element); + clock.tick(1); + expect(element.buildInternal).to.be.calledOnce; + }); + + it('should build asap when document becomes ready', async () => { + const element = createAmpElement({deferredBuild: true}); + builder.scheduleAsap(element); + clock.tick(1); + expect(element.buildInternal).to.be.not.called; + + await setAmpdocReady(); + clock.tick(1); + expect(element.buildInternal).to.be.calledOnce; + }); + + it('should build when has next siblings', async () => { + const element = createAmpElement({deferredBuild: false}); + doc.body.appendChild(element); + builder.schedule(element); + clock.tick(1); + expect(element.buildInternal).to.not.be.called; + + const element2 = createAmpElement({deferredBuild: false}); + doc.body.appendChild(element2); + builder.schedule(element2); + clock.tick(1); + expect(element.buildInternal).to.be.calledOnce; + expect(element2.buildInternal).to.not.be.called; + }); + + it('should build asap when has next siblings', async () => { + const element = createAmpElement({deferredBuild: false}); + doc.body.appendChild(element); + builder.scheduleAsap(element); + clock.tick(1); + expect(element.buildInternal).to.not.be.called; + + const element2 = createAmpElement({deferredBuild: false}); + doc.body.appendChild(element2); + builder.scheduleAsap(element2); + clock.tick(1); + expect(element.buildInternal).to.be.calledOnce; + expect(element2.buildInternal).to.not.be.called; + }); + + it('should wait the deferred even when parsed', async () => { + await setAmpdocReady(); + const element = createAmpElement({deferredBuild: true}); + doc.body.appendChild(element); + builder.schedule(element); + clock.tick(1); + expect(element.buildInternal).to.not.be.called; + }); + }); + + describe('wait for document visibility', () => { + beforeEach(async () => { + ampdoc.overrideVisibilityState('prerender'); + await setAmpdocReady(); + }); + + it('should build if prerenderAllowed', () => { + const element = createAmpElement({ + deferredBuild: false, + prerenderAllowed: true, + }); + builder.schedule(element); + clock.tick(1); + expect(element.buildInternal).to.be.calledOnce; + }); + + it('should build asap if prerenderAllowed', () => { + const element = createAmpElement({ + deferredBuild: true, + prerenderAllowed: true, + }); + builder.scheduleAsap(element); + clock.tick(1); + expect(element.buildInternal).to.be.calledOnce; + }); + + it('should NOT build if not prerenderAllowed', () => { + const element = createAmpElement({ + deferredBuild: false, + prerenderAllowed: false, + }); + builder.schedule(element); + clock.tick(1); + expect(element.buildInternal).to.be.not.called; + }); + + it('should NOT build asap if not prerenderAllowed', () => { + const element = createAmpElement({ + deferredBuild: true, + prerenderAllowed: false, + }); + builder.scheduleAsap(element); + clock.tick(1); + expect(element.buildInternal).to.be.not.called; + }); + + it('should build when becomes visible', () => { + const element = createAmpElement({prerenderAllowed: false}); + builder.schedule(element); + clock.tick(1); + expect(element.buildInternal).to.not.be.called; + + ampdoc.overrideVisibilityState('visible'); + clock.tick(1); + expect(element.buildInternal).to.be.calledOnce; + }); + + it('should build when becomes hidden', () => { + const element = createAmpElement({prerenderAllowed: false}); + builder.schedule(element); + clock.tick(1); + expect(element.buildInternal).to.not.be.called; + + ampdoc.overrideVisibilityState('hidden'); + clock.tick(1); + expect(element.buildInternal).to.be.calledOnce; + }); + + it('should NOT build when becomes paused or inactive', () => { + const element = createAmpElement({prerenderAllowed: false}); + builder.schedule(element); + clock.tick(1); + expect(element.buildInternal).to.not.be.called; + + ampdoc.overrideVisibilityState('paused'); + clock.tick(1); + expect(element.buildInternal).to.not.be.called; + + ampdoc.overrideVisibilityState('inactive'); + clock.tick(1); + expect(element.buildInternal).to.not.be.called; + }); + + it('should NOT build when scheduled in paused', () => { + ampdoc.overrideVisibilityState('paused'); + + const element = createAmpElement({prerenderAllowed: false}); + builder.schedule(element); + clock.tick(1); + expect(element.buildInternal).to.not.be.called; + + ampdoc.overrideVisibilityState('visible'); + clock.tick(1); + expect(element.buildInternal).to.be.calledOnce; + }); + + it('should NOT build when scheduled in inactive', () => { + ampdoc.overrideVisibilityState('inactive'); + + const element = createAmpElement({prerenderAllowed: false}); + builder.schedule(element); + clock.tick(1); + expect(element.buildInternal).to.not.be.called; + + ampdoc.overrideVisibilityState('visible'); + clock.tick(1); + expect(element.buildInternal).to.be.calledOnce; + }); + }); + + describe('wait for intersection', () => { + beforeEach(async () => { + await setAmpdocReady(); + }); + + it('should wait for intersection when deferred', () => { + const element = createAmpElement({deferredBuild: true}); + builder.schedule(element); + expect(intersectionObserverStub.isObserved(element)).to.be.true; + clock.tick(1); + expect(element.buildInternal).to.not.be.called; + + intersectionObserverStub.notifySync({ + target: element, + isIntersecting: false, + }); + clock.tick(1); + expect(element.buildInternal).to.not.be.called; + + intersectionObserverStub.notifySync({ + target: element, + isIntersecting: true, + }); + clock.tick(1); + expect(element.buildInternal).to.be.calledOnce; + }); + + it('should not wait for intersection when not deferred', () => { + const element = createAmpElement({deferredBuild: false}); + builder.schedule(element); + expect(intersectionObserverStub.isObserved(element)).to.be.false; + clock.tick(1); + expect(element.buildInternal).to.be.calledOnce; + }); + + it('should not wait for intersection when asap', () => { + const element = createAmpElement({deferredBuild: true}); + builder.scheduleAsap(element); + expect(intersectionObserverStub.isObserved(element)).to.be.false; + clock.tick(1); + expect(element.buildInternal).to.be.calledOnce; + }); + }); + + describe('priority', () => { + beforeEach(async () => { + await setAmpdocReady(); + }); + + it('should run deferred CONTENT at high priority', () => { + const element = createAmpElement({deferredBuild: true}); + builder.schedule(element); + intersectionObserverStub.notifySync({ + target: element, + isIntersecting: true, + }); + clock.tick(1); + expect(element.buildInternal).to.be.calledOnce; + }); + + it('should run deferred METADATA at low priority', () => { + const element = createAmpElement({ + deferredBuild: true, + buildPriority: LayoutPriority.METADATA, + }); + builder.schedule(element); + intersectionObserverStub.notifySync({ + target: element, + isIntersecting: true, + }); + clock.tick(1); + expect(element.buildInternal).to.not.be.called; + + clock.tick(100); + expect(element.buildInternal).to.be.calledOnce; + }); + + it('should run non-deferred METADATA at low priority', () => { + const element = createAmpElement({ + deferredBuild: false, + buildPriority: LayoutPriority.METADATA, + }); + builder.schedule(element); + clock.tick(1); + expect(element.buildInternal).to.not.be.called; + + clock.tick(100); + expect(element.buildInternal).to.be.calledOnce; + }); + + it('should run asap METADATA at high priority', () => { + const element = createAmpElement({ + deferredBuild: false, + buildPriority: LayoutPriority.METADATA, + }); + builder.scheduleAsap(element); + clock.tick(1); + expect(element.buildInternal).to.be.calledOnce; + }); + }); +}); diff --git a/test/unit/test-custom-element-v1.js b/test/unit/test-custom-element-v1.js new file mode 100644 index 000000000000..22758e78541f --- /dev/null +++ b/test/unit/test-custom-element-v1.js @@ -0,0 +1,644 @@ +/** + * Copyright 2021 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 {BaseElement} from '../../src/base-element'; +import {CommonSignals} from '../../src/common-signals'; +import {ElementStub} from '../../src/element-stub'; +import {LayoutPriority} from '../../src/layout'; +import {Services} from '../../src/services'; +import {chunkInstanceForTesting} from '../../src/chunk'; +import { + createAmpElementForTesting, + getActionQueueForTesting, + getImplClassSyncForTesting, + getImplSyncForTesting, +} from '../../src/custom-element'; +import {getBuilderForDoc} from '../../src/service/builder'; + +describes.realWin('CustomElement V1', {amp: true}, (env) => { + let win, doc, ampdoc; + let resources, resourcesMock; + let builder, builderMock; + let ElementClass, StubElementClass; + let chunks; + + beforeEach(() => { + win = env.win; + doc = win.document; + ampdoc = env.ampdoc; + chunks = chunkInstanceForTesting(ampdoc); + + ElementClass = createAmpElementForTesting(win, TestElement); + StubElementClass = createAmpElementForTesting(win, ElementStub); + win.customElements.define('amp-test', ElementClass); + win.customElements.define('amp-stub', StubElementClass); + win.__AMP_EXTENDED_ELEMENTS['amp-test'] = TestElement; + win.__AMP_EXTENDED_ELEMENTS['amp-stub'] = ElementStub; + ampdoc.declareExtension('amp-stub'); + ElementClass.prototype.inspect = function () { + return this.tagName; + }; + StubElementClass.prototype.inspect = function () { + return this.tagName; + }; + + resources = Services.resourcesForDoc(ampdoc); + resourcesMock = env.sandbox.mock(resources); + resourcesMock.expects('upgraded').never(); + + builder = getBuilderForDoc(ampdoc); + builderMock = env.sandbox.mock(builder); + }); + + afterEach(() => { + resourcesMock.verify(); + builderMock.verify(); + }); + + class TestElement extends BaseElement { + static V1() { + return true; + } + + constructor(element, source) { + super(element); + this.source = source; + } + + isLayoutSupported() { + return true; + } + } + + describe('upgrade', () => { + it('should not create impl immediately when attached', () => { + const element = new ElementClass(); + + builderMock.expects('schedule').withExactArgs(element).once(); + + expect(element.isUpgraded()).to.be.false; + expect(getImplClassSyncForTesting(element)).to.equal(TestElement); + expect(getImplSyncForTesting(element)).to.be.null; + expect(element.getBuildPriority()).equal(LayoutPriority.CONTENT); + + doc.body.appendChild(element); + + expect(getImplClassSyncForTesting(element)).to.equal(TestElement); + expect(getImplSyncForTesting(element)).to.be.null; + expect(element.isUpgraded()).to.be.false; + expect(element.readyState).to.equal('building'); + expect(element.isBuilt()).to.be.false; + expect(element.getBuildPriority()).equal(LayoutPriority.CONTENT); + }); + + it('should not upgrade immediately when attached', () => { + const element = new StubElementClass(); + + builderMock.expects('schedule').withExactArgs(element).once(); + + expect(element.isUpgraded()).to.be.false; + expect(getImplClassSyncForTesting(element)).to.be.null; + expect(getImplSyncForTesting(element)).to.be.null; + expect(element.getBuildPriority()).equal(LayoutPriority.BACKGROUND); + + doc.body.appendChild(element); + element.upgrade(TestElement); + + expect(getImplClassSyncForTesting(element)).to.equal(TestElement); + expect(getImplSyncForTesting(element)).to.be.null; + expect(element.isUpgraded()).to.be.false; + expect(element.readyState).to.equal('building'); + expect(element.isBuilt()).to.be.false; + expect(element.getBuildPriority()).equal(LayoutPriority.CONTENT); + }); + + it('should reschedule build when re-attached', () => { + const element = new ElementClass(); + + builderMock.expects('schedule').withExactArgs(element).twice(); + builderMock.expects('unschedule').withExactArgs(element).once(); + + doc.body.appendChild(element); + expect(element.readyState).to.equal('building'); + + doc.body.removeChild(element); + expect(element.readyState).to.equal('building'); + + doc.body.appendChild(element); + expect(element.readyState).to.equal('building'); + }); + }); + + describe('preconnect', () => { + let preconnectMock; + let chunkStub; + + beforeEach(() => { + chunkStub = env.sandbox.stub(chunks, 'runForStartup'); + builderMock.expects('schedule').once(); + + const preconnect = Services.preconnectFor(win); + preconnectMock = env.sandbox.mock(preconnect); + }); + + afterEach(() => { + preconnectMock.verify(); + }); + + it('should preconnect on upgrade', () => { + env.sandbox.stub(TestElement, 'getPreconnects').returns(['url1', 'url2']); + preconnectMock.expects('url').withExactArgs(ampdoc, 'url1', false).once(); + preconnectMock.expects('url').withExactArgs(ampdoc, 'url2', false).once(); + + const element = new ElementClass(); + doc.body.appendChild(element); + expect(chunkStub).to.be.calledOnce; + chunkStub.firstCall.firstArg(); + }); + + it('should NOT preconnect on upgrade if not urls', () => { + preconnectMock.expects('url').never(); + + const element = new ElementClass(); + doc.body.appendChild(element); + expect(chunkStub).to.not.be.called; + }); + }); + + describe('buildInternal', () => { + let buildCallbackStub; + + beforeEach(() => { + buildCallbackStub = env.sandbox.stub( + TestElement.prototype, + 'buildCallback' + ); + builderMock.expects('schedule').atLeast(0); + }); + + it('should NOT allow build on unupgraded element', async () => { + expectAsyncConsoleError(/unupgraded/); + const element = new StubElementClass(); + doc.body.appendChild(element); + + expect(() => element.buildInternal()).to.throw(/unupgraded/); + expect(element.isBuilding()).to.be.false; + expect(getImplSyncForTesting(element)).to.be.null; + expect(buildCallbackStub).to.not.be.called; + expect(element.isUpgraded()).to.be.false; + expect(element.isBuilt()).to.be.false; + expect(element.readyState).to.equal('upgrading'); + }); + + it('should build a pre-upgraded element', async () => { + const attachedCallbackStub = env.sandbox.stub( + TestElement.prototype, + 'attachedCallback' + ); + + const element = new ElementClass(); + doc.body.appendChild(element); + + const promise = element.buildInternal(); + expect(element.isBuilding()).to.be.true; + expect(getImplSyncForTesting(element)).to.be.null; + expect(element.isUpgraded()).to.be.false; + expect(element.isBuilt()).to.be.false; + expect(element.readyState).to.equal('building'); + expect(element).to.have.class('i-amphtml-notbuilt'); + expect(element).to.have.class('amp-notbuilt'); + expect(element).to.not.have.class('i-amphtml-built'); + expect(element.signals().get(CommonSignals.BUILT)).to.be.null; + expect(attachedCallbackStub).to.not.be.called; + + await promise; + expect(getImplSyncForTesting(element)).to.be.instanceOf(TestElement); + expect(buildCallbackStub).to.be.calledOnce; + expect(element.isUpgraded()).to.be.true; + expect(element.isBuilt()).to.be.true; + expect(element.readyState).to.equal('complete'); + expect(element).to.not.have.class('i-amphtml-notbuilt'); + expect(element).to.not.have.class('amp-notbuilt'); + expect(element).to.have.class('i-amphtml-built'); + expect(element.signals().get(CommonSignals.BUILT)).to.exist; + expect(attachedCallbackStub).to.be.calledOnce; + }); + + it('should build an element after upgrade', async () => { + const attachedCallbackStub = env.sandbox.stub( + TestElement.prototype, + 'attachedCallback' + ); + + const element = new StubElementClass(); + doc.body.appendChild(element); + element.upgrade(TestElement); + + const promise = element.buildInternal(); + expect(element.isBuilding()).to.be.true; + expect(getImplSyncForTesting(element)).to.be.null; + expect(element.isUpgraded()).to.be.false; + expect(element.isBuilt()).to.be.false; + expect(element.readyState).to.equal('building'); + expect(attachedCallbackStub).to.not.be.called; + + await promise; + expect(getImplSyncForTesting(element)).to.be.instanceOf(TestElement); + expect(buildCallbackStub).to.be.calledOnce; + expect(element.isUpgraded()).to.be.true; + expect(element.isBuilt()).to.be.true; + expect(element.readyState).to.equal('complete'); + expect(element).to.not.have.class('i-amphtml-notbuilt'); + expect(element).to.not.have.class('amp-notbuilt'); + expect(element).to.have.class('i-amphtml-built'); + expect(element.signals().get(CommonSignals.BUILT)).to.exist; + expect(attachedCallbackStub).to.be.calledOnce; + }); + + it('should continue in loading state if buildCallback requests it', async () => { + buildCallbackStub.callsFake(function () { + this.setReadyState('loading'); + }); + + const element = new ElementClass(); + doc.body.appendChild(element); + + await element.buildInternal(); + expect(buildCallbackStub).to.be.calledOnce; + expect(element.readyState).to.equal('loading'); + }); + + it('should set the failing state if buildCallback fails', async () => { + expectAsyncConsoleError(/intentional/); + buildCallbackStub.throws(new Error('intentional')); + + const element = new ElementClass(); + doc.body.appendChild(element); + + try { + await element.buildInternal(); + throw new Error('must have failed'); + } catch (e) { + expect(e.toString()).to.match(/intentional/); + } + expect(element.readyState).to.equal('error'); + expect(element.signals().get(CommonSignals.BUILT)).to.exist; + expect(element.signals().get(CommonSignals.BUILT).toString()).to.match( + /intentional/ + ); + }); + + it('should set the failing state if buildCallback rejects', async () => { + expectAsyncConsoleError(/intentional/); + buildCallbackStub.rejects(new Error('intentional')); + + const element = new ElementClass(); + doc.body.appendChild(element); + + try { + await element.buildInternal(); + throw new Error('must have failed'); + } catch (e) { + expect(e.toString()).to.match(/intentional/); + } + expect(element.readyState).to.equal('error'); + }); + + it('should only execute build once', async () => { + const element = new ElementClass(); + doc.body.appendChild(element); + + const promise = element.buildInternal(); + const promise2 = element.buildInternal(); + expect(promise2).to.equal(promise); + + await promise; + await promise2; + const promise3 = element.buildInternal(); + expect(promise3).to.equal(promise); + expect(buildCallbackStub).to.be.calledOnce; + }); + + it('should continue build with a pre-created implementation', async () => { + const element = new ElementClass(); + doc.body.appendChild(element); + + await element.getImpl(false); + + await element.buildInternal(); + expect(buildCallbackStub).to.be.calledOnce; + expect(element.readyState).to.equal('complete'); + }); + + describe('consent', () => { + it('should build on consent sufficient', async () => { + const element = new ElementClass(); + env.sandbox + .stub(Services, 'consentPolicyServiceForDocOrNull') + .callsFake(() => { + return Promise.resolve({ + whenPolicyUnblock: () => { + return Promise.resolve(true); + }, + }); + }); + env.sandbox.stub(element, 'getConsentPolicy_').callsFake(() => { + return 'default'; + }); + doc.body.appendChild(element); + + await element.buildInternal(); + expect(buildCallbackStub).to.be.calledOnce; + expect(element.readyState).to.equal('complete'); + }); + + it('should not build on consent insufficient', async () => { + const element = new ElementClass(); + env.sandbox + .stub(Services, 'consentPolicyServiceForDocOrNull') + .callsFake(() => { + return Promise.resolve({ + whenPolicyUnblock: () => { + return Promise.resolve(false); + }, + }); + }); + env.sandbox.stub(element, 'getConsentPolicy_').callsFake(() => { + return 'default'; + }); + doc.body.appendChild(element); + + try { + await element.buildInternal(); + throw new Error('must have failed'); + } catch (e) { + expect(e.toString()).to.match(/BLOCK_BY_CONSENT/); + } + }); + + it('should respect user specified consent policy', async () => { + const element = new ElementClass(); + doc.body.appendChild(element); + await element.getImpl(false); + + expect(element.getConsentPolicy_()).to.equal(null); + element.setAttribute('data-block-on-consent', ''); + expect(element.getConsentPolicy_()).to.equal('default'); + element.setAttribute('data-block-on-consent', '_none'); + expect(element.getConsentPolicy_()).to.equal('_none'); + }); + + it('should repsect metaTag specified consent', async () => { + const meta = doc.createElement('meta'); + meta.setAttribute('name', 'amp-consent-blocking'); + meta.setAttribute('content', 'amp-test'); + doc.head.appendChild(meta); + + const element = new ElementClass(); + doc.body.appendChild(element); + await element.getImpl(false); + + expect(element.getConsentPolicy_()).to.equal('default'); + expect(element.getAttribute('data-block-on-consent')).to.equal( + 'default' + ); + }); + }); + }); + + describe('build', () => { + it('should only execute build once', async () => { + const element = new ElementClass(); + doc.body.appendChild(element); + + builderMock.expects('scheduleAsap').never(); + builderMock.expects('schedule').never(); + + const promise = element.buildInternal(); + const promise2 = element.build(); + expect(promise2).to.equal(promise); + + await promise; + await promise2; + const promise3 = element.build(); + expect(promise3).to.equal(promise); + }); + + it('should wait until the element is upgraded', async () => { + const element = new StubElementClass(); + + builderMock.expects('scheduleAsap').withExactArgs(element).once(); + + const promise = element.build(); + + doc.body.appendChild(element); + element.upgrade(TestElement); + + await element.buildInternal(); + await promise; + }); + }); + + describe('ensureLoaded', () => { + let element; + let buildCallbackStub; + let ensureLoadedStub; + + beforeEach(() => { + element = new StubElementClass(); + buildCallbackStub = env.sandbox.stub( + TestElement.prototype, + 'buildCallback' + ); + ensureLoadedStub = env.sandbox.stub( + TestElement.prototype, + 'ensureLoaded' + ); + builderMock.expects('schedule').atLeast(0); + builderMock.expects('scheduleAsap').atLeast(1); + }); + + it('should force build and wait for whenLoaded even if not marked as loading', async () => { + const promise = element.ensureLoaded(); + + doc.body.appendChild(element); + element.upgrade(TestElement); + + await element.buildInternal(); + await promise; + expect(ensureLoadedStub).to.be.calledOnce; + + await element.whenLoaded(); + }); + + it('should force build and ensureLoaded if loading', async () => { + buildCallbackStub.callsFake(function () { + this.setReadyState('loading'); + }); + ensureLoadedStub.callsFake(function () { + this.setReadyState('complete'); + }); + + const promise = element.ensureLoaded(); + + doc.body.appendChild(element); + element.upgrade(TestElement); + + await element.buildInternal(); + await promise; + expect(ensureLoadedStub).to.be.calledOnce; + + await element.whenLoaded(); + }); + }); + + describe('setReadyStateInternal', () => { + let element; + + beforeEach(async () => { + builderMock.expects('schedule').atLeast(0); + + element = new ElementClass(); + doc.body.appendChild(element); + await element.buildInternal(); + element.reset_(); + element.setReadyStateInternal('other'); + + env.sandbox.stub(element, 'toggleLoading'); + }); + + it('should update loading state', () => { + expect(element.readyState).equal('other'); + expect(element.toggleLoading).to.not.be.called; + expect(element.signals().get(CommonSignals.LOAD_START)).to.be.null; + element.signals().signal(CommonSignals.UNLOAD); + element.classList.remove('i-amphtml-layout'); + + element.setReadyStateInternal('loading'); + expect(element.readyState).equal('loading'); + expect(element.toggleLoading).to.be.calledOnce.calledWith(true); + expect(element.signals().get(CommonSignals.LOAD_START)).to.exist; + expect(element.signals().get(CommonSignals.UNLOAD)).to.be.null; + expect(element).to.have.class('i-amphtml-layout'); + }); + + it('should update complete state', () => { + const loadEventSpy = env.sandbox.spy(); + element.addEventListener('load', loadEventSpy); + + expect(element.readyState).equal('other'); + expect(element.toggleLoading).to.not.be.called; + expect(element.signals().get(CommonSignals.LOAD_END)).to.be.null; + element.classList.remove('i-amphtml-layout'); + + element.setReadyStateInternal('complete'); + expect(element.readyState).equal('complete'); + expect(element.toggleLoading).to.be.calledOnce.calledWith(false); + expect(element.signals().get(CommonSignals.LOAD_END)).to.exist; + expect(element).to.have.class('i-amphtml-layout'); + expect(loadEventSpy).to.be.calledOnce; + }); + + it('should update error state', () => { + const errorEventSpy = env.sandbox.spy(); + element.addEventListener('error', errorEventSpy); + + expect(element.readyState).equal('other'); + expect(element.toggleLoading).to.not.be.called; + expect(element.signals().get(CommonSignals.LOAD_END)).to.be.null; + + const error = new Error(); + element.setReadyStateInternal('error', error); + expect(element.readyState).equal('error'); + expect(element.toggleLoading).to.be.calledOnce.calledWith(false); + expect(element.signals().get(CommonSignals.LOAD_END)).to.equal(error); + expect(errorEventSpy).to.be.calledOnce; + }); + + it('should not duplicate events', () => { + const loadEventSpy = env.sandbox.spy(); + element.addEventListener('load', loadEventSpy); + + element.setReadyStateInternal('complete'); + expect(loadEventSpy).to.be.calledOnce; + + // Repeat. + element.setReadyStateInternal('complete'); + expect(loadEventSpy).to.be.calledOnce; // no change. + }); + }); + + describe('executeAction', () => { + beforeEach(async () => { + builderMock.expects('schedule').atLeast(0); + }); + + it('should enqueue actions until built and schedule build', () => { + const element = new ElementClass(); + const handler = env.sandbox.stub(TestElement.prototype, 'executeAction'); + + builderMock.expects('scheduleAsap').withExactArgs(element).once(); + + doc.body.appendChild(element); + + const inv = {}; + element.enqueAction(inv); + const actionQueue = getActionQueueForTesting(element); + expect(actionQueue.length).to.equal(1); + expect(actionQueue[0]).to.equal(inv); + expect(handler).to.not.be.called; + }); + + it('should execute action immediately after built', async () => { + builderMock.expects('scheduleAsap').never(); + const element = new ElementClass(); + const handler = env.sandbox.stub(TestElement.prototype, 'executeAction'); + doc.body.appendChild(element); + await element.buildInternal(); + + const inv = {}; + element.enqueAction(inv); + expect(handler).to.be.calledOnce.calledWith(inv, false); + expect(getActionQueueForTesting(element)).to.not.exist; + }); + + it('should dequeue all actions after build', async () => { + const element = new ElementClass(); + builderMock.expects('scheduleAsap').withExactArgs(element).atLeast(1); + + const handler = env.sandbox.stub(TestElement.prototype, 'executeAction'); + + const inv1 = {}; + const inv2 = {}; + element.enqueAction(inv1); + element.enqueAction(inv2); + const actionQueue = getActionQueueForTesting(element); + expect(actionQueue).to.have.length(2); + expect(actionQueue[0]).to.equal(inv1); + expect(actionQueue[1]).to.equal(inv2); + expect(handler).to.not.be.called; + + doc.body.appendChild(element); + await element.buildInternal(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(getActionQueueForTesting(element)).to.not.exist; + expect(handler) + .to.be.calledTwice.calledWith(inv1, true) + .calledWith(inv2, true); + }); + }); +}); diff --git a/test/unit/test-custom-element.js b/test/unit/test-custom-element.js index dcf02d2d4c19..5dce9951d45c 100644 --- a/test/unit/test-custom-element.js +++ b/test/unit/test-custom-element.js @@ -200,7 +200,7 @@ describes.realWin('CustomElement', {amp: true}, (env) => { expect(element.hasAttributes()).to.equal(false); expect(element.isUpgraded()).to.equal(false); expect(element.upgradeState_).to.equal(/* NOT_UPGRADED */ 1); - expect(element.readyState).to.equal('loading'); + expect(element.readyState).to.equal('upgrading'); expect(element.everAttached).to.equal(false); expect(element.getLayout()).to.equal(Layout.NODISPLAY); @@ -225,7 +225,7 @@ describes.realWin('CustomElement', {amp: true}, (env) => { expect(element.isBuilt()).to.equal(false); expect(element.hasAttributes()).to.equal(false); expect(element.isUpgraded()).to.equal(false); - expect(element.readyState).to.equal('loading'); + expect(element.readyState).to.equal('upgrading'); expect(element.everAttached).to.equal(false); expect(element.getLayout()).to.equal(Layout.NODISPLAY); @@ -396,6 +396,7 @@ describes.realWin('CustomElement', {amp: true}, (env) => { element.upgrade(TestElement); expect(element.isUpgraded()).to.equal(true); + expect(element.readyState).to.equal('building'); const impl = getImplSyncForTesting(element); expect(impl).to.be.instanceOf(TestElement); expect(impl.getLayout()).to.equal(Layout.FILL); @@ -414,6 +415,7 @@ describes.realWin('CustomElement', {amp: true}, (env) => { element.upgrade(TestElement); expect(element.isUpgraded()).to.equal(false); + expect(element.readyState).to.equal('upgrading'); expect(getImplSyncForTesting(element)).to.be.null; expect(element.isBuilt()).to.equal(false); }); @@ -435,6 +437,7 @@ describes.realWin('CustomElement', {amp: true}, (env) => { element.upgrade(TestElement); expect(element.isUpgraded()).to.equal(true); + expect(element.readyState).to.equal('building'); expect(getImplSyncForTesting(element)).to.be.instanceOf(TestElement); expect(element.isBuilt()).to.equal(false); }); @@ -448,6 +451,7 @@ describes.realWin('CustomElement', {amp: true}, (env) => { element.upgrade(TestElement); expect(element.isUpgraded()).to.equal(false); + expect(element.readyState).to.equal('upgrading'); expect(element.isBuilt()).to.equal(false); }); @@ -532,15 +536,6 @@ describes.realWin('CustomElement', {amp: true}, (env) => { expect(element.isUpgraded()).to.equal(false); }); - it('Element - build NOT allowed before attachment', () => { - const element = new ElementClass(); - allowConsoleError(() => { - expect(() => { - element.buildInternal(); - }).to.throw(/upgrade/); - }); - }); - it('Element - build allowed', () => { const element = new ElementClass(); @@ -684,6 +679,7 @@ describes.realWin('CustomElement', {amp: true}, (env) => { container.appendChild(element); return element.buildingPromise_.then(() => { expect(element.isBuilt()).to.equal(true); + expect(element.readyState).to.equal('loading'); expect(testElementCreatePlaceholderCallback).to.have.not.been.called; }); }); @@ -702,6 +698,7 @@ describes.realWin('CustomElement', {amp: true}, (env) => { // Call again. return element.buildInternal().then(() => { expect(element.isBuilt()).to.equal(true); + expect(element.readyState).to.equal('loading'); expect(testElementBuildCallback).to.be.calledOnce; setTimeout(() => { expect(testElementPreconnectCallback).to.be.calledOnce; @@ -710,7 +707,7 @@ describes.realWin('CustomElement', {amp: true}, (env) => { }); }); - it('Element - build is repeatable', () => { + it('Element - build is repeatable', async () => { const element = new ElementClass(); expect(element.isBuilt()).to.equal(false); expect(testElementBuildCallback).to.have.not.been.called; @@ -718,6 +715,8 @@ describes.realWin('CustomElement', {amp: true}, (env) => { container.appendChild(element); const buildingPromise = element.buildingPromise_; expect(element.buildInternal()).to.equal(buildingPromise); + // Skip a task. + await new Promise(setTimeout); expect(testElementBuildCallback).to.be.calledOnce; }); @@ -896,8 +895,10 @@ describes.realWin('CustomElement', {amp: true}, (env) => { const element = new ElementClass(); element.setAttribute('layout', 'fill'); container.appendChild(element); + expect(element.readyState).to.equal('building'); return element.buildInternal().then(() => { expect(element.isBuilt()).to.equal(true); + expect(element.readyState).to.equal('loading'); expect(testElementLayoutCallback).to.have.not.been.called; const p = element.layoutCallback(); @@ -1040,21 +1041,6 @@ describes.realWin('CustomElement', {amp: true}, (env) => { }); }); - it('StubElement - layoutCallback should fail before attach', () => { - const element = new StubElementClass(); - element.setAttribute('layout', 'fill'); - resourcesMock.expects('upgraded').withExactArgs(element).never(); - element.upgrade(TestElement); - allowConsoleError(() => { - expect(() => element.buildInternal()).to.throw( - /Cannot build unupgraded element/ - ); - }); - expect(element.isUpgraded()).to.equal(false); - expect(element.isBuilt()).to.equal(false); - expect(testElementLayoutCallback).to.have.not.been.called; - }); - it('StubElement - layoutCallback after attached', () => { const element = new StubElementClass(); element.setAttribute('layout', 'fill'); diff --git a/test/unit/test-resources.js b/test/unit/test-resources.js index d426fb07e803..5ca5e1451c7e 100644 --- a/test/unit/test-resources.js +++ b/test/unit/test-resources.js @@ -76,7 +76,7 @@ describes.realWin('Resources', {amp: true}, (env) => { }; const element = document.createElement('amp-el'); - element.V2 = () => false; + element.V1 = () => false; element.isBuilt = () => isBuilt; element.isBuilding = () => isBuilding; @@ -617,7 +617,7 @@ describes.realWin('Resources discoverWork', {amp: true}, (env) => { function createElement(rect) { const element = env.win.document.createElement('amp-test'); element.classList.add('i-amphtml-element'); - element.V2 = () => false; + element.V1 = () => false; element.signals = () => new Signals(); element.whenBuilt = () => Promise.resolve(); element.isBuilt = () => true; diff --git a/testing/element-v1.js b/testing/element-v1.js new file mode 100644 index 000000000000..e1a337ae3aed --- /dev/null +++ b/testing/element-v1.js @@ -0,0 +1,151 @@ +/** + * Copyright 2021 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 {BaseElement} from '../src/base-element'; + +/** + * @type {!Array<{ + * name: string, + * test: function(typeof BaseElement):boolean, + * }>} + */ +const RULES = [ + { + name: 'V1=true', + test: (implClass) => implClass.V1() === true, + }, + + { + name: 'If has getLayoutPriority, must also have getBuildPriority', + test: (implClass) => { + const hasLayoutPriority = + implClass.prototype.getLayoutPriority !== + BaseElement.prototype.getLayoutPriority; + const hasBuildPriority = + implClass.getBuildPriority !== BaseElement.getBuildPriority; + return !hasLayoutPriority || hasBuildPriority; + }, + }, + + { + name: 'If has preconnectCallback, must also have getPreconnects', + test: (implClass) => { + const hasPreconnectCallback = + implClass.prototype.preconnectCallback !== + BaseElement.prototype.preconnectCallback; + const hasGetPreconnects = + implClass.getPreconnects !== BaseElement.getPreconnects; + return !hasPreconnectCallback || hasGetPreconnects; + }, + }, + + { + name: 'If has layoutCallback, must also have ensureLoaded', + test: (implClass) => { + const hasLayoutCallback = + implClass.prototype.layoutCallback !== + BaseElement.prototype.layoutCallback; + const hasEnsureLoaded = + implClass.prototype.ensureLoaded !== BaseElement.prototype.ensureLoaded; + return !hasLayoutCallback || hasEnsureLoaded; + }, + }, + + { + name: 'Must not use getLayoutBox', + test: (implClass) => { + return !sourceIncludes(implClass, 'getLayoutBox'); + }, + }, + { + name: 'Must not use getLayoutSize', + test: (implClass) => { + return !sourceIncludes(implClass, 'getLayoutSize'); + }, + }, + + { + name: 'Must not have renderOutsideViewport', + test: (implClass) => { + const hasCallback = + implClass.prototype.renderOutsideViewport !== + BaseElement.prototype.renderOutsideViewport; + return !hasCallback; + }, + }, + { + name: 'Must not have idleRenderOutsideViewport', + test: (implClass) => { + const hasCallback = + implClass.prototype.idleRenderOutsideViewport !== + BaseElement.prototype.idleRenderOutsideViewport; + return !hasCallback; + }, + }, + { + name: 'Must not have isRelayoutNeeded', + test: (implClass) => { + const hasCallback = + implClass.prototype.isRelayoutNeeded !== + BaseElement.prototype.isRelayoutNeeded; + return !hasCallback; + }, + }, +]; + +/** + * @param {typeof BaseElement} implClass + * @param {{ + * exceptions: (!Array|undefined), + * }=} options + */ +export function testElementV1(implClass, options = {}) { + const exceptions = options.exceptions || []; + RULES.forEach(({name, test}) => { + if (exceptions.includes(name)) { + expect(test(implClass), 'unused exception: ' + name).to.be.false; + } else { + expect(test(implClass), name).to.be.true; + } + }); +} + +/** + * Returns `true` if the class's source contains the given substring. + * + * @param {typeof BaseElement} implClass + * @param {string} substring + * @return {boolean} + */ +function sourceIncludes(implClass, substring) { + const code = []; + code.push(implClass.toString()); + const classProps = Object.getOwnPropertyDescriptors(implClass); + for (const k in classProps) { + const desc = classProps[k]; + if (typeof desc.value == 'function') { + code.push(desc.value.toString()); + } + } + const protoProps = Object.getOwnPropertyDescriptors(implClass.prototype); + for (const k in protoProps) { + const desc = protoProps[k]; + if (typeof desc.value == 'function') { + code.push(desc.value.toString()); + } + } + return code.filter((code) => code.includes(substring)).length > 0; +} diff --git a/testing/intersection-observer-stub.js b/testing/intersection-observer-stub.js new file mode 100644 index 000000000000..af6c7db712c4 --- /dev/null +++ b/testing/intersection-observer-stub.js @@ -0,0 +1,97 @@ +/** + * Copyright 2021 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. + */ + +/** + * @param {!Object} sandbox + * @param {!Window} window + * @return {!IntersectionObservers} + */ +export function installIntersectionObserverStub(sandbox, win) { + return new IntersectionObservers(sandbox, win); +} + +class IntersectionObservers { + /** + * @param {!Object} sandbox + * @param {!Window} win + */ + constructor(sandbox, win) { + const observers = new Set(); + this.observers = observers; + + sandbox.stub(win, 'IntersectionObserver').value(function (callback) { + const observer = new IntersectionObserverStub(callback, () => { + observers.delete(observer); + }); + observers.add(observer); + return observer; + }); + } + + /** + * @param {!Element} target + * @return {boolean} + */ + isObserved(target) { + return Array.from(this.observers).some((observer) => + observer.elements.has(target) + ); + } + + /** + * @param {!IntersectionObserverEntry|!Array} entryOrEntries + */ + notifySync(entryOrEntries) { + const entries = Array.isArray(entryOrEntries) + ? entryOrEntries + : [entryOrEntries]; + this.observers.forEach((observer) => { + const subEntries = entries.filter(({target}) => + observer.elements.has(target) + ); + if (subEntries.length > 0) { + observer.callback(subEntries); + } + }); + } +} + +class IntersectionObserverStub { + constructor(callback, onDisconnect) { + this.onDisconnect_ = onDisconnect; + this.callback = callback; + this.elements = new Set(); + } + + disconnect() { + const onDisconnect = this.onDisconnect_; + onDisconnect(); + } + + /** + * @param {!Element} element + */ + observe(element) { + this.elements.add(element); + } + + /** + * @param {!Element} element + */ + unobserve(element) { + this.elements.delete(element); + } +}