diff --git a/build-system/server.js b/build-system/server.js index f00563830fd5..5a70d37681ca 100644 --- a/build-system/server.js +++ b/build-system/server.js @@ -404,6 +404,21 @@ app.use('/examples/amp-fresh.amp.(min.|max.)?html', function(req, res, next) { next(); }); + +app.use('/impression-proxy/', function(req, res) { + assertCors(req, res, ['GET']); + // Fake response with the following optional fields: + // location: The Url the that server would have sent redirect to w/o ALP + // tracking_url: URL that should be requested to track click + // gclid: The conversion tracking value + const body = { + 'location': 'localhost:8000/examples/?gclid=1234&foo=bar&example=123', + 'tracking_url': 'tracking_url', + 'gclid': '1234', + }; + res.send(body); +}); + // Proxy with unminified JS. // Example: // http://localhost:8000/max/s/www.washingtonpost.com/amphtml/news/post-politics/wp/2016/02/21/bernie-sanders-says-lower-turnout-contributed-to-his-nevada-loss-to-hillary-clinton/ diff --git a/src/document-info.js b/src/document-info.js index 14807df712fd..3bf8b8b44afb 100644 --- a/src/document-info.js +++ b/src/document-info.js @@ -44,8 +44,6 @@ export let DocumentInfoDef; export function documentInfoForDoc(nodeOrDoc) { return /** @type {!DocumentInfoDef} */ (getServiceForDoc(nodeOrDoc, 'documentInfo', ampdoc => { - const url = ampdoc.getUrl(); - const sourceUrl = getSourceUrl(url); const rootNode = ampdoc.getRootNode(); let canonicalUrl = rootNode && rootNode.AMP && rootNode.AMP.canonicalUrl; @@ -56,11 +54,19 @@ export function documentInfoForDoc(nodeOrDoc) { canonicalUrl = parseUrl(canonicalTag.href).href; } const pageViewId = getPageViewId(ampdoc.win); - return {url, sourceUrl, canonicalUrl, pageViewId}; + const res = { + get sourceUrl() { + return getSourceUrl(ampdoc.getUrl()); + }, + canonicalUrl, + pageViewId, + }; + return res; })); } + /** * Returns a relatively low entropy random string. * This should be called once per window and then cached for subsequent diff --git a/src/impression.js b/src/impression.js index 47418b423d4f..a44b11756282 100644 --- a/src/impression.js +++ b/src/impression.js @@ -14,11 +14,38 @@ * limitations under the License. */ -import {user} from './log'; +import {dev, user} from './log'; import {isExperimentOn} from './experiments'; import {viewerForDoc} from './viewer'; import {xhrFor} from './xhr'; +import { + isProxyOrigin, + parseUrl, + parseQueryString, + addParamsToUrl, +} from './url'; +import {timerFor} from './timer'; +import {getMode} from './mode'; +const TIMEOUT_VALUE = 8000; + +let trackImpressionPromise = null; + +/** + * A function to get the trackImpressionPromise; + * @return {!Promise} + */ +export function getTrackImpressionPromise() { + return dev().assert(trackImpressionPromise); +} + +/** + * Function that reset the trackImpressionPromise only for testing + * @visibleForTesting + */ +export function resetTrackImpressionPromiseForTesting() { + trackImpressionPromise = null; +} /** * Emit a HTTP request to a destination defined on the incoming URL. @@ -26,19 +53,30 @@ import {xhrFor} from './xhr'; * @param {!Window} win */ export function maybeTrackImpression(win) { + let resolveImpression; + + trackImpressionPromise = new Promise(resolve => { + resolveImpression = resolve; + }); + if (!isExperimentOn(win, 'alp')) { + resolveImpression(); return; } + const viewer = viewerForDoc(win.document); /** @const {string|undefined} */ const clickUrl = viewer.getParam('click'); + if (!clickUrl) { + resolveImpression(); return; } if (clickUrl.indexOf('https://') != 0) { user().warn('Impression', 'click fragment param should start with https://. Found ', clickUrl); + resolveImpression(); return; } if (win.location.hash) { @@ -47,15 +85,63 @@ export function maybeTrackImpression(win) { // avoid duplicate tracking. win.location.hash = ''; } + viewer.whenFirstVisible().then(() => { - invoke(win, clickUrl); + // TODO(@zhouyx) need test with a real response. + const promise = invoke(win, dev().assertString(clickUrl)).then(response => { + applyResponse(win, viewer, response); + }); + + // Timeout invoke promise after 8s and resolve trackImpressionPromise. + resolveImpression(timerFor(win).timeoutPromise(TIMEOUT_VALUE, promise, + 'timeout waiting for ad server response').catch(() => {})); }); } +/** + * Send the url to ad server and wait for its response + * @param {!Window} win + * @param {string} clickUrl + * @return {!Promise} + */ function invoke(win, clickUrl) { - xhrFor(win).fetchJson(clickUrl, { + if (getMode().localDev && !getMode().test) { + clickUrl = 'http://localhost:8000/impression-proxy?url=' + clickUrl; + } + return xhrFor(win).fetchJson(clickUrl, { credentials: 'include', requireAmpResponseSourceOrigin: true, }); - // TODO(@cramforce): Do something with the result. +} + +/** + * parse the response back from ad server + * Set for analytics purposes + * @param {!Window} win + * @param {!Object} response + */ +function applyResponse(win, viewer, response) { + const adLocation = response['location']; + const adTracking = response['tracking_url']; + + // If there is a tracking_url, need to track it + // Otherwise track the location + const trackUrl = adTracking || adLocation; + + if (trackUrl && !isProxyOrigin(trackUrl)) { + // To request the provided trackUrl for tracking purposes. + new Image().src = trackUrl; + } + + // Replace the location href params with new location params we get. + if (adLocation) { + if (!win.history.replaceState) { + return; + } + const currentHref = win.location.href; + const url = parseUrl(adLocation); + const params = parseQueryString(url.search); + const newHref = addParamsToUrl(currentHref, params); + win.history.replaceState(null, '', newHref); + } } diff --git a/src/service/url-replacements-impl.js b/src/service/url-replacements-impl.js index 341bf295f2b4..4607a7e76e9b 100644 --- a/src/service/url-replacements-impl.js +++ b/src/service/url-replacements-impl.js @@ -29,6 +29,7 @@ import {viewportForDoc} from '../viewport'; import {userNotificationManagerFor} from '../user-notification'; import {activityFor} from '../activity'; import {isExperimentOn} from '../experiments'; +import {getTrackImpressionPromise} from '../impression.js'; /** @private @const {string} */ @@ -163,6 +164,14 @@ export class UrlReplacements { return removeFragment(info.sourceUrl); })); + this.setAsync_('SOURCE_URL', () => { + return getTrackImpressionPromise().then(() => { + return this.getDocInfoValue_(info => { + return removeFragment(info.sourceUrl); + }); + }); + }); + // Returns the host of the Source URL for this AMP document. this.set_('SOURCE_HOST', this.getDocInfoValue_.bind(this, info => { return parseUrl(info.sourceUrl).host; @@ -186,15 +195,13 @@ export class UrlReplacements { })); this.set_('QUERY_PARAM', (param, defaultValue = '') => { - user().assert(param, - 'The first argument to QUERY_PARAM, the query string ' + - 'param is required'); - const url = parseUrl(this.ampdoc.win.location.href); - const params = parseQueryString(url.search); + return this.getQueryParamData_(param, defaultValue); + }); - return (typeof params[param] !== 'undefined') ? - params[param] : - defaultValue; + this.setAsync_('QUERY_PARAM', (param, defaultValue = '') => { + return getTrackImpressionPromise().then(() => { + return this.getQueryParamData_(param, defaultValue); + }); }); /** @@ -540,6 +547,23 @@ export class UrlReplacements { return navigationInfo[attribute]; } + /** + * Return the QUERY_PARAM from the current location href + * @param {*} param + * @param {string} defaultValue + * @return {string} + */ + getQueryParamData_(param, defaultValue) { + user().assert(param, + 'The first argument to QUERY_PARAM, the query string ' + + 'param is required'); + user().assert(typeof param == 'string', 'param should be a string'); + const url = parseUrl(this.ampdoc.win.location.href); + const params = parseQueryString(url.search); + return (typeof params[param] !== 'undefined') + ? params[param] : defaultValue; + } + /** * * Sets a synchronous value resolver for the variable with the specified name. diff --git a/src/service/xhr-impl.js b/src/service/xhr-impl.js index 0d9ce3d69d1b..6058fdfe4794 100644 --- a/src/service/xhr-impl.js +++ b/src/service/xhr-impl.js @@ -162,7 +162,6 @@ export class Xhr { const init = opt_init || {}; init.method = normalizeMethod_(init.method); setupJson_(init); - return this.fetchAmpCors_(input, init).then(response => { return assertSuccess(response); }).then(response => response.json()); diff --git a/test/functional/test-document-info.js b/test/functional/test-document-info.js index 437544d336db..6cf81e023134 100644 --- a/test/functional/test-document-info.js +++ b/test/functional/test-document-info.js @@ -63,12 +63,30 @@ describe('document-info', () => { }; win.document.defaultView = win; installDocService(win, true); - expect(documentInfoForDoc(win.document).url).to.equal( - 'https://cdn.ampproject.org/v/www.origin.com/foo/?f=0'); expect(documentInfoForDoc(win.document).sourceUrl).to.equal( 'http://www.origin.com/foo/?f=0'); }); + it('should provide the updated sourceUrl', () => { + const win = { + document: { + nodeType: /* document */ 9, + querySelector() { return 'http://www.origin.com/foo/?f=0'; }, + }, + Math: {random() { return 0.123456789; }}, + location: { + href: 'https://cdn.ampproject.org/v/www.origin.com/foo/?f=0', + }, + }; + win.document.defaultView = win; + installDocService(win, true); + expect(documentInfoForDoc(win.document).sourceUrl).to.equal( + 'http://www.origin.com/foo/?f=0'); + win.location.href = 'https://cdn.ampproject.org/v/www.origin.com/foo/?f=1'; + expect(documentInfoForDoc(win.document).sourceUrl).to.equal( + 'http://www.origin.com/foo/?f=1'); + }); + it('should provide the pageViewId', () => { return getWin('https://twitter.com/').then(win => { sandbox.stub(win.Math, 'random', () => 0.123456789); diff --git a/test/functional/test-impression.js b/test/functional/test-impression.js index 50581b5e47ec..5174784a533b 100644 --- a/test/functional/test-impression.js +++ b/test/functional/test-impression.js @@ -14,7 +14,11 @@ * limitations under the License. */ -import {maybeTrackImpression} from '../../src/impression'; +import { + getTrackImpressionPromise, + maybeTrackImpression, + resetTrackImpressionPromiseForTesting, +} from '../../src/impression'; import {toggleExperiment} from '../../src/experiments'; import {viewerForDoc} from '../../src/viewer'; import {xhrFor} from '../../src/xhr'; @@ -37,6 +41,7 @@ describe('impression', () => { }; sandbox.spy(xhr, 'fetchJson'); sandbox.stub(viewer, 'whenFirstVisible').returns(Promise.resolve()); + resetTrackImpressionPromiseForTesting(); }); afterEach(() => { @@ -44,10 +49,10 @@ describe('impression', () => { sandbox.restore(); }); - it('should do nothing if the experiment is off', () => { viewer.getParam.throws(new Error('Should not be called')); maybeTrackImpression(window); + return getTrackImpressionPromise().should.be.fulfilled; }); it('should do nothing if there is no click arg', () => { @@ -55,6 +60,7 @@ describe('impression', () => { viewer.getParam.withArgs('click').returns(''); maybeTrackImpression(window); expect(xhr.fetchJson.callCount).to.equal(0); + return getTrackImpressionPromise().should.be.fulfilled; }); it('should do nothing if there is the click arg is http', () => { @@ -62,6 +68,7 @@ describe('impression', () => { viewer.getParam.withArgs('click').returns('http://www.example.com'); maybeTrackImpression(window); expect(xhr.fetchJson.callCount).to.equal(0); + return getTrackImpressionPromise().should.be.fulfilled; }); it('should invoke URL', () => { @@ -80,4 +87,88 @@ describe('impression', () => { }); }); }); + + it('should do nothing if response is not received', () => { + toggleExperiment(window, 'alp', true); + viewer.getParam.withArgs('click').returns('https://www.example.com'); + xhr.fetchJson = () => { + setTimeout(() => { + return Promise.resolve({ + 'location': 'test_location?gclid=654321', + }); + }, 5000); + }; + const clock = sandbox.useFakeTimers(); + const promise = new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 2000); + }); + clock.tick(2001); + return promise.then(() => { + expect(window.location.href).to.not.contain('gclid=654321'); + // Reset + xhr.fetchJson = () => { + return Promise.resolve(); + }; + }); + }); + + it('should resolve trackImpressionPromise after timeout', () => { + toggleExperiment(window, 'alp', true); + viewer.getParam.withArgs('click').returns('https://www.example.com'); + xhr.fetchJson = () => { + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 10000); + }); + }; + const clock = sandbox.useFakeTimers(); + maybeTrackImpression(window); + return Promise.resolve().then(() => { + clock.tick(8001); + return getTrackImpressionPromise().should.be.fulfilled; + }); + }); + + it('should do nothing if get empty response', () => { + toggleExperiment(window, 'alp', true); + viewer.getParam.withArgs('click').returns('https://www.example.com'); + const prevHref = window.location.href; + maybeTrackImpression(window); + return Promise.resolve().then(() => { + return Promise.resolve().then(() => { + expect(window.location.href).to.equal(prevHref); + return getTrackImpressionPromise().should.be.fulfilled; + }); + }); + }); + + it('should replace location href only with query params', () => { + toggleExperiment(window, 'alp', true); + viewer.getParam.withArgs('click').returns('https://www.example.com'); + + xhr.fetchJson = () => { + return Promise.resolve({ + 'location': 'test_location?gclid=123456&foo=bar&example=123', + }); + }; + const prevHref = window.location.href; + console.log(prevHref); + window.history.replaceState(null, '', prevHref + '?bar=foo&test=4321'); + console.log(window.location.href); + maybeTrackImpression(window); + return Promise.resolve().then(() => { + return Promise.resolve().then(() => { + expect(window.location.href).to.equal('http://localhost:9876/context.html' + + '?bar=foo&test=4321&gclid=123456&foo=bar&example=123'); + xhr.fetchJson = () => { + return Promise.resolve(); + }; + window.history.replaceState(null, '', prevHref); + return getTrackImpressionPromise().should.be.fulfilled; + }); + }); + }); }); diff --git a/test/functional/test-url-replacements.js b/test/functional/test-url-replacements.js index c972b3ec7177..41ab3f61d5fa 100644 --- a/test/functional/test-url-replacements.js +++ b/test/functional/test-url-replacements.js @@ -33,6 +33,7 @@ import {setCookie} from '../../src/cookies'; import {parseUrl} from '../../src/url'; import {toggleExperiment} from '../../src/experiments'; import {viewerForDoc} from '../../src/viewer'; +import * as trackPromise from '../../src/impression'; import * as sinon from 'sinon'; @@ -198,6 +199,9 @@ describe('UrlReplacements', () => { }); it('should replace SOURCE_URL and _HOST', () => { + sandbox.stub(trackPromise, 'getTrackImpressionPromise', () => { + return Promise.resolve(); + }); return expandAsync('?url=SOURCE_URL&host=SOURCE_HOST').then(res => { expect(res).to.not.match(/SOURCE_URL/); expect(res).to.not.match(/SOURCE_HOST/); @@ -205,12 +209,31 @@ describe('UrlReplacements', () => { }); it('should replace SOURCE_URL and _HOSTNAME', () => { + sandbox.stub(trackPromise, 'getTrackImpressionPromise', () => { + return Promise.resolve(); + }); return expandAsync('?url=SOURCE_URL&host=SOURCE_HOSTNAME').then(res => { expect(res).to.not.match(/SOURCE_URL/); expect(res).to.not.match(/SOURCE_HOSTNAME/); }); }); + it('should update SOURCE_URL after track impression', () => { + const win = getFakeWindow(); + win.location = parseUrl('https://wrong.com'); + sandbox.stub(trackPromise, 'getTrackImpressionPromise', () => { + return new Promise(resolve => { + win.location = parseUrl('https://example.com?gclid=123456'); + resolve(); + }); + }); + return installUrlReplacementsServiceForDoc(win.ampdoc) + .expandAsync('?url=SOURCE_URL') + .then(res => { + expect(res).to.contain('example.com'); + }); + }); + it('should replace SOURCE_PATH', () => { return expandAsync('?path=SOURCE_PATH').then(res => { expect(res).to.not.match(/SOURCE_PATH/); @@ -727,10 +750,19 @@ describe('UrlReplacements', () => { it('should replace QUERY_PARAM with foo', () => { const win = getFakeWindow(); - win.location = parseUrl('https://example.com?query_string_param1=foo'); + win.location = parseUrl('https://example.com?query_string_param1=wrong'); + sandbox.stub(trackPromise, 'getTrackImpressionPromise', () => { + return new Promise(resolve => { + win.location = + parseUrl('https://example.com?query_string_param1=foo'); + resolve(); + console.log('promise resolve'); + }); + }); return installUrlReplacementsServiceForDoc(win.ampdoc) .expandAsync('?sh=QUERY_PARAM(query_string_param1)&s') .then(res => { + console.log('compare happend', res); expect(res).to.match(/sh=foo&s/); }); }); @@ -738,6 +770,9 @@ describe('UrlReplacements', () => { it('should replace QUERY_PARAM with ""', () => { const win = getFakeWindow(); win.location = parseUrl('https://example.com'); + sandbox.stub(trackPromise, 'getTrackImpressionPromise', () => { + return Promise.resolve(); + }); return installUrlReplacementsServiceForDoc(win.ampdoc) .expandAsync('?sh=QUERY_PARAM(query_string_param1)&s') .then(res => { @@ -748,6 +783,9 @@ describe('UrlReplacements', () => { it('should replace QUERY_PARAM with default_value', () => { const win = getFakeWindow(); win.location = parseUrl('https://example.com'); + sandbox.stub(trackPromise, 'getTrackImpressionPromise', () => { + return Promise.resolve(); + }); return installUrlReplacementsServiceForDoc(win.ampdoc) .expandAsync('?sh=QUERY_PARAM(query_string_param1,default_value)&s') .then(res => { @@ -758,6 +796,9 @@ describe('UrlReplacements', () => { it('should collect vars', () => { const win = getFakeWindow(); win.location = parseUrl('https://example.com?p1=foo'); + sandbox.stub(trackPromise, 'getTrackImpressionPromise', () => { + return Promise.resolve(); + }); return installUrlReplacementsServiceForDoc(win.ampdoc) .collectVars('?SOURCE_HOST&QUERY_PARAM(p1)&SIMPLE&FUNC&PROMISE', { 'SIMPLE': 21,