diff --git a/extensions/amp-analytics/0.1/amp-analytics.js b/extensions/amp-analytics/0.1/amp-analytics.js index 38b26eb4a744..dd83212a7ddc 100644 --- a/extensions/amp-analytics/0.1/amp-analytics.js +++ b/extensions/amp-analytics/0.1/amp-analytics.js @@ -39,6 +39,7 @@ import {expandTemplate} from '../../../src/string'; import {getMode} from '../../../src/mode'; import {installLinkerReaderService} from './linker-reader'; import {isArray, isEnumValue} from '../../../src/types'; +import {isExperimentOn} from '../../../src/experiments'; import {isIframed} from '../../../src/dom'; import {isInFie} from '../../../src/iframe-helper'; import {toggle} from '../../../src/style'; @@ -105,6 +106,12 @@ export class AmpAnalytics extends AMP.BaseElement { /** @private {?boolean} */ this.isInFie_ = null; + + /** @private {boolean} */ + this.multiSelectorVisibilityOn_ = isExperimentOn( + this.win, + 'multi-selector-visibility-trigger' + ); } /** @override */ @@ -360,6 +367,21 @@ export class AmpAnalytics extends AMP.BaseElement { this.addTrigger_(trigger); } else if (trigger['selector']) { // Expand the selector using variable expansion. + if (this.multiSelectorVisibilityOn_) { + if (Array.isArray(trigger['selector'])) { + const selectorPromises = trigger['selector'].map(s => { + return this.variableService_.expandTemplate( + s, + expansionOptions, + this.element + ); + }); + return Promise.all(selectorPromises).then(selectors => { + trigger['selector'] = selectors; + this.addTrigger_(trigger); + }); + } + } return this.variableService_ .expandTemplate( trigger['selector'], diff --git a/extensions/amp-analytics/0.1/events.js b/extensions/amp-analytics/0.1/events.js index 4a3702730c1d..0b3c97844437 100644 --- a/extensions/amp-analytics/0.1/events.js +++ b/extensions/amp-analytics/0.1/events.js @@ -27,6 +27,7 @@ import {dict, hasOwn} from '../../../src/utils/object'; import {getData} from '../../../src/event-helper'; import {getDataParamsFromAttributes} from '../../../src/dom'; import {isEnumValue, isFiniteNumber} from '../../../src/types'; +import {isExperimentOn} from '../../../src/experiments'; import {startsWith} from '../../../src/string'; const SCROLL_PRECISION_PERCENT = 5; @@ -1424,7 +1425,10 @@ export class VisibilityTracker extends EventTracker { const waitForSpec = visibilitySpec['waitFor']; let reportWhenSpec = visibilitySpec['reportWhen']; let createReportReadyPromiseFunc = null; - + const multiSelectorVisibilityOn = isExperimentOn( + this.root.ampdoc.win, + 'multi-selector-visibility-trigger' + ); if (reportWhenSpec) { userAssert( !visibilitySpec['repeat'], @@ -1468,6 +1472,29 @@ export class VisibilityTracker extends EventTracker { ); } + const getUnlistenPromiseForSelector = (selector, selectionMethod) => { + return this.root + .getAmpElement( + context.parentElement || context, + selector, + selectionMethod + ) + .then(element => { + return visibilityManagerPromise.then( + visibilityManager => { + return visibilityManager.listenElement( + element, + visibilitySpec, + this.getReadyPromise(waitForSpec, selector, element), + createReportReadyPromiseFunc, + this.onEvent_.bind(this, eventType, listener, element) + ); + }, + () => {} + ); + }); + }; + let unlistenPromise; // Root selectors are delegated to analytics roots. if (!selector || selector == ':root' || selector == ':host') { @@ -1494,31 +1521,28 @@ export class VisibilityTracker extends EventTracker { // false missed searches. const selectionMethod = config['selectionMethod'] || visibilitySpec['selectionMethod']; - unlistenPromise = this.root - .getAmpElement( - context.parentElement || context, + + if (multiSelectorVisibilityOn && Array.isArray(selector)) { + unlistenPromise = Promise.all( + selector.map(s => getUnlistenPromiseForSelector(s, selectionMethod)) + ); + } else { + unlistenPromise = getUnlistenPromiseForSelector( selector, selectionMethod - ) - .then(element => { - return visibilityManagerPromise.then( - visibilityManager => { - return visibilityManager.listenElement( - element, - visibilitySpec, - this.getReadyPromise(waitForSpec, selector, element), - createReportReadyPromiseFunc, - this.onEvent_.bind(this, eventType, listener, element) - ); - }, - () => {} - ); - }); + ); + } } return function() { unlistenPromise.then(unlisten => { - unlisten(); + if (multiSelectorVisibilityOn && Array.isArray(unlisten)) { + for (let i = 0; i < unlisten.length; i++) { + unlisten[i](); + } + } else { + unlisten(); + } }); }; } diff --git a/extensions/amp-analytics/0.1/test/test-amp-analytics.js b/extensions/amp-analytics/0.1/test/test-amp-analytics.js index 03589c519ed5..92513dd5e1a7 100644 --- a/extensions/amp-analytics/0.1/test/test-amp-analytics.js +++ b/extensions/amp-analytics/0.1/test/test-amp-analytics.js @@ -34,6 +34,7 @@ import { import {installCryptoService} from '../../../../src/service/crypto-impl'; import {installUserNotificationManagerForTesting} from '../../../amp-user-notification/0.1/amp-user-notification'; import {instrumentationServiceForDocForTesting} from '../instrumentation'; +import {toggleExperiment} from '../../../../src/experiments'; describes.realWin( 'amp-analytics', @@ -817,6 +818,74 @@ describes.realWin( }); }); + describe('expand multi-selector visibility', () => { + beforeEach(() => { + toggleExperiment(win, 'multi-selector-visibility-trigger', true); + }); + + afterEach(() => { + toggleExperiment(win, 'multi-selector-visibility-trigger', false); + }); + + it('expands selector with config variable', () => { + const tracker = ins.root_.getTracker('visible', VisibilityTracker); + const addStub = env.sandbox.stub(tracker, 'add'); + const analytics = getAnalyticsTag({ + requests: {foo: 'https://example.test/bar'}, + triggers: [ + {on: 'visible', selector: ['${foo}', '${title}'], request: 'foo'}, + ], + vars: {foo: 'bar'}, + }); + return waitForNoSendRequest(analytics).then(() => { + expect(addStub).to.be.calledOnce; + const config = addStub.args[0][2]; + expect(config['selector']).to.deep.equal(['bar', 'My Test Title']); + }); + }); + + function selectorExpansionTest(selector) { + it('expand selector value: ' + selector, () => { + const tracker = ins.root_.getTracker('visible', VisibilityTracker); + const addStub = env.sandbox.stub(tracker, 'add'); + const analytics = getAnalyticsTag({ + requests: {foo: 'https://example.test/bar'}, + triggers: [ + {on: 'visible', selector: ['${foo}, ${bar}'], request: 'foo'}, + ], + vars: {foo: selector, bar: '123'}, + }); + return waitForNoSendRequest(analytics).then(() => { + expect(addStub).to.be.calledOnce; + const config = addStub.args[0][2]; + expect(config['selector']).to.deep.equal([selector + ', 123']); + }); + }); + } + + [ + '.clazz', + 'a, div', + 'a .foo', + 'a #foo', + 'a > div', + 'div + p', + 'div ~ ul', + '[target=_blank]', + '[title~=flower]', + '[lang|=en]', + 'a[href^="https"]', + 'a[href$=".pdf"]', + 'a[href="w3schools"]', + 'a:active', + 'p::after', + 'p:first-child', + 'p:lang(it)', + ':not(p)', + 'p:nth-child(2)', + ].map(selectorExpansionTest); + }); + describe('optout by function', () => { beforeEach(() => { env.sandbox.stub(AnalyticsConfig.prototype, 'loadConfig').returns( diff --git a/extensions/amp-analytics/0.1/test/test-events.js b/extensions/amp-analytics/0.1/test/test-events.js index 108ad00e7850..779a605050bf 100644 --- a/extensions/amp-analytics/0.1/test/test-events.js +++ b/extensions/amp-analytics/0.1/test/test-events.js @@ -32,6 +32,7 @@ import {AmpdocAnalyticsRoot} from '../analytics-root'; import {Deferred} from '../../../../src/utils/promise'; import {Signals} from '../../../../src/utils/signals'; import {macroTask} from '../../../../testing/yield'; +import {toggleExperiment} from '../../../../src/experiments'; describes.realWin('Events', {amp: 1}, env => { let win; @@ -40,7 +41,9 @@ describes.realWin('Events', {amp: 1}, env => { let handler; let analyticsElement; let target; + let target2; let child; + let child2; beforeEach(() => { win = env.win; @@ -55,9 +58,17 @@ describes.realWin('Events', {amp: 1}, env => { target.classList.add('target'); win.document.body.appendChild(target); + target2 = win.document.createElement('div'); + target2.classList.add('target2'); + win.document.body.appendChild(target2); + child = win.document.createElement('div'); child.classList.add('child'); target.appendChild(child); + + child2 = win.document.createElement('div'); + child2.classList.add('child2'); + target2.appendChild(child2); }); describe('AnalyticsEventType', () => { @@ -1864,6 +1875,113 @@ describes.realWin('Events', {amp: 1}, env => { }); }); + describe('multi selector visibility trigger', () => { + let targetSignals2; + let saveCallback2; + let eventsSpy; + + beforeEach(() => { + toggleExperiment(win, 'multi-selector-visibility-trigger', true); + + eventsSpy = env.sandbox.spy(tracker, 'onEvent_'); + + target2.classList.add('i-amphtml-element'); + targetSignals2 = new Signals(); + target2.signals = () => targetSignals2; + + saveCallback2 = env.sandbox.match(arg => { + if (typeof arg == 'function') { + saveCallback2.callback = arg; + return true; + } + return false; + }); + }); + + afterEach(() => { + toggleExperiment(win, 'multi-selector-visibility-trigger', false); + }); + + it('should fire event per selector', async () => { + const config = {visibilitySpec: {selector: ['.target', '.target2']}}; + const unlisten = env.sandbox.spy(); + const unlisten2 = env.sandbox.spy(); + iniLoadTrackerMock.expects('getRootSignal').twice(); + const readyPromise = Promise.resolve(); + // const readyPromise2 = Promise.resolve(); + iniLoadTrackerMock + .expects('getElementSignal') + .withExactArgs('ini-load', target) + .returns(readyPromise) + .once(); + iniLoadTrackerMock + .expects('getElementSignal') + .withExactArgs('ini-load', target2) + .returns(readyPromise) + .once(); + visibilityManagerMock + .expects('listenElement') + .withExactArgs( + target, + config.visibilitySpec, + readyPromise, + /* createReportReadyPromiseFunc */ null, + saveCallback + ) + .returns(unlisten) + .once(); + visibilityManagerMock + .expects('listenElement') + .withExactArgs( + target2, + config.visibilitySpec, + readyPromise, + /* createReportReadyPromiseFunc */ null, + saveCallback2 + ) + .returns(unlisten2) + .once(); + // Dispose function + const res = tracker.add( + analyticsElement, + 'visible', + config, + eventResolver + ); + expect(res).to.be.a('function'); + const unlistenReady = getAmpElementSpy.returnValues[0]; + const unlistenReady2 = getAmpElementSpy.returnValues[1]; + // #getAmpElement Promise + await unlistenReady; + await unlistenReady2; + // #assertMeasurable_ Promise + await macroTask(); + await macroTask(); + saveCallback.callback({totalVisibleTime: 10}); + saveCallback2.callback({totalVisibleTime: 15}); + + // Testing that visibilty manager mock sends state to onEvent_ + expect(eventsSpy.getCall(0).args[0]).to.equal('visible'); + expect(eventsSpy.getCall(0).args[1]).to.equal(eventResolver); + expect(eventsSpy.getCall(0).args[2]).to.equal(target); + expect(eventsSpy.getCall(0).args[3]).to.deep.equal({ + totalVisibleTime: 10, + }); + expect(eventsSpy.getCall(1).args[0]).to.equal('visible'); + expect(eventsSpy.getCall(1).args[1]).to.equal(eventResolver); + expect(eventsSpy.getCall(1).args[2]).to.equal(target2); + expect(eventsSpy.getCall(1).args[3]).to.deep.equal({ + totalVisibleTime: 15, + }); + + expect(unlisten).to.not.be.called; + expect(unlisten2).to.not.be.called; + await res(); + expect(unlisten).to.be.calledOnce; + expect(unlisten2).to.be.calledOnce; + }); + }); + it('should expand data params', function*() { target.setAttribute('data-vars-foo', 'bar'); diff --git a/test/manual/amp-analytics-visible-repeat.amp.html b/test/manual/amp-analytics-visible-repeat.amp.html index 39c936213aca..de18753dea22 100644 --- a/test/manual/amp-analytics-visible-repeat.amp.html +++ b/test/manual/amp-analytics-visible-repeat.amp.html @@ -56,6 +56,23 @@ } + + + diff --git a/tools/experiments/experiments-config.js b/tools/experiments/experiments-config.js index 6b44948956dc..7dce53b12da2 100644 --- a/tools/experiments/experiments-config.js +++ b/tools/experiments/experiments-config.js @@ -305,4 +305,10 @@ export const EXPERIMENTS = [ spec: 'https://github.com/ampproject/amphtml/issues/20595', cleanupIssue: 'https://github.com/ampproject/amphtml/issues/26709', }, + { + id: 'multi-selector-visibility-trigger', + name: 'AMP Analytics Multi-selector Visibility Trigger', + spec: 'https://github.com/ampproject/amphtml/issues/26823', + cleanupIssue: 'https://github.com/ampproject/amphtml/issues/26823', + }, ];