From 46bc9e7a6d7998906f82d4b5396c2d54e9c0e8d2 Mon Sep 17 00:00:00 2001 From: Dima Voytenko Date: Fri, 21 Apr 2017 15:10:49 -0700 Subject: [PATCH] Adopt ClickHandler for FIE (#8864) * Adopt ClickHandler for FIE * restrict parseUrlWithA * fixed types * whitelist presubmits --- build-system/tasks/presubmit-checks.js | 8 + src/amp.js | 2 - src/document-click.js | 258 --------- src/inabox/amp-inabox.js | 2 - src/runtime.js | 2 + src/service/document-click.js | 297 +++++++++++ src/service/extensions-impl.js | 1 + src/url.js | 30 +- test/functional/test-document-click.js | 699 +++++++++++++------------ 9 files changed, 706 insertions(+), 593 deletions(-) delete mode 100644 src/document-click.js create mode 100644 src/service/document-click.js diff --git a/build-system/tasks/presubmit-checks.js b/build-system/tasks/presubmit-checks.js index e03cb021cdd9c..dd67a0ec55f2e 100644 --- a/build-system/tasks/presubmit-checks.js +++ b/build-system/tasks/presubmit-checks.js @@ -283,6 +283,14 @@ var forbiddenTerms = { 'tools/experiments/experiments.js', ], }, + 'parseUrlWithA': { + message: 'Use parseUrl instead.', + whitelist: [ + 'src/url.js', + 'src/service/document-click.js', + 'dist.3p/current/integration.js', + ], + }, '\\.sendMessage\\(': { message: 'Usages must be reviewed.', whitelist: [ diff --git a/src/amp.js b/src/amp.js index 3ffbde4197326..3d4f1b77371d0 100644 --- a/src/amp.js +++ b/src/amp.js @@ -27,7 +27,6 @@ import { performanceFor, } from './service/performance-impl'; import {installPullToRefreshBlocker} from './pull-to-refresh'; -import {installGlobalClickListenerForDoc} from './document-click'; import {installStyles, makeBodyVisible} from './style-installer'; import {installErrorReporting} from './error'; import {installDocService} from './service/ampdoc-impl'; @@ -97,7 +96,6 @@ startupChunk(self.document, function initial() { }); startupChunk(self.document, function final() { installPullToRefreshBlocker(self); - installGlobalClickListenerForDoc(ampdoc); maybeValidate(self); makeBodyVisible(self.document, /* waitForServices */ true); diff --git a/src/document-click.js b/src/document-click.js deleted file mode 100644 index fb58c2056f4b2..0000000000000 --- a/src/document-click.js +++ /dev/null @@ -1,258 +0,0 @@ -/** - * Copyright 2015 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 { - closestByTag, - openWindowDialog, - escapeCssSelectorIdent, - isIframed, -} from './dom'; -import {registerServiceBuilderForDoc} from './service'; -import {dev} from './log'; -import {historyForDoc} from './services'; -import {parseUrl} from './url'; -import {viewerForDoc} from './services'; -import {viewportForDoc} from './services'; -import {platformFor} from './services'; -import {timerFor} from './services'; -import {urlReplacementsForDoc} from './services'; - - -/** - * Install click handler service for ampdoc. Immediately instantiates the - * the click handler service. - * @param {!./service/ampdoc-impl.AmpDoc} ampdoc - */ -export function installGlobalClickListenerForDoc(ampdoc) { - registerServiceBuilderForDoc( - ampdoc, - 'clickhandler', - ClickHandler, - /* opt_factory */ undefined, - /* opt_instantiate */ true); -} - - -/** - * Intercept any click on the current document and prevent any - * linking to an identifier from pushing into the history stack. - * @visibleForTesting - */ -export class ClickHandler { - /** - * @param {!./service/ampdoc-impl.AmpDoc} ampdoc - */ - constructor(ampdoc) { - /** @const {!./service/ampdoc-impl.AmpDoc} */ - this.ampdoc = ampdoc; - - /** @private @const {!./service/viewport-impl.Viewport} */ - this.viewport_ = viewportForDoc(this.ampdoc); - - /** @private @const {!./service/viewer-impl.Viewer} */ - this.viewer_ = viewerForDoc(this.ampdoc); - - /** @private @const {!./service/history-impl.History} */ - this.history_ = historyForDoc(this.ampdoc); - - const platform = platformFor(this.ampdoc.win); - /** @private @const {boolean} */ - this.isIosSafari_ = platform.isIos() && platform.isSafari(); - - /** @private @const {boolean} */ - this.isIframed_ = - isIframed(this.ampdoc.win) && this.viewer_.isOvertakeHistory(); - - /** @private @const {!function(!Event)|undefined} */ - this.boundHandle_ = this.handle_.bind(this); - this.ampdoc.getRootNode().addEventListener('click', this.boundHandle_); - } - - /** - * Removes all event listeners. - */ - cleanup() { - if (this.boundHandle_) { - this.ampdoc.getRootNode().removeEventListener('click', this.boundHandle_); - } - } - - /** - * Click event handler which on bubble propagation intercepts any click on the - * current document and prevent any linking to an identifier from pushing into - * the history stack. - * @param {!Event} e - */ - handle_(e) { - onDocumentElementClick_( - e, this.ampdoc, this.viewport_, this.history_, this.isIosSafari_, - this.isIframed_); - } -} - - -/** - * Intercept any click on the current document and prevent any - * linking to an identifier from pushing into the history stack. - * - * This also handles custom protocols (e.g. whatsapp://) when iframed - * on iOS Safari. - * - * @param {!Event} e - * @param {!./service/ampdoc-impl.AmpDoc} ampdoc - * @param {!./service/viewport-impl.Viewport} viewport - * @param {!./service/history-impl.History} history - * @param {boolean} isIosSafari - * @param {boolean} isIframed - */ -export function onDocumentElementClick_( - e, ampdoc, viewport, history, isIosSafari, isIframed) { - if (e.defaultPrevented) { - return; - } - - const target = closestByTag(dev().assertElement(e.target), 'A'); - if (!target || !target.href) { - return; - } - urlReplacementsForDoc(ampdoc).maybeExpandLink(target); - - const tgtLoc = parseUrl(target.href); - // Handle custom protocols only if the document is iframe'd. - if (isIframed) { - handleCustomProtocolClick_(e, target, tgtLoc, ampdoc, isIosSafari); - } - - if (tgtLoc.hash) { - handleHashClick_(e, tgtLoc, ampdoc, viewport, history); - } -} - - -/** - * Handles clicking on a custom protocol link. - * @param {!Event} e - * @param {!Element} target - * @param {!Location} tgtLoc - * @param {!./service/ampdoc-impl.AmpDoc} ampdoc - * @param {boolean} isIosSafari - * @private - */ -function handleCustomProtocolClick_(e, target, tgtLoc, ampdoc, isIosSafari) { - /** @const {!Window} */ - const win = ampdoc.win; - // On Safari iOS, custom protocol links will fail to open apps when the - // document is iframed - in order to go around this, we set the top.location - // to the custom protocol href. - const isFTP = tgtLoc.protocol == 'ftp:'; - - // In case of FTP Links in embedded documents always open then in _blank. - if (isFTP) { - openWindowDialog(win, target.href, '_blank'); - e.preventDefault(); - } - - const isNormalProtocol = /^(https?|mailto):$/.test(tgtLoc.protocol); - if (isIosSafari && !isNormalProtocol) { - openWindowDialog(win, target.href, '_top'); - // Without preventing default the page would should an alert error twice - // in the case where there's no app to handle the custom protocol. - e.preventDefault(); - } -} - - -/** - * Handles clicking on a link with hash navigation. - * @param {!Event} e - * @param {!Location} tgtLoc - * @param {!./service/ampdoc-impl.AmpDoc} ampdoc - * @param {!./service/viewport-impl.Viewport} viewport - * @param {!./service/history-impl.History} history - * @private - */ -function handleHashClick_(e, tgtLoc, ampdoc, viewport, history) { - /** @const {!Window} */ - const win = ampdoc.win; - /** @const {!Location} */ - const curLoc = parseUrl(win.location.href); - const tgtHref = `${tgtLoc.origin}${tgtLoc.pathname}${tgtLoc.search}`; - const curHref = `${curLoc.origin}${curLoc.pathname}${curLoc.search}`; - - // If the current target anchor link is the same origin + path - // as the current document then we know we are just linking to an - // identifier in the document. - if (tgtHref != curHref) { - return; - } - - // We prevent default so that the current click does not push - // into the history stack as this messes up the external documents - // history which contains the amp document. - e.preventDefault(); - - // Look for the referenced element. - const hash = tgtLoc.hash.slice(1); - let elem = null; - - if (hash) { - const escapedHash = escapeCssSelectorIdent(win, hash); - elem = (ampdoc.getRootNode().getElementById(hash) || - // Fallback to anchor[name] if element with id is not found. - // Linking to an anchor element with name is obsolete in html5. - ampdoc.getRootNode().querySelector(`a[name="${escapedHash}"]`)); - } - - // If possible do update the URL with the hash. As explained above - // we do `replace` to avoid messing with the container's history. - if (tgtLoc.hash != curLoc.hash) { - history.replaceStateForTarget(tgtLoc.hash).then(() => { - scrollToElement(elem, win, viewport, hash); - }); - } else { - // If the hash did not update just scroll to the element. - scrollToElement(elem, win, viewport, hash); - } -} - - -/** - * Scrolls the page to the given element. - * @param {?Element} elem - * @param {!Window} win - * @param {!./service/viewport-impl.Viewport} viewport - * @param {string} hash - */ -function scrollToElement(elem, win, viewport, hash) { - // Scroll to the element if found. - if (elem) { - // The first call to scrollIntoView overrides browsers' default - // scrolling behavior. The second call insides setTimeout allows us to - // scroll to that element properly. - // Without doing this, the viewport will not catch the updated scroll - // position on iOS Safari and hence calculate the wrong scrollTop for - // the scrollbar jumping the user back to the top for failing to calculate - // the new jumped offset. - // Without the first call there will be a visual jump due to browser scroll. - // See https://github.com/ampproject/amphtml/issues/5334 for more details. - viewport./*OK*/scrollIntoView(elem); - timerFor(win).delay(() => viewport./*OK*/scrollIntoView( - dev().assertElement(elem)), 1); - } else { - dev().warn('HTML', - `failed to find element with id=${hash} or a[name=${hash}]`); - } -} diff --git a/src/inabox/amp-inabox.js b/src/inabox/amp-inabox.js index 02230a6cdaf4c..6ab1b31bece52 100644 --- a/src/inabox/amp-inabox.js +++ b/src/inabox/amp-inabox.js @@ -28,7 +28,6 @@ import { performanceFor, } from '../service/performance-impl'; import {installPullToRefreshBlocker} from '../pull-to-refresh'; -import {installGlobalClickListenerForDoc} from '../document-click'; import {installStyles, makeBodyVisible} from '../style-installer'; import {installErrorReporting} from '../error'; import {installDocService} from '../service/ampdoc-impl'; @@ -111,7 +110,6 @@ startupChunk(self.document, function initial() { }); startupChunk(self.document, function final() { installPullToRefreshBlocker(self); - installGlobalClickListenerForDoc(ampdoc); installAnchorClickInterceptor(ampdoc, self); maybeValidate(self); diff --git a/src/runtime.js b/src/runtime.js index 2c162c3c90799..102690f733a73 100644 --- a/src/runtime.js +++ b/src/runtime.js @@ -51,6 +51,7 @@ import { import {installActionServiceForDoc} from './service/action-impl'; import {installCryptoService} from './service/crypto-impl'; import {installDocumentInfoServiceForDoc} from './service/document-info-impl'; +import {installGlobalClickListenerForDoc} from './service/document-click'; import {installGlobalSubmitListenerForDoc} from './document-submit'; import {extensionsFor} from './services'; import {installHistoryServiceForDoc} from './service/history-impl'; @@ -145,6 +146,7 @@ export function installAmpdocServices(ampdoc, opt_initParams) { installActionServiceForDoc(ampdoc); installStandardActionsForDoc(ampdoc); installStorageServiceForDoc(ampdoc); + installGlobalClickListenerForDoc(ampdoc); installGlobalSubmitListenerForDoc(ampdoc); } diff --git a/src/service/document-click.js b/src/service/document-click.js new file mode 100644 index 0000000000000..92087e3dce484 --- /dev/null +++ b/src/service/document-click.js @@ -0,0 +1,297 @@ +/** + * Copyright 2015 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 { + closestByTag, + openWindowDialog, + escapeCssSelectorIdent, + isIframed, +} from '../dom'; +import { + registerServiceBuilderForDoc, + installServiceInEmbedScope, +} from '../service'; +import {dev} from '../log'; +import {getMode} from '../mode'; +import { + historyForDoc, + platformFor, + timerFor, + urlReplacementsForDoc, + viewerForDoc, + viewportForDoc, +} from '../services'; +import {parseUrl, parseUrlWithA} from '../url'; + +const TAG = 'clickhandler'; + + +/** + * Install click handler service for ampdoc. Immediately instantiates the + * the click handler service. + * @param {!./ampdoc-impl.AmpDoc} ampdoc + */ +export function installGlobalClickListenerForDoc(ampdoc) { + registerServiceBuilderForDoc( + ampdoc, + TAG, + ClickHandler, + /* opt_factory */ undefined, + /* opt_instantiate */ true); +} + + +/** + * Intercept any click on the current document and prevent any + * linking to an identifier from pushing into the history stack. + * @implements {../service.EmbeddableService} + * @visibleForTesting + */ +export class ClickHandler { + /** + * @param {!./ampdoc-impl.AmpDoc} ampdoc + * @param {(!Document|!ShadowRoot)=} opt_rootNode + */ + constructor(ampdoc, opt_rootNode) { + /** @const {!./ampdoc-impl.AmpDoc} */ + this.ampdoc = ampdoc; + + /** @private @const {!Document|!ShadowRoot} */ + this.rootNode_ = opt_rootNode || ampdoc.getRootNode(); + + /** @private @const {!./viewport-impl.Viewport} */ + this.viewport_ = viewportForDoc(this.ampdoc); + + /** @private @const {!./viewer-impl.Viewer} */ + this.viewer_ = viewerForDoc(this.ampdoc); + + /** @private @const {!./history-impl.History} */ + this.history_ = historyForDoc(this.ampdoc); + + const platform = platformFor(this.ampdoc.win); + /** @private @const {boolean} */ + this.isIosSafari_ = platform.isIos() && platform.isSafari(); + + /** @private @const {boolean} */ + this.isIframed_ = + isIframed(this.ampdoc.win) && this.viewer_.isOvertakeHistory(); + + /** @private @const {boolean} */ + this.isEmbed_ = this.rootNode_ != this.ampdoc.getRootNode(); + + /** @private @const {boolean} */ + this.isInABox_ = getMode(this.ampdoc.win).runtime == 'inabox'; + + /** + * Used for URL resolution in embeds. + * @private {?HTMLAnchorElement} + */ + this.embedA_ = null; + + /** @private @const {!function(!Event)|undefined} */ + this.boundHandle_ = this.handle_.bind(this); + this.rootNode_.addEventListener('click', this.boundHandle_); + } + + /** @override */ + adoptEmbedWindow(embedWin) { + installServiceInEmbedScope(embedWin, TAG, + new ClickHandler(this.ampdoc, embedWin.document)); + } + + /** + * Removes all event listeners. + */ + cleanup() { + if (this.boundHandle_) { + this.rootNode_.removeEventListener('click', this.boundHandle_); + } + } + + /** + * Intercept any click on the current document and prevent any + * linking to an identifier from pushing into the history stack. + * + * This also handles custom protocols (e.g. whatsapp://) when iframed + * on iOS Safari. + * + * @param {!Event} e + * @private + */ + handle_(e) { + if (e.defaultPrevented) { + return; + } + + const target = closestByTag(dev().assertElement(e.target), 'A'); + if (!target || !target.href) { + return; + } + urlReplacementsForDoc(target).maybeExpandLink(target); + + const tgtLoc = this.parseUrl_(target.href); + + // Handle custom protocols only if the document is iframed. + if (this.isIframed_) { + this.handleCustomProtocolClick_(e, target, tgtLoc); + } + + // Handle navigation clicks. + if (!e.defaultPrevented) { + this.handleNavClick_(e, target, tgtLoc); + } + } + + /** + * Handles clicking on a custom protocol link. + * @param {!Event} e + * @param {!Element} target + * @param {!Location} tgtLoc + * @private + */ + handleCustomProtocolClick_(e, target, tgtLoc) { + /** @const {!Window} */ + const win = target.ownerDocument.defaultView; + // On Safari iOS, custom protocol links will fail to open apps when the + // document is iframed - in order to go around this, we set the top.location + // to the custom protocol href. + const isFTP = tgtLoc.protocol == 'ftp:'; + + // In case of FTP Links in embedded documents always open then in _blank. + if (isFTP) { + openWindowDialog(win, target.href, '_blank'); + e.preventDefault(); + return; + } + + const isNormalProtocol = /^(https?|mailto):$/.test(tgtLoc.protocol); + if (this.isIosSafari_ && !isNormalProtocol) { + openWindowDialog(win, target.href, '_top'); + // Without preventing default the page would should an alert error twice + // in the case where there's no app to handle the custom protocol. + e.preventDefault(); + } + } + + /** + * Handles clicking on a link with hash navigation. + * @param {!Event} e + * @param {!Element} target + * @param {!Location} tgtLoc + * @private + */ + handleNavClick_(e, target, tgtLoc) { + /** @const {!Window} */ + const win = e.target.ownerDocument.defaultView; + /** @const {!Location} */ + const curLoc = this.parseUrl_(''); + const tgtHref = `${tgtLoc.origin}${tgtLoc.pathname}${tgtLoc.search}`; + const curHref = `${curLoc.origin}${curLoc.pathname}${curLoc.search}`; + + // If the current target anchor link is the same origin + path + // as the current document then we know we are just linking to an + // identifier in the document. Otherwise, it's an external navigation. + if (!tgtLoc.hash || tgtHref != curHref) { + if (this.isEmbed_ || this.isInABox_) { + // Target in the embed must be either _top or _blank. If none specified, + // force to _blank. + const targetAttr = (target.getAttribute('target') || '').toLowerCase(); + if (targetAttr != '_top' && targetAttr != '_blank') { + target.setAttribute('target', '_blank'); + } + } + return; + } + + // We prevent default so that the current click does not push + // into the history stack as this messes up the external documents + // history which contains the amp document. + e.preventDefault(); + + // For an embed, do not perform scrolling or global history push - both have + // significant UX and browser problems. + if (this.isEmbed_) { + return; + } + + // Look for the referenced element. + const hash = tgtLoc.hash.slice(1); + let elem = null; + if (hash) { + const escapedHash = escapeCssSelectorIdent(win, hash); + elem = (this.rootNode_.getElementById(hash) || + // Fallback to anchor[name] if element with id is not found. + // Linking to an anchor element with name is obsolete in html5. + this.rootNode_./*OK*/querySelector(`a[name="${escapedHash}"]`)); + } + + // If possible do update the URL with the hash. As explained above + // we do `replace` to avoid messing with the container's history. + if (tgtLoc.hash != curLoc.hash) { + this.history_.replaceStateForTarget(tgtLoc.hash).then(() => { + this.scrollToElement_(elem, hash); + }); + } else { + // If the hash did not update just scroll to the element. + this.scrollToElement_(elem, hash); + } + } + + /** + * Scrolls the page to the given element. + * @param {?Element} elem + * @param {string} hash + * @private + */ + scrollToElement_(elem, hash) { + // Scroll to the element if found. + if (elem) { + // The first call to scrollIntoView overrides browsers' default + // scrolling behavior. The second call insides setTimeout allows us to + // scroll to that element properly. + // Without doing this, the viewport will not catch the updated scroll + // position on iOS Safari and hence calculate the wrong scrollTop for + // the scrollbar jumping the user back to the top for failing to calculate + // the new jumped offset. + // Without the first call there will be a visual jump due to browser scroll. + // See https://github.com/ampproject/amphtml/issues/5334 for more details. + this.viewport_./*OK*/scrollIntoView(elem); + timerFor(this.ampdoc.win).delay(() => this.viewport_./*OK*/scrollIntoView( + dev().assertElement(elem)), 1); + } else { + dev().warn(TAG, + `failed to find element with id=${hash} or a[name=${hash}]`); + } + } + + /** + * @param {string} url + * @return {!Location} + * @private + */ + parseUrl_(url) { + if (this.isEmbed_) { + let a = this.embedA_; + if (!a) { + const embedDoc = (this.rootNode_.ownerDocument || this.rootNode_); + a = /** @type {!HTMLAnchorElement} */ (embedDoc.createElement('a')); + this.embedA_ = a; + } + return parseUrlWithA(a, url); + } + return parseUrl(url || this.ampdoc.win.location.href); + } +} diff --git a/src/service/extensions-impl.js b/src/service/extensions-impl.js index cf4a25d530790..35500ef8506e9 100644 --- a/src/service/extensions-impl.js +++ b/src/service/extensions-impl.js @@ -621,4 +621,5 @@ function adoptServicesForEmbed(childWin) { // to pass the "embeddable" flag if this set becomes too unwieldy. adoptServiceForEmbed(childWin, 'action'); adoptServiceForEmbed(childWin, 'standard-actions'); + adoptServiceForEmbed(childWin, 'clickhandler'); } diff --git a/src/url.js b/src/url.js index e4517e00221eb..1966daba007c2 100644 --- a/src/url.js +++ b/src/url.js @@ -60,7 +60,29 @@ export function parseUrl(url, opt_nocache) { if (fromCache) { return fromCache; } + + const info = parseUrlWithA(a, url); + + // Freeze during testing to avoid accidental mutation. + const frozen = (getMode().test && Object.freeze) ? Object.freeze(info) : info; + + if (opt_nocache) { + return frozen; + } + return cache[url] = frozen; +} + +/** + * Returns a Location-like object for the given URL. If it is relative, + * the URL gets resolved. + * @param {!HTMLAnchorElement} a + * @param {string} url + * @return {!Location} + * @restricted + */ +export function parseUrlWithA(a, url) { a.href = url; + // IE11 doesn't provide full URL components when parsing relative URLs. // Assigning to itself again does the trick. // TODO(lannka, #3449): Remove all the polyfills once we don't support IE11 @@ -104,13 +126,7 @@ export function parseUrl(url, opt_nocache) { } else { info.origin = info.protocol + '//' + info.host; } - // Freeze during testing to avoid accidental mutation. - const frozen = (getMode().test && Object.freeze) ? Object.freeze(info) : info; - - if (opt_nocache) { - return frozen; - } - return cache[url] = frozen; + return info; } /** diff --git a/test/functional/test-document-click.js b/test/functional/test-document-click.js index 5ac40716489b9..23b029f05d77b 100644 --- a/test/functional/test-document-click.js +++ b/test/functional/test-document-click.js @@ -14,393 +14,444 @@ * limitations under the License. */ -import {onDocumentElementClick_} from '../../src/document-click'; -import {installTimerService} from '../../src/service/timer-impl'; -import { - installUrlReplacementsServiceForDoc, -} from '../../src/service/url-replacements-impl'; -import {installDocumentInfoServiceForDoc,} from - '../../src/service/document-info-impl'; -import * as sinon from 'sinon'; - -describe('test-document-click onDocumentElementClick_', () => { - let sandbox; - let evt; - let doc; - let win; - let ampdoc; - let history; - let tgt; - let elem; - let docElem; - let getElementByIdSpy; - let preventDefaultSpy; - let scrollIntoViewSpy; - let querySelectorSpy; - let viewport; - let timerFuncSpy; - let replaceStateForTargetSpy; - let replaceStateForTargetPromise; - let replaceStateForTargetResolver; +import '../../src/service/document-click'; + + +describes.sandboxed('ClickHandler', {}, () => { + let event; beforeEach(() => { - replaceStateForTargetPromise = new Promise(resolve => { - replaceStateForTargetResolver = resolve; - }); - sandbox = sinon.sandbox.create(); - preventDefaultSpy = sandbox.spy(); - scrollIntoViewSpy = sandbox.spy(); - timerFuncSpy = sandbox.stub(); - elem = {nodeType: 1}; - getElementByIdSpy = sandbox.stub(); - querySelectorSpy = sandbox.stub(); - replaceStateForTargetSpy = sandbox.stub(); - tgt = document.createElement('a'); - tgt.href = 'https://www.google.com'; - win = { - document: {}, - location: { - href: 'https://www.google.com/some-path?hello=world#link', - }, - setTimeout: fn => { - timerFuncSpy(); - fn(); - }, - Object, - Math, - services: { - 'viewport': {obj: {}}, - }, - }; - ampdoc = { - win, - isSingleDoc: () => true, - getRootNode: () => { - return { - getElementById: getElementByIdSpy, - querySelector: querySelectorSpy, - }; - }, - getUrl: () => win.location.href, - }; - doc = {defaultView: win}; - docElem = { - nodeType: 1, - ownerDocument: doc, + event = { + target: null, + defaultPrevented: false, }; - evt = { - currentTarget: docElem, - target: tgt, - preventDefault: preventDefaultSpy, + event.preventDefault = function() { + event.defaultPrevented = true; }; - viewport = { - scrollIntoView: scrollIntoViewSpy, - }; - history = { - push: () => Promise.resolve(), - replaceStateForTarget: hash => { - replaceStateForTargetSpy(hash); - return replaceStateForTargetPromise; - }, - }; - installTimerService(win); - installDocumentInfoServiceForDoc(ampdoc); - installUrlReplacementsServiceForDoc(ampdoc); - }); - - afterEach(() => { - sandbox.restore(); }); - describe('when linking to a different origin or path', () => { + describes.fakeWin('non-embed', { + win: { + location: 'https://www.google.com/some-path?hello=world#link', + }, + amp: true, + }, env => { + let win, doc; + let handler; + let handleNavSpy; + let handleCustomProtocolSpy; + let winOpenStub; + let scrollIntoViewStub; + let replaceStateForTargetStub; + let replaceStateForTargetPromise; + let anchor; + let elementWithId; + let anchorWithName; beforeEach(() => { - win.location.href = 'https://www.google.com/some-path?hello=world#link'; - }); - - afterEach(() => { - sandbox.restore(); + win = env.win; + doc = win.document; + + handler = win.services.clickhandler.obj; + handler.isIframed_ = true; + handleNavSpy = sandbox.spy(handler, 'handleNavClick_'); + handleCustomProtocolSpy = sandbox.spy(handler, + 'handleCustomProtocolClick_'); + win.open = function() {}; + winOpenStub = sandbox.stub(win, 'open', () => { + return {}; + }); + const viewport = win.services.viewport.obj; + scrollIntoViewStub = sandbox.stub(viewport, 'scrollIntoView'); + const history = win.services.history.obj; + replaceStateForTargetPromise = Promise.resolve(); + replaceStateForTargetStub = sandbox.stub(history, + 'replaceStateForTarget', () => replaceStateForTargetPromise); + + anchor = doc.createElement('a'); + anchor.href = 'https://www.google.com/other'; + doc.body.appendChild(anchor); + event.target = anchor; + + elementWithId = doc.createElement('div'); + elementWithId.id = 'test'; + doc.body.appendChild(elementWithId); + + anchorWithName = doc.createElement('a'); + anchorWithName.setAttribute('name', 'test2'); + doc.body.appendChild(anchorWithName); }); - it('should not do anything on path change', () => { - tgt.href = 'https://www.google.com/some-other-path'; - onDocumentElementClick_(evt, ampdoc, viewport, history); + describe('discovery', () => { + it('should select a direct link', () => { + handler.handle_(event); + expect(handleNavSpy).to.be.calledOnce; + expect(handleNavSpy).to.be.calledWith(event, anchor); + expect(handleCustomProtocolSpy).to.be.calledOnce; + expect(handleCustomProtocolSpy).to.be.calledWith(event, anchor); + }); - expect(getElementByIdSpy).to.have.not.been.called; - expect(querySelectorSpy).to.have.not.been.called; - expect(preventDefaultSpy).to.have.not.been.called; - expect(scrollIntoViewSpy).to.have.not.been.called; - }); + it('should NOT handle custom protocol when not iframed', () => { + handler.isIframed_ = false; + handler.handle_(event); + expect(handleCustomProtocolSpy).to.not.be.called; + }); - it('should not do anything on origin change', () => { - tgt.href = 'https://maps.google.com/some-path#link'; - onDocumentElementClick_(evt, ampdoc, viewport, history); + it('should discover a link from a nested target', () => { + const target = doc.createElement('span'); + anchor.appendChild(target); + event.target = target; + handler.handle_(event); + expect(handleNavSpy).to.be.calledOnce; + expect(handleNavSpy).to.be.calledWith(event, anchor); + expect(handleCustomProtocolSpy).to.be.calledOnce; + expect(handleCustomProtocolSpy).to.be.calledWith(event, anchor); + }); - expect(getElementByIdSpy).to.have.not.been.called; - expect(querySelectorSpy).to.have.not.been.called; - expect(preventDefaultSpy).to.have.not.been.called; - expect(scrollIntoViewSpy).to.have.not.been.called; - }); + it('should NOT proceed if event is cancelled', () => { + event.preventDefault(); + handler.handle_(event); + expect(handleNavSpy).to.not.be.called; + expect(handleCustomProtocolSpy).to.not.be.called; + }); - it('should not do anything when there is no hash', () => { - tgt.href = 'https://www.google.com/some-path'; - onDocumentElementClick_(evt, ampdoc, viewport, history); + it('should ignore a target without link', () => { + const target = doc.createElement('span'); + doc.body.appendChild(target); + event.target = target; + handler.handle_(event); + expect(handleNavSpy).to.not.be.called; + expect(handleCustomProtocolSpy).to.not.be.called; + }); - expect(getElementByIdSpy).to.have.not.been.called; - expect(querySelectorSpy).to.have.not.been.called; - expect(preventDefaultSpy).to.have.not.been.called; - expect(scrollIntoViewSpy).to.have.not.been.called; + it('should ignore a link without href', () => { + anchor.removeAttribute('href'); + handler.handle_(event); + expect(handleNavSpy).to.not.be.called; + expect(handleCustomProtocolSpy).to.not.be.called; + }); }); - it('should not do anything on a query change', () => { - tgt.href = 'https://www.google.com/some-path?hello=foo#link'; - onDocumentElementClick_(evt, ampdoc, viewport, history); + describe('link expansion', () => { + it('should expand a link', () => { + anchor.href = 'https://www.google.com/link?out=QUERY_PARAM(hello)'; + anchor.setAttribute('data-amp-replace', 'QUERY_PARAM'); + handler.handle_(event); + expect(anchor.href).to.equal('https://www.google.com/link?out=world'); + expect(handleNavSpy).to.be.calledOnce; + }); - expect(getElementByIdSpy).to.have.not.been.called; - expect(querySelectorSpy).to.have.not.been.called; - expect(preventDefaultSpy).to.have.not.been.called; - expect(scrollIntoViewSpy).to.have.not.been.called; + it('should only expand with whitelist', () => { + anchor.href = 'https://www.google.com/link?out=QUERY_PARAM(hello)'; + handler.handle_(event); + expect(anchor.href).to.equal( + 'https://www.google.com/link?out=QUERY_PARAM(hello)'); + expect(handleNavSpy).to.be.calledOnce; + }); }); - }); - describe('when linking to identifier', () => { + describe('when linking to ftp: protocol', () => { + beforeEach(() => { + anchor.href = 'ftp://example.com/a'; + }); - beforeEach(() => { - win.location.href = 'https://www.google.com/some-path?hello=world'; - tgt.href = 'https://www.google.com/some-path?hello=world#test'; - }); + it('should always open in _blank when embedded', () => { + handler.handle_(event); + expect(winOpenStub).to.be.calledOnce; + expect(winOpenStub).to.be.calledWith('ftp://example.com/a', '_blank'); + expect(event.defaultPrevented).to.be.true; + }); - afterEach(() => { - sandbox.restore(); + it('should not do anything not embedded', () => { + handler.isIframed_ = false; + handler.handle_(event); + expect(winOpenStub).to.not.be.called; + expect(winOpenStub).to.not.be.calledWith('ftp://example.com/a', '_blank'); + expect(event.defaultPrevented).to.be.false; + }); }); - it('should call getElementById on document', () => { - getElementByIdSpy.returns(elem); - expect(getElementByIdSpy).to.have.not.been.called; - onDocumentElementClick_(evt, ampdoc, viewport, history); - expect(getElementByIdSpy).to.be.calledOnce; - expect(querySelectorSpy).to.have.not.been.called; - }); + describe('when linking to custom protocols e.g. whatsapp:', () => { + beforeEach(() => { + handler.isIosSafari_ = true; + anchor.href = 'whatsapp://send?text=hello'; + }); - it('should always call preventDefault', () => { - getElementByIdSpy.returns(null); - querySelectorSpy.returns(null); - expect(preventDefaultSpy).to.have.not.been.called; - onDocumentElementClick_(evt, ampdoc, viewport, history); - expect(preventDefaultSpy).to.be.calledOnce; - }); + it('should open link in _top on Safari iOS when embedded', () => { + handler.handle_(event); + expect(winOpenStub).to.be.calledOnce; + expect(winOpenStub.calledWith( + 'whatsapp://send?text=hello', '_top')).to.be.true; + expect(event.defaultPrevented).to.be.true; + }); - it('should not do anything if no anchor is found', () => { - evt.target = document.createElement('span'); - onDocumentElementClick_(evt, ampdoc, viewport, history); - expect(getElementByIdSpy).to.have.not.been.called; - expect(querySelectorSpy).to.have.not.been.called; - }); + it('should not do anything on when not embedded', () => { + handler.isIframed_ = false; + handler.handle_(event); + expect(winOpenStub).to.not.be.called; + expect(winOpenStub).to.not.be.calledWith( + 'whatsapp://send?text=hello', '_top'); + expect(event.defaultPrevented).to.be.false; + }); - it('should call querySelector on document if element with id is not ' + - 'found', () => { - getElementByIdSpy.returns(null); - expect(getElementByIdSpy).to.have.not.been.called; - onDocumentElementClick_(evt, ampdoc, viewport, history); - expect(getElementByIdSpy).to.be.calledOnce; - expect(querySelectorSpy).to.be.calledOnce; - }); + it('should not do anything for mailto: protocol', () => { + anchor.href = 'mailto:hello@example.com'; + handler.handle_(event); + expect(winOpenStub).to.not.be.called; + expect(event.defaultPrevented).to.be.false; + }); + + it('should not do anything on other non-safari iOS', () => { + handler.isIosSafari_ = false; + handler.handle_(event); + expect(winOpenStub).to.not.be.called; + expect(event.defaultPrevented).to.be.false; + }); - it('should not call scrollIntoView if element with id is not found or ' + - 'anchor with name is not found, but should still update URL', () => { - getElementByIdSpy.returns(null); - querySelectorSpy.returns(null); - expect(getElementByIdSpy).to.have.not.been.called; - - onDocumentElementClick_(evt, ampdoc, viewport, history); - expect(getElementByIdSpy).to.be.calledOnce; - expect(scrollIntoViewSpy).to.have.not.been.called; - expect(replaceStateForTargetSpy).to.be.calledOnce; - expect(replaceStateForTargetSpy.args[0][0]).to.equal('#test'); + it('should not do anything on other platforms', () => { + handler.isIosSafari_ = false; + handler.handle_(event); + expect(winOpenStub).to.not.be.called; + expect(event.defaultPrevented).to.be.false; + }); }); - it('should call scrollIntoView if element with id is found', () => { - getElementByIdSpy.returns(elem); + describe('when linking to a different origin or path', () => { + it('should not do anything on path change', () => { + anchor.href = 'https://www.google.com/some-other-path'; + handler.handle_(event); + expect(event.defaultPrevented).to.be.false; + expect(winOpenStub).to.not.be.called; + expect(scrollIntoViewStub).to.not.be.called; + expect(anchor.getAttribute('target')).to.be.null; + }); - expect(replaceStateForTargetSpy).to.have.not.been.called; - expect(scrollIntoViewSpy).to.have.not.been.called; - onDocumentElementClick_(evt, ampdoc, viewport, history); - expect(replaceStateForTargetSpy).to.be.calledOnce; - expect(replaceStateForTargetSpy.args[0][0]).to.equal('#test'); - replaceStateForTargetResolver(); - return replaceStateForTargetPromise.then(() => { - expect(scrollIntoViewSpy).to.have.callCount(2); - expect(timerFuncSpy).to.be.calledOnce; + it('should not do anything on origin change', () => { + anchor.href = 'https://maps.google.com/some-path#link'; + handler.handle_(event); + expect(event.defaultPrevented).to.be.false; + expect(winOpenStub).to.not.be.called; + expect(scrollIntoViewStub).to.not.be.called; + expect(anchor.getAttribute('target')).to.be.null; }); - }); - it('should call scrollIntoView if element with name is found', () => { - getElementByIdSpy.returns(null); - querySelectorSpy.returns(elem); + it('should not do anything when there is no hash', () => { + anchor.href = 'https://www.google.com/some-path'; + handler.handle_(event); + expect(event.defaultPrevented).to.be.false; + expect(winOpenStub).to.not.be.called; + expect(scrollIntoViewStub).to.not.be.called; + expect(anchor.getAttribute('target')).to.be.null; + }); - expect(replaceStateForTargetSpy).to.have.not.been.called; - expect(scrollIntoViewSpy).to.have.not.been.called; - onDocumentElementClick_(evt, ampdoc, viewport, history); - replaceStateForTargetResolver(); - return replaceStateForTargetPromise.then(() => { - expect(scrollIntoViewSpy).to.have.callCount(2); - expect(timerFuncSpy).to.be.calledOnce; - expect(replaceStateForTargetSpy).to.be.calledOnce; - expect(replaceStateForTargetSpy.args[0][0]).to.equal('#test'); + it('should not do anything on a query change', () => { + anchor.href = 'https://www.google.com/some-path?hello=foo#link'; + handler.handle_(event); + expect(event.defaultPrevented).to.be.false; + expect(winOpenStub).to.not.be.called; + expect(scrollIntoViewStub).to.not.be.called; + expect(anchor.getAttribute('target')).to.be.null; }); }); - it('should use escaped css selectors', () => { - tgt.href = 'https://www.google.com/some-path?hello=world#test%20hello'; - getElementByIdSpy.returns(null); - querySelectorSpy.returns(elem); + describe('when linking to identifier', () => { - onDocumentElementClick_(evt, ampdoc, viewport, history); - expect(querySelectorSpy).to.be.calledWith('a[name="test\\%20hello"]'); - - querySelectorSpy.reset(); - tgt.href = 'https://www.google.com/some-path?hello=world#test"hello'; - onDocumentElementClick_(evt, ampdoc, viewport, history); - expect(querySelectorSpy).to.be.calledWith('a[name="test\\"hello"]'); - }); + beforeEach(() => { + anchor.href = 'https://www.google.com/some-path?hello=world#test'; + }); - it('should call replaceStateForTarget before scrollIntoView', () => { - getElementByIdSpy.returns(null); - querySelectorSpy.returns(elem); - onDocumentElementClick_(evt, ampdoc, viewport, history); - expect(replaceStateForTargetSpy).to.have.been.calledOnce; - expect(scrollIntoViewSpy).to.not.be.called; - replaceStateForTargetResolver(); - return replaceStateForTargetPromise.then(() => { - expect(timerFuncSpy).to.be.calledOnce; - expect(scrollIntoViewSpy).to.be.calledTwice; + it('should find element by id', () => { + handler.handle_(event); + expect(event.defaultPrevented).to.be.true; + expect(replaceStateForTargetStub).to.be.calledOnce; + expect(replaceStateForTargetStub).to.be.calledWith('#test'); + expect(scrollIntoViewStub).to.not.be.called; + return replaceStateForTargetPromise.then(() => { + expect(scrollIntoViewStub).to.be.called; + expect(scrollIntoViewStub).to.be.calledWith(elementWithId); + }); }); - }); - it('should push and pop history state', () => { - sandbox.stub(history, 'push'); + it('should always call preventDefault', () => { + elementWithId.id = 'something-else'; + handler.handle_(event); + expect(event.defaultPrevented).to.be.true; + expect(replaceStateForTargetStub).to.be.calledOnce; + expect(replaceStateForTargetStub).to.be.calledWith('#test'); + return replaceStateForTargetPromise.then(() => { + expect(scrollIntoViewStub).to.not.be.called; + }); + }); - // Click -> push. - onDocumentElementClick_(evt, ampdoc, viewport, history); - expect(scrollIntoViewSpy).to.have.not.been.called; - expect(replaceStateForTargetSpy).to.be.calledOnce; - expect(replaceStateForTargetSpy.args[0][0]).to.equal('#test'); - }); + it('should call querySelector on document if element with id is not ' + + 'found', () => { + anchor.href = 'https://www.google.com/some-path?hello=world#test2'; + handler.handle_(event); + expect(replaceStateForTargetStub).to.be.calledOnce; + expect(replaceStateForTargetStub).to.be.calledWith('#test2'); + expect(scrollIntoViewStub).to.not.be.called; + return replaceStateForTargetPromise.then(() => { + expect(scrollIntoViewStub).to.be.called; + expect(scrollIntoViewStub).to.be.calledWith(anchorWithName); + }); + }); - it('should push and pop history state with pre-existing hash', () => { - win.location.href = 'https://www.google.com/some-path?hello=world#first'; - sandbox.stub(history, 'push'); + it('should call scrollIntoView twice if element with id is found', () => { + handler.handle_(event); + expect(replaceStateForTargetStub).to.be.calledOnce; + expect(replaceStateForTargetStub).to.be.calledWith('#test'); + return replaceStateForTargetPromise.then(() => { + expect(scrollIntoViewStub).to.have.callCount(1); + return new Promise(resolve => { + setTimeout(resolve, 2); + }); + }).then(() => { + expect(scrollIntoViewStub).to.have.callCount(2); + }); + }); - // Click -> push. - onDocumentElementClick_(evt, ampdoc, viewport, history, - /* isIosSafari*/ true, /* isIframed */ false); - expect(replaceStateForTargetSpy).to.be.calledOnce; - expect(replaceStateForTargetSpy.args[0][0]).to.equal('#test'); - }); - }); + it('should use escaped css selectors with spaces', () => { + anchor.href = + 'https://www.google.com/some-path?hello=world#test%20hello'; + anchorWithName.setAttribute('name', 'test%20hello'); + handler.handle_(event); + expect(replaceStateForTargetStub).to.be.calledWith('#test%20hello'); + return replaceStateForTargetPromise.then(() => { + expect(scrollIntoViewStub).to.be.calledWith(anchorWithName); + }); + }); - describe('when linking to ftp: protocol', () => { - beforeEach(() => { - win.open = sandbox.spy(); - win.parent = {}; - win.top = { - location: { - href: 'https://google.com', - }, - }; - tgt.href = 'ftp://example.com/a'; - }); + it('should use escaped css selectors with quotes', () => { + anchor.href = + 'https://www.google.com/some-path?hello=world#test"hello'; + anchorWithName.setAttribute('name', 'test"hello'); + handler.handle_(event); + expect(replaceStateForTargetStub).to.be.calledWith('#test"hello'); + return replaceStateForTargetPromise.then(() => { + expect(scrollIntoViewStub).to.be.calledWith(anchorWithName); + }); + }); - it('should always open in _blank when embedded', () => { - onDocumentElementClick_(evt, ampdoc, viewport, history, - /* isIosSafari */ false, /* isIframed */ true); - expect(win.open).to.be.called; - expect(win.open).to.be.calledWith('ftp://example.com/a', '_blank'); - expect(preventDefaultSpy).to.be.calledOnce; - }); + it('should push and pop history state with pre-existing hash', () => { + win.location.href = + 'https://www.google.com/some-path?hello=world#first'; + handler.isIosSafari_ = true; + handler.isIframed_ = false; + handler.handle_(event); + expect(replaceStateForTargetStub).to.be.calledOnce; + expect(replaceStateForTargetStub).to.be.calledWith('#test'); + }); - it('should not do anything not embedded', () => { - onDocumentElementClick_(evt, ampdoc, viewport, history, - /* isIosSafari */ false, /* isIframed */ false); - expect(win.open).to.not.be.called; - expect(win.open).to.not.be.calledWith('ftp://example.com/a', '_blank'); - expect(preventDefaultSpy).to.have.not.been.called; + it('should only scroll same hash, no history changes', () => { + win.location.href = + 'https://www.google.com/some-path?hello=world#test'; + handler.handle_(event); + expect(replaceStateForTargetStub).to.not.be.called; + expect(scrollIntoViewStub).to.be.calledOnce; + expect(scrollIntoViewStub).to.be.calledWith(elementWithId); + }); }); }); - describe('when linking to custom protocols e.g. whatsapp:', () => { + describes.realWin('fie embed', { + amp: { + ampdoc: 'fie', + }, + }, env => { + let win, doc; + let parentWin; + let ampdoc; + let embed; + let handler; + let winOpenStub; + let scrollIntoViewStub; + let replaceStateForTargetStub; + let replaceStateForTargetPromise; + let anchor; + let elementWithId; + let anchorWithName; + beforeEach(() => { - win.open = sandbox.spy(); - win.parent = {}; - win.top = { - location: { - href: 'https://google.com', - }, - }; - tgt.href = 'whatsapp://send?text=hello'; + win = env.win; + doc = win.document; + ampdoc = env.ampdoc; + parentWin = env.parentWin; + embed = env.embed; + + handler = win.services.clickhandler.obj; + winOpenStub = sandbox.stub(win, 'open', () => { + return {}; + }); + const viewport = parentWin.services.viewport.obj; + scrollIntoViewStub = sandbox.stub(viewport, 'scrollIntoView'); + const history = parentWin.services.history.obj; + replaceStateForTargetPromise = Promise.resolve(); + replaceStateForTargetStub = sandbox.stub(history, + 'replaceStateForTarget', () => replaceStateForTargetPromise); + + anchor = doc.createElement('a'); + anchor.href = 'http://ads.localhost:8000/example'; + doc.body.appendChild(anchor); + event.target = anchor; + + elementWithId = doc.createElement('div'); + elementWithId.id = 'test'; + doc.body.appendChild(elementWithId); + + anchorWithName = doc.createElement('a'); + anchorWithName.setAttribute('name', 'test2'); + doc.body.appendChild(anchorWithName); }); - it('should open link in _top on Safari iOS when embedded', () => { - onDocumentElementClick_(evt, ampdoc, viewport, history, - /* isIosSafari*/ true, /* isIframed */ true); - expect(win.open.called).to.be.true; - expect(win.open.calledWith( - 'whatsapp://send?text=hello', '_top')).to.be.true; - expect(preventDefaultSpy).to.be.calledOnce; + it('should adopt correctly to embed', () => { + expect(handler.ampdoc).to.equal(ampdoc); + expect(handler.rootNode_).to.equal(embed.win.document); + expect(handler.isEmbed_).to.be.true; }); - it('should not do anything on when not embedded', () => { - onDocumentElementClick_(evt, ampdoc, viewport, history, - /* isIosSafari*/ true, /* isIframed */ false); - expect(win.open).to.not.be.called; - expect(win.open).to.not.be.calledWith( - 'whatsapp://send?text=hello', '_top'); - expect(preventDefaultSpy).to.have.not.been.called; - }); + describe('when linking to a different origin or path', () => { + it('should update target to _blank', () => { + anchor.href = 'https://www.google.com/some-other-path'; + handler.handle_(event); + expect(event.defaultPrevented).to.be.false; + expect(winOpenStub).to.not.be.called; + expect(scrollIntoViewStub).to.not.be.called; + expect(anchor.getAttribute('target')).to.equal('_blank'); + }); - it('should not do anything for mailto: protocol', () => { - tgt.href = 'mailto:hello@example.com'; - onDocumentElementClick_(evt, ampdoc, viewport, history, - /* isIosSafari*/ true, /* isIframed */ true); - expect(win.open.called).to.be.false; - expect(preventDefaultSpy).to.have.not.been.called; - }); + it('should keep the target when specified', () => { + anchor.href = 'https://www.google.com/some-other-path'; + anchor.setAttribute('target', '_top'); + handler.handle_(event); + expect(event.defaultPrevented).to.be.false; + expect(winOpenStub).to.not.be.called; + expect(scrollIntoViewStub).to.not.be.called; + expect(anchor.getAttribute('target')).to.equal('_top'); + }); - it('should not do anything on other non-safari iOS', () => { - onDocumentElementClick_(evt, ampdoc, viewport, history, - /* isIosSafari*/ false, /* isIframed */ true); - expect(win.open.called).to.be.false; - expect(preventDefaultSpy).to.have.not.been.called; + it('should reset the target when illegal specified', () => { + anchor.href = 'https://www.google.com/some-other-path'; + anchor.setAttribute('target', '_self'); + handler.handle_(event); + expect(event.defaultPrevented).to.be.false; + expect(winOpenStub).to.not.be.called; + expect(scrollIntoViewStub).to.not.be.called; + expect(anchor.getAttribute('target')).to.equal('_blank'); + }); }); - it('should not do anything on other platforms', () => { - onDocumentElementClick_(evt, ampdoc, viewport, history, - /* isIosSafari*/ false, /* isIframed */ true); - expect(win.top.location.href).to.equal('https://google.com'); - expect(preventDefaultSpy).to.have.not.been.called; - }); - }); + describe('when linking to identifier', () => { - describe('link expansion', () => { - it('should expand a link', () => { - querySelectorSpy.returns({ - href: 'https://www.google.com', + beforeEach(() => { + anchor.href = 'http://ads.localhost:8000/example#test'; }); - tgt.href = 'https://www.google.com/link?out=QUERY_PARAM(hello)'; - tgt.setAttribute('data-amp-replace', 'QUERY_PARAM'); - onDocumentElementClick_(evt, ampdoc, viewport, history); - expect(tgt.href).to.equal( - 'https://www.google.com/link?out=world'); - }); - it('should only expand with whitelist', () => { - querySelectorSpy.returns({ - href: 'https://www.google.com', + it('should NOT do anything, but cancel the event', () => { + handler.handle_(event); + expect(event.defaultPrevented).to.be.true; + expect(replaceStateForTargetStub).to.not.be.called; + expect(scrollIntoViewStub).to.not.be.called; }); - tgt.href = 'https://www.google.com/link?out=QUERY_PARAM(hello)'; - onDocumentElementClick_(evt, ampdoc, viewport, history); - expect(tgt.href).to.equal( - 'https://www.google.com/link?out=QUERY_PARAM(hello)'); }); }); });