From 06ef0f73bfc2f0163502e9ac89983e6f923b54b0 Mon Sep 17 00:00:00 2001
From: Dima Voytenko
Date: Fri, 15 Jan 2021 11:25:14 -0800
Subject: [PATCH] Deferred build API and builder
---
.../global-configs/experiments-const.json | 1 +
build-system/tasks/presubmit-checks.js | 14 +
builtins/amp-img.js | 87 ++-
src/base-element.js | 111 ++++
src/custom-element.js | 311 +++++++--
src/ready-state.js | 47 ++
src/service/builder.js | 243 +++++++
src/service/resource.js | 3 +
src/service/resources-impl.js | 20 +-
test/fixtures/images.html | 18 +-
test/integration/test-amp-img.js | 4 +-
test/unit/test-amp-img-intrinsic.js | 284 ++++++++
test/unit/test-amp-img-v2.js | 628 ++++++++++++++++++
test/unit/test-amp-img.js | 241 +------
test/unit/test-builder.js | 422 ++++++++++++
test/unit/test-custom-element-v2.js | 566 ++++++++++++++++
test/unit/test-custom-element.js | 40 +-
testing/element-v2.js | 225 +++++++
testing/intersection-observer-stub.js | 84 +++
19 files changed, 3011 insertions(+), 338 deletions(-)
create mode 100644 src/ready-state.js
create mode 100644 src/service/builder.js
create mode 100644 test/unit/test-amp-img-intrinsic.js
create mode 100644 test/unit/test-amp-img-v2.js
create mode 100644 test/unit/test-builder.js
create mode 100644 test/unit/test-custom-element-v2.js
create mode 100644 testing/element-v2.js
create mode 100644 testing/intersection-observer-stub.js
diff --git a/build-system/global-configs/experiments-const.json b/build-system/global-configs/experiments-const.json
index e612c40960641..7be1103bd6991 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,
+ "V2_IMG_VIDEO": false,
"WITHIN_VIEWPORT_INOB": false
}
diff --git a/build-system/tasks/presubmit-checks.js b/build-system/tasks/presubmit-checks.js
index 7d906d047b9c9..462d3c091145f 100644
--- a/build-system/tasks/presubmit-checks.js
+++ b/build-system/tasks/presubmit-checks.js
@@ -148,6 +148,18 @@ const forbiddenTerms = {
],
},
// Service factories that should only be installed once.
+ '\\.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'],
+ },
'installActionServiceForDoc': {
message: privateServiceFactory,
allowlist: [
@@ -308,6 +320,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/services.js',
@@ -1398,6 +1411,7 @@ function hasAnyTerms(file) {
/^test-/.test(basename) ||
/^_init_tests/.test(basename) ||
/_test\.js$/.test(basename) ||
+ /testing\//.test(pathname) ||
/storybook\/[^/]+\.js$/.test(pathname);
if (!isTestFile) {
hasSrcInclusiveTerms = matchTerms(file, forbiddenTermsSrcInclusive);
diff --git a/builtins/amp-img.js b/builtins/amp-img.js
index f2730d3d8855c..5d1614196ca28 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 V2() {
+ return V2_IMG_VIDEO;
+ }
+
/** @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.V2() && !this.img_.complete) {
+ this.onReadyState(ReadyState.LOADING);
+ }
}
}
@@ -255,6 +288,38 @@ export class AmpImg extends BaseElement {
return false;
}
+ /** @override */
+ buildCallback() {
+ if (!AmpImg.V2()) {
+ return;
+ }
+
+ // A V2 amp-img loads and reloads automatically.
+ this.onReadyState(ReadyState.LOADING);
+ this.initialize_();
+ const img = dev().assertElement(this.img_);
+ if (img.complete) {
+ this.onReadyState(ReadyState.COMPLETE);
+ this.firstLayoutCompleted();
+ this.hideFallbackImg_();
+ }
+ listen(img, 'load', () => {
+ this.onReadyState(ReadyState.COMPLETE);
+ this.firstLayoutCompleted();
+ this.hideFallbackImg_();
+ });
+ listen(img, 'error', (reason) => {
+ this.onReadyState(ReadyState.ERROR, reason);
+ this.onImgLoadingError_();
+ });
+ }
+
+ /** @override */
+ ensureLoaded() {
+ const img = dev().assertElement(this.img_);
+ img.loading = 'eager';
+ }
+
/** @override */
layoutCallback() {
this.initialize_();
@@ -270,6 +335,10 @@ export class AmpImg extends BaseElement {
/** @override */
unlayoutCallback() {
+ if (!AmpImg.V2()) {
+ return;
+ }
+
if (this.unlistenError_) {
this.unlistenError_();
this.unlistenError_ = null;
@@ -314,10 +383,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);
}
}
@@ -327,13 +394,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/src/base-element.js b/src/base-element.js
index 3f7975e37bab8..1316e43c3d3da 100644
--- a/src/base-element.js
+++ b/src/base-element.js
@@ -104,6 +104,57 @@ import {isArray, toWin} from './types';
* @implements {BaseElementInterface}
*/
export class BaseElement {
+ /**
+ * Whether this element supports V2 protocol, which includes:
+ * 1. Layout/unlayout are not managed by the runtime, but instead are
+ * implemented by the element as needed.
+ * 2. The element will wait until it's fully parsed before building, unless
+ * it's mutable. See `mutable`.
+ * 3. The element can defer its build until later. See `deferredBuild`.
+ * 4. 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 V2() {
+ return false;
+ }
+
+ /**
+ * Whether this element supports mutations. A mutable element can be built
+ * immediately, even before the element has been fully parsed, thus it should
+ * be able to apply additional markup when it's parsed. Normally, however,
+ * the element will wait until it's fully parsed before building to save
+ * resources.
+ *
+ * Only used for V2 elements.
+ *
+ * @return {boolean}
+ * @nocollapse
+ */
+ static mutable() {
+ 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 V2 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 +186,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 +275,7 @@ export class BaseElement {
*
* The default priority for base elements is LayoutPriority.CONTENT.
* @return {number}
+ * TODO(#31915): remove once V2 migration is complete.
*/
getLayoutPriority() {
return LayoutPriority.CONTENT;
@@ -226,6 +308,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 V2 migration is complete.
*/
getLayoutBox() {
return this.element.getLayoutBox();
@@ -234,6 +317,7 @@ export class BaseElement {
/**
* Returns a previously measured layout size.
* @return {!./layout-rect.LayoutSizeDef}
+ * TODO(#31915): remove once V2 migration is complete.
*/
getLayoutSize() {
return this.element.getLayoutSize();
@@ -344,6 +428,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 V2 migration is complete.
*/
preconnectCallback(opt_onLayout) {
// Subclasses may override.
@@ -414,6 +499,26 @@ export class BaseElement {
return false;
}
+ /**
+ * Ensure that the element is being eagerly loaded.
+ *
+ * Only used for V2 elements.
+ */
+ ensureLoaded() {}
+
+ /**
+ * Update the current `readyState`.
+ *
+ * Only used for V2 elements.
+ *
+ * @param {!./ready-state.ReadyState} state
+ * @param {*=} opt_failure
+ * @final
+ */
+ onReadyState(state, opt_failure) {
+ this.element.onReadyStateInternal(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 +540,7 @@ export class BaseElement {
* {@link isRelayoutNeeded} method.
*
* @return {!Promise}
+ * TODO(#31915): remove once V2 migration is complete.
*/
layoutCallback() {
return Promise.resolve();
@@ -457,6 +563,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 V2 migration is complete.
*/
pauseCallback() {}
@@ -464,6 +571,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 V2 migration is complete.
*/
resumeCallback() {}
@@ -474,6 +582,7 @@ export class BaseElement {
* {@link layoutCallback} in case document becomes active again.
*
* @return {boolean}
+ * TODO(#31915): remove once V2 migration is complete.
*/
unlayoutCallback() {
return false;
@@ -483,6 +592,7 @@ export class BaseElement {
* Subclasses can override this method to opt-in into calling
* {@link unlayoutCallback} when paused.
* @return {boolean}
+ * TODO(#31915): remove once V2 migration is complete.
*/
unlayoutOnPause() {
return false;
@@ -947,6 +1057,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 V2 migration is complete.
*/
onLayoutMeasure() {}
diff --git a/src/custom-element.js b/src/custom-element.js
index 01f9b26b3f2cf..929098505b7c7 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.onReadyStateInternal(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.V2()) {
+ 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 V2 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,67 @@ 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.onReadyStateInternal(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.V2()) {
+ if (this.readyState_ == ReadyState.BUILDING) {
+ this.onReadyStateInternal(ReadyState.COMPLETE);
+ }
+ } else {
+ this.onReadyStateInternal(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 +545,11 @@ function createBaseCustomElementClass(win) {
CommonSignals.BUILT,
/** @type {!Error} */ (reason)
);
+
+ if (this.V2()) {
+ this.onReadyStateInternal(ReadyState.ERROR, reason);
+ }
+
if (!isBlockedByConsent(reason)) {
reportError(reason, this);
}
@@ -510,6 +558,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.V2()) {
+ const builder = getBuilderForDoc(this.getAmpDoc());
+ builder.scheduleAsap(this);
+ }
+ return this.whenBuilt();
+ });
+ }
+
/**
* @return {!Promise}
* @final
@@ -519,14 +587,22 @@ 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.V2()) {
+ if (this.readyState_ == ReadyState.LOADING) {
+ this.impl_.ensureLoaded();
+ return this.whenLoaded();
+ }
+ return;
+ }
+
const resource = this.getResource_();
if (resource.getState() == ResourceState.LAYOUT_COMPLETE) {
return;
@@ -550,10 +626,58 @@ function createBaseCustomElementClass(win) {
});
}
+ /**
+ * Update the internal ready state.
+ *
+ * @param {!ReadyState} state
+ * @param {*=} opt_failure
+ * @protected
+ * @final
+ */
+ onReadyStateInternal(state, opt_failure) {
+ if (state === this.readyState_) {
+ return;
+ }
+
+ this.readyState_ = state;
+
+ if (!this.V2()) {
+ return;
+ }
+
+ // V2 processing.
+ 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 V2 migration is complete.
*/
preconnect(onLayout) {
devAssert(this.isUpgraded());
@@ -572,6 +696,36 @@ function createBaseCustomElementClass(win) {
}
}
+ /**
+ * See `BaseElement.V2()`.
+ *
+ * @return {boolean}
+ * @final
+ */
+ V2() {
+ return this.implClass_ ? this.implClass_.V2() : false;
+ }
+
+ /**
+ * See `BaseElement.mutable()`.
+ *
+ * @return {boolean}
+ * @final
+ */
+ mutable() {
+ return this.implClass_ ? this.implClass_.mutable() : 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}
@@ -843,7 +997,7 @@ function createBaseCustomElementClass(win) {
reportError(e, this);
}
if (this.implClass_) {
- this.tryUpgrade_();
+ this.upgradeOrSchedule_();
}
if (!this.isUpgraded()) {
this.classList.add('amp-unresolved');
@@ -870,6 +1024,41 @@ function createBaseCustomElementClass(win) {
this.classList.remove('i-amphtml-layout-awaiting-size');
}
+ /**
+ * Upgrade or schedule element based on V2.
+ * @private @final
+ */
+ upgradeOrSchedule_() {
+ if (this.V2()) {
+ if (!this.buildingPromise_) {
+ this.onReadyStateInternal(ReadyState.BUILDING);
+ const builder = getBuilderForDoc(this.getAmpDoc());
+ builder.schedule(this);
+
+ // Preconnect, since the build scheduling may take a while.
+ 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)
+ );
+ });
+ }
+ }
+ } else {
+ this.tryUpgrade_();
+ }
+ }
+
/**
* Try to upgrade the element with the provided implementation.
* @return {!Promise|undefined}
@@ -888,6 +1077,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 +1158,10 @@ function createBaseCustomElementClass(win) {
if (this.impl_) {
this.impl_.detachedCallback();
}
+ if (!this.built_ && this.V2()) {
+ const builder = getBuilderForDoc(this.getAmpDoc());
+ builder.unschedule(this);
+ }
this.toggleLoading(false);
this.disposeMediaAttrs_();
}
@@ -1129,10 +1323,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 +1404,7 @@ function createBaseCustomElementClass(win) {
if (isLoadEvent) {
this.signals_.signal(CommonSignals.LOAD_END);
}
- this.readyState = 'complete';
+ this.onReadyStateInternal(ReadyState.COMPLETE);
this.layoutCount_++;
this.toggleLoading(false);
// Check if this is the first success layout that needs
@@ -1219,6 +1426,7 @@ function createBaseCustomElementClass(win) {
/** @type {!Error} */ (reason)
);
}
+ this.onReadyStateInternal(ReadyState.ERROR, reason);
this.layoutCount_++;
this.toggleLoading(false);
throw reason;
@@ -1446,11 +1654,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 +1980,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}
diff --git a/src/ready-state.js b/src/ready-state.js
new file mode 100644
index 0000000000000..3dd7e4206b4b7
--- /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 0000000000000..90f499346c44a
--- /dev/null
+++ b/src/service/builder.js
@@ -0,0 +1,243 @@
+/**
+ * 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});
+
+ if (target.mutable() && this.parsingTargets_) {
+ // Don't need to wait until parsing is complete.
+ removeItem(this.parsingTargets_, target);
+ this.maybeBuild_(target);
+ } else {
+ 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);
+ this.signalScanReady_();
+ }
+
+ /**
+ * @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.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.PAUSED && vs != VisibilityState.INACTIVE) {
+ this.targets_.forEach((_, target) => this.maybeBuild_(target));
+ }
+ }
+
+ /**
+ * @param {!AmpElement} target
+ * @private
+ */
+ waitParsing_(target) {
+ const parsingTargets = this.parsingTargets_;
+ if (parsingTargets && !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, {...current, 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 bd43b28398956..d5421b9a05165 100644
--- a/src/service/resource.js
+++ b/src/service/resource.js
@@ -1020,6 +1020,9 @@ export class Resource {
* @return {!Promise}
*/
loadedOnce() {
+ if (this.element.V2()) {
+ return this.element.whenLoaded();
+ }
return this.loadPromise_;
}
diff --git a/src/service/resources-impl.js b/src/service/resources-impl.js
index 5d68ecfcf306c..1eae8e8f3f6a4 100644
--- a/src/service/resources-impl.js
+++ b/src/service/resources-impl.js
@@ -1207,7 +1207,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.V2()
+ ) {
this.buildOrScheduleBuildForResource_(r, /* checkForDupes */ true);
}
if (this.intersectionObserver_) {
@@ -1273,7 +1277,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.V2()) {
// If element has owner, and measure is not requested, do nothing.
continue;
}
@@ -1334,7 +1338,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.V2()
+ ) {
continue;
}
// Note that when the document is not visible, neither are any of its
@@ -1359,6 +1367,7 @@ export class ResourcesImpl {
!r.isBuilt() &&
!r.isBuilding() &&
!r.hasOwner() &&
+ !r.element.V2() &&
r.hasBeenMeasured() &&
r.isDisplayed() &&
r.overlaps(loadRect)
@@ -1394,6 +1403,7 @@ export class ResourcesImpl {
if (
r.getState() == ResourceState.READY_FOR_LAYOUT &&
!r.hasOwner() &&
+ !r.element.V2() &&
r.isDisplayed() &&
r.idleRenderOutsideViewport()
) {
@@ -1413,6 +1423,7 @@ export class ResourcesImpl {
if (
r.getState() == ResourceState.READY_FOR_LAYOUT &&
!r.hasOwner() &&
+ !r.element.V2() &&
r.isDisplayed()
) {
dev().fine(TAG_, 'idle layout:', r.debugid);
@@ -1718,6 +1729,9 @@ export class ResourcesImpl {
opt_parentPriority,
opt_forceOutsideViewport
) {
+ if (resource.element.V2()) {
+ 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 dea5cb3e7d043..9d49483623726 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 0000000000000..8eaf0c880b31b
--- /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-v2.js b/test/unit/test-amp-img-v2.js
new file mode 100644
index 0000000000000..c78952d8e5ab7
--- /dev/null
+++ b/test/unit/test-amp-img-v2.js
@@ -0,0 +1,628 @@
+/**
+ * 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 {dispatchCustomEvent} from '../../src/dom';
+import {testElementV2} from '../../testing/element-v2';
+
+describes.realWin('amp-img V2', {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, 'V2').returns(true);
+
+ windowWidth = 320;
+ sandbox.stub(BaseElement.prototype, 'getViewport').callsFake(() => {
+ return {
+ getWidth: () => windowWidth,
+ };
+ });
+ });
+
+ function createImg(attributes, children) {
+ const img = 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 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('testElementV2', () => {
+ testElementV2(AmpImg, {
+ exceptions: [
+ 'Must not have preconnectCallback',
+ 'Must not have layoutCallback',
+ 'Must not have unlayoutCallback',
+ 'Must not use getLayoutSize',
+ 'Must not have firstLayoutCompleted',
+ ],
+ });
+ });
+
+ 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 63f6d62685c1a..91249e5697e87 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;
@@ -781,240 +778,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 0000000000000..93cefc1895a4b
--- /dev/null
+++ b/test/unit/test-builder.js
@@ -0,0 +1,422 @@
+/**
+ * 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.mutable = () => options.mutable || false;
+ 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 signal READY_SCAN_SIGNAL after first element scheduled', 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.exist;
+ });
+
+ 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 build asap when mutab;e', async () => {
+ const element = createAmpElement({deferredBuild: false, mutable: true});
+ doc.body.appendChild(element);
+ builder.scheduleAsap(element);
+ clock.tick(1);
+ expect(element.buildInternal).to.be.calledOnce;
+ });
+
+ 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-v2.js b/test/unit/test-custom-element-v2.js
new file mode 100644
index 0000000000000..a0211e2571a31
--- /dev/null
+++ b/test/unit/test-custom-element-v2.js
@@ -0,0 +1,566 @@
+/**
+ * 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,
+ getImplClassSyncForTesting,
+ getImplSyncForTesting,
+} from '../../src/custom-element';
+import {getBuilderForDoc} from '../../src/service/builder';
+
+describes.realWin('CustomElement V2', {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 V2() {
+ 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);
+ });
+ });
+
+ 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.onReadyState('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 immediately resolve if not loading', async () => {
+ const promise = element.ensureLoaded();
+
+ doc.body.appendChild(element);
+ element.upgrade(TestElement);
+
+ await element.buildInternal();
+ await promise;
+ expect(ensureLoadedStub).to.not.be.called;
+
+ await element.whenLoaded();
+ });
+
+ it('should force build and ensureLoaded if loading', async () => {
+ buildCallbackStub.callsFake(function () {
+ this.onReadyState('loading');
+ });
+ ensureLoadedStub.callsFake(function () {
+ this.onReadyState('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('onReadyStateInternal', () => {
+ let element;
+
+ beforeEach(async () => {
+ builderMock.expects('schedule').atLeast(0);
+
+ element = new ElementClass();
+ doc.body.appendChild(element);
+ await element.buildInternal();
+ element.reset_();
+ element.onReadyStateInternal('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.onReadyStateInternal('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.onReadyStateInternal('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.onReadyStateInternal('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.onReadyStateInternal('complete');
+ expect(loadEventSpy).to.be.calledOnce;
+
+ // Repeat.
+ element.onReadyStateInternal('complete');
+ expect(loadEventSpy).to.be.calledOnce; // no change.
+ });
+ });
+});
diff --git a/test/unit/test-custom-element.js b/test/unit/test-custom-element.js
index dcf02d2d4c19a..5dce9951d45ca 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/testing/element-v2.js b/testing/element-v2.js
new file mode 100644
index 0000000000000..b0fc64a5b88af
--- /dev/null
+++ b/testing/element-v2.js
@@ -0,0 +1,225 @@
+/**
+ * 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: 'V2=true',
+ test: (implClass) => implClass.V2() === true,
+ },
+
+ {
+ name: 'Must not have getLayoutPriority',
+ test: (implClass) => {
+ const hasLayoutPriority =
+ implClass.prototype.getLayoutPriority !==
+ BaseElement.prototype.getLayoutPriority;
+ return !hasLayoutPriority;
+ },
+ },
+ {
+ 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: 'Must not have preconnectCallback',
+ test: (implClass) => {
+ const hasPreconnectCallback =
+ implClass.prototype.preconnectCallback !==
+ BaseElement.prototype.preconnectCallback;
+ return !hasPreconnectCallback;
+ },
+ },
+ {
+ 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: 'Must not have layoutCallback',
+ test: (implClass) => {
+ const hasLayoutCallback =
+ implClass.prototype.layoutCallback !==
+ BaseElement.prototype.layoutCallback;
+ return !hasLayoutCallback;
+ },
+ },
+ {
+ 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 have onLayoutMeasure',
+ test: (implClass) => {
+ const hasCallback =
+ implClass.prototype.onLayoutMeasure !==
+ BaseElement.prototype.onLayoutMeasure;
+ return !hasCallback;
+ },
+ },
+ {
+ 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 pauseCallback',
+ test: (implClass) => {
+ const hasCallback =
+ implClass.prototype.pauseCallback !==
+ BaseElement.prototype.pauseCallback;
+ return !hasCallback;
+ },
+ },
+ {
+ name: 'Must not have resumeCallback',
+ test: (implClass) => {
+ const hasCallback =
+ implClass.prototype.resumeCallback !==
+ BaseElement.prototype.resumeCallback;
+ return !hasCallback;
+ },
+ },
+ {
+ name: 'Must not have unlayoutCallback',
+ test: (implClass) => {
+ const hasCallback =
+ implClass.prototype.unlayoutCallback !==
+ BaseElement.prototype.unlayoutCallback;
+ return !hasCallback;
+ },
+ },
+ {
+ name: 'Must not have unlayoutOnPause',
+ test: (implClass) => {
+ const hasCallback =
+ implClass.prototype.unlayoutOnPause !==
+ BaseElement.prototype.unlayoutOnPause;
+ return !hasCallback;
+ },
+ },
+ {
+ name: 'Must not have firstLayoutCompleted',
+ test: (implClass) => {
+ const hasCallback =
+ implClass.prototype.firstLayoutCompleted !==
+ BaseElement.prototype.firstLayoutCompleted;
+ return !hasCallback;
+ },
+ },
+ {
+ 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 testElementV2(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;
+ }
+ });
+}
+
+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 0000000000000..cd3a9b2e3c686
--- /dev/null
+++ b/testing/intersection-observer-stub.js
@@ -0,0 +1,84 @@
+/**
+ * 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 {
+ 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;
+ });
+ }
+
+ isObserved(target) {
+ let found = false;
+ this.observers.forEach((observer) => {
+ if (observer.elements.has(target)) {
+ found = true;
+ }
+ });
+ return found;
+ }
+
+ 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();
+ }
+
+ observe(element) {
+ this.elements.add(element);
+ }
+
+ unobserve(element) {
+ this.elements.delete(element);
+ }
+}