/**
* Copyright 2016 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 {parseUrlDeprecated, resolveRelativeUrl} from '../src/url';
/**
* @typedef {{
* hidden: (boolean|undefined),
* historyOff: (boolean|undefined),
* localStorageOff: (boolean|undefined),
* location: (string|undefined),
* navigator: ({userAgent:(string|undefined)}|undefined),
* readyState: (boolean|undefined),
* top: (FakeWindowSpec|undefined),
* }}
*/
export let FakeWindowSpec;
/** @extends {!Window} */
export class FakeWindow {
/**
* @param {!FakeWindowSpec=} opt_spec
*/
constructor(opt_spec) {
const spec = opt_spec || {};
/**
* This value is reflected on this.document.readyState.
* @type {string}
*/
this.readyState = spec.readyState || 'complete';
// Passthrough.
/** @const */
this.Object = window.Object;
/** @const */
this.HTMLElement = window.HTMLElement;
/** @const */
this.HTMLFormElement = window.HTMLFormElement;
/** @const */
this.Element = window.Element;
/** @const */
this.Node = window.Node;
/** @const */
this.EventTarget = window.EventTarget;
/** @const */
this.DOMTokenList = window.DOMTokenList;
/** @const */
this.Math = window.Math;
/** @const */
this.Promise = window.Promise;
/** @const */
this.IntersectionObserver = window.IntersectionObserver;
/** @const */
this./*OK*/ pageYOffset = window./*OK*/ pageYOffset;
/** @const */
this.IntersectionObserver = window.IntersectionObserver;
/** @const */
this.crypto = window.crypto || window.msCrypto;
// Parent Window points to itself if spec.parent was not passed.
/** @const @type {!Window} */
this.parent = spec.parent ? new FakeWindow(spec.parent) : this;
// Top Window points to parent if spec.top was not passed.
/** @const */
this.top = spec.top ? new FakeWindow(spec.top) : this.parent;
// Events.
EventListeners.intercept(this);
// Document.
/** @const {!HTMLDocument} */
this.document = self.document.implementation.createHTMLDocument('');
Object.defineProperty(this.document, 'defaultView', {
get: () => this,
});
Object.defineProperty(this.document, 'readyState', {
get: () => this.readyState,
});
if (!this.document.fonts) {
this.document.fonts = {};
}
Object.defineProperty(this.document.fonts, 'ready', {
get: () => Promise.resolve(),
});
let fontStatus = 'loaded';
Object.defineProperty(this.document.fonts, 'status', {
get: () => fontStatus,
set: (val) => (fontStatus = val),
});
EventListeners.intercept(this.document);
EventListeners.intercept(this.document.documentElement);
EventListeners.intercept(this.document.body);
// Document.hidden and document.visibilityState properties.
/** @private {boolean} */
this.documentHidden_ = spec.hidden !== undefined ? spec.hidden : false;
/** @private {?string} */
this.visibilityState_ = null;
Object.defineProperty(this.document, 'hidden', {
get: () => this.documentHidden_,
set: (value) => {
this.documentHidden_ = value;
this.visibilityState_ = null;
this.document.eventListeners.fire({type: 'visibilitychange'});
},
});
Object.defineProperty(this.document, 'visibilityState', {
get: () => {
if (this.visibilityState_) {
return this.visibilityState_;
}
return this.documentHidden_ ? 'hidden' : 'visible';
},
set: (value) => {
this.visibilityState_ = value;
this.documentHidden_ = value != 'visible';
this.document.eventListeners.fire({type: 'visibilitychange'});
},
});
/** @private {!Array} */
this.cookie_ = [];
this.document.publicSuffixList = [];
this.document.lastSetCookieRaw; // used to verify cookie settings like expiration time etc
Object.defineProperty(this.document, 'cookie', {
get: () => {
const cookie = [];
for (let i = 0; i < this.cookie_.length; i += 2) {
cookie.push(`${this.cookie_[i]}=${this.cookie_[i + 1]}`);
}
return cookie.join(';');
},
set: (value) => {
this.document.lastSetCookieRaw = value;
let cookie = value.match(/^([^=]*)=([^;]*)/);
if (!cookie) {
// couldn't find the match. Treat cookie as single value.
cookie = [value, null];
}
const expiresMatch = value.match(/expires=([^;]*)(;|$)/);
const domainMatch = value.match(/domain=([^;]*)/);
const domain = domainMatch ? domainMatch[1] : '';
if (this.document.publicSuffixList.indexOf(domain) >= 0) {
// Can't set cookie to etld
this.document.lastSetCookieRaw = '';
return;
}
const expires = expiresMatch ? Date.parse(expiresMatch[1]) : Infinity;
let i = 0;
for (; i < this.cookie_.length; i += 2) {
if (this.cookie_[i] == cookie[1]) {
break;
}
}
if (Date.now() >= expires) {
this.cookie_.splice(i, 2);
} else {
this.cookie_.splice(i, 2, cookie[1], cookie[2]);
}
},
});
// Create element to enhance test elements.
const nativeDocumentCreate = this.document.createElement;
/** @this {HTMLDocument} */
this.document.createElement = function () {
const result = nativeDocumentCreate.apply(this, arguments);
EventListeners.intercept(result);
return result;
};
/** @const {!FakeCustomElements} */
this.customElements = new FakeCustomElements(this);
// History.
/** @const {!FakeHistory|undefined} */
this.history = spec.historyOff ? undefined : new FakeHistory(this);
// Location.
/** @private @const {!FakeLocation} */
this.location_ = new FakeLocation(
spec.location || window.location.href,
this,
this.history
);
Object.defineProperty(this, 'location', {
get: () => this.location_,
set: (href) => this.location_.assign(href),
});
// Navigator.
/** @const {!Navigator} */
this.navigator = {
userAgent:
(spec.navigator && spec.navigator.userAgent) ||
window.navigator.userAgent,
};
// Storage.
/** @const {!FakeStorage|undefined} */
this.localStorage = spec.localStorageOff ? undefined : new FakeStorage();
/** @const {!FakeStorage} */
this.sessionStorage = new FakeStorage();
// Timers and animation frames.
/** @const */
this.Date = window.Date;
/** polyfill setTimeout. */
this.setTimeout = function () {
return window.setTimeout.apply(window, arguments);
};
/** polyfill clearTimeout. */
this.clearTimeout = function () {
return window.clearTimeout.apply(window, arguments);
};
/** polyfill setInterval. */
this.setInterval = function () {
return window.setInterval.apply(window, arguments);
};
/** polyfill clearInterval. */
this.clearInterval = function () {
return window.clearInterval.apply(window, arguments);
};
let raf =
window.requestAnimationFrame || window.webkitRequestAnimationFrame;
if (raf) {
raf = raf.bind(window);
} else {
raf = function (fn) {
window.setTimeout(fn, 16);
};
}
/**
* @param {function()} handler
* @const
*/
this.requestAnimationFrame = raf;
// Styles.
this.getComputedStyle = function () {
return window.getComputedStyle.apply(window, arguments);
};
}
/** polyfill addEventListener. */
addEventListener() {}
/** polyfill removeEventListener. */
removeEventListener() {}
}
/**
* @typedef {{
* type: string,
* handler: function(!Event),
* capture: boolean,
* options: ?Object
* }}
*/
export let EventListener;
/**
* Helper for testing event listeners.
*/
class EventListeners {
/**
* @param {!EventTarget} target
* @return {!EventListeners}
*/
static intercept(target) {
target.eventListeners = new EventListeners();
const {
addEventListener: originalAdd,
removeEventListener: originalRemove,
postMessage: originalPostMessage,
} = target;
target.addEventListener = function (type, handler, captureOrOpts) {
target.eventListeners.add(type, handler, captureOrOpts);
if (originalAdd) {
originalAdd.apply(target, arguments);
}
};
target.removeEventListener = function (type, handler, captureOrOpts) {
target.eventListeners.remove(type, handler, captureOrOpts);
if (originalRemove) {
originalRemove.apply(target, arguments);
}
};
target.postMessage = function (type) {
const e = new Event('message');
e.data = type;
target.eventListeners.fire(e);
if (originalPostMessage) {
originalPostMessage.apply(target, arguments);
}
};
}
/** Create empty instance. */
constructor() {
/** @const {!Array} */
this.listeners = [];
}
/**
* @param {string} type
* @param {function(!Event)} handler
* @param {(boolean|!Object)=} captureOrOpts
* @private
*/
listener_(type, handler, captureOrOpts) {
return {
type,
handler,
capture:
typeof captureOrOpts == 'boolean'
? captureOrOpts
: typeof captureOrOpts == 'object'
? captureOrOpts.capture || false
: false,
options: typeof captureOrOpts == 'object' ? captureOrOpts : null,
};
}
/**
* @param {string} type
* @param {function(!Event)} handler
* @param {(boolean|!Object)=} captureOrOpts
*/
add(type, handler, captureOrOpts) {
const listener = this.listener_(type, handler, captureOrOpts);
this.listeners.push(listener);
}
/**
* @param {string} type
* @param {function(!Event)} handler
* @param {(boolean|!Object)=} captureOrOpts
*/
remove(type, handler, captureOrOpts) {
const toRemove = this.listener_(type, handler, captureOrOpts);
for (let i = this.listeners.length - 1; i >= 0; i--) {
const listener = this.listeners[i];
if (
listener.type == toRemove.type &&
listener.handler == toRemove.handler &&
listener.capture == toRemove.capture
) {
this.listeners.splice(i, 1);
}
}
}
/**
* @param {string} type
* @return {!Array}
*/
forType(type) {
return this.listeners.filter((listener) => listener.type == type);
}
/**
* @param {string} type
* @return {number}
*/
count(type) {
return this.forType(type).length;
}
/**
* @param {!Event} event
*/
fire(event) {
this.forType(event.type).forEach((listener) => {
listener.handler.call(null, event);
});
}
}
/**
* @param {!EventTarget} target
*/
export function interceptEventListeners(target) {
EventListeners.intercept(target);
}
/**
* @extends {!Location}
*/
export class FakeLocation {
/**
* @param {string} href
* @param {!FakeWindow} win
* @param {?History} history
*/
constructor(href, win, history) {
/** @const {!Window} */
this.win = win;
/** @private @const {?History} */
this.history_ = history;
/** @const {!Array} */
this.changes = [];
/** @private {!Location} */
this.url_ = parseUrlDeprecated(href, true);
// href
Object.defineProperty(this, 'href', {
get: () => this.url_.href,
set: (href) => this.assign(href),
configurable: true,
});
const properties = [
'protocol',
'host',
'hostname',
'port',
'pathname',
'search',
'hash',
'origin',
];
properties.forEach((property) => {
Object.defineProperty(this, property, {
get: () => this.url_[property],
});
});
if (this.history_) {
this.history_.replaceState(
null,
'',
this.url_.href,
/* fireEvent */ false
);
}
}
/**
* @param {string} href
*/
set_(href) {
const oldHash = this.url_.hash;
this.url_ = parseUrlDeprecated(resolveRelativeUrl(href, this.url_));
if (this.url_.hash != oldHash) {
this.win.eventListeners.fire({type: 'hashchange'});
}
}
/**
* @param {!Object} args
*/
change_(args) {
const change = parseUrlDeprecated(this.url_.href);
({...change, ...args});
this.changes.push(change);
}
/**
* @param {string} href
*/
assign(href) {
this.set_(href);
if (this.history_) {
this.history_.pushState(null, '', this.url_.href, /* fireEvent */ true);
}
this.change_({assign: true});
}
/**
* @param {string} href
*/
replace(href) {
this.set_(href);
if (this.history_) {
this.history_.replaceState(
null,
'',
this.url_.href,
/* fireEvent */ true
);
}
this.change_({replace: true});
}
/**
* @param {boolean} forceReload
*/
reload(forceReload) {
this.change_({reload: true, forceReload});
}
/**
* Resets the URL without firing any events or triggering a history
* entry.
* @param {string} href
*/
resetHref(href) {
this.url_ = parseUrlDeprecated(resolveRelativeUrl(href, this.url_));
}
}
/**
* @extends {!History}
*/
export class FakeHistory {
/** @param {!FakeWindow} win */
constructor(win) {
/** @const */
this.win = win;
/** @const {!Array} */
this.stack = [{url: '', state: null}];
/** @const {number} */
this.index = 0;
Object.defineProperty(this, 'length', {
get: () => this.stack.length,
});
Object.defineProperty(this, 'state', {
get: () => this.stack[this.index].state,
});
}
/** */
back() {
this.go(-1);
}
/** */
forward() {
this.go(1);
}
/**
* @param {number} steps
*/
go(steps) {
const newIndex = this.index + steps;
if (newIndex == this.index) {
return;
}
if (newIndex < 0) {
throw new Error("can't go back");
}
if (newIndex >= this.stack.length) {
throw new Error("can't go forward");
}
this.index = newIndex;
// Make sure to restore the location href before firing popstate to match
// real browsers behaviors.
this.win.location.resetHref(this.stack[this.index].url);
this.win.eventListeners.fire({type: 'popstate'});
}
/**
* @param {?Object} state
* @param {?string} title
* @param {?string} url
* @param {boolean=} opt_fireEvent
*/
pushState(state, title, url, opt_fireEvent) {
this.index++;
if (this.index < this.stack.length) {
// Remove tail.
this.stack.splice(this.index, this.stack.length - this.index);
}
this.stack[this.index] = {
state: state ? freeze(state) : null,
url,
};
if (opt_fireEvent) {
this.win.eventListeners.fire({type: 'popstate'});
}
}
/**
* @param {?Object} state
* @param {?string} title
* @param {?string} url
* @param {boolean=} opt_fireEvent
*/
replaceState(state, title, url, opt_fireEvent) {
const cell = this.stack[this.index];
cell.state = state ? freeze(state) : null;
cell.url = url;
if (opt_fireEvent) {
this.win.eventListeners.fire({type: 'popstate'});
}
}
}
/**
* @extends {Storage}
*/
export class FakeStorage {
constructor() {
/** @const {!Object} */
this.values = {};
// Length.
Object.defineProperty(this, 'length', {
get: () => Object.keys(this.values).length,
});
}
/**
* @param {number} n
* @return {string}
*/
key(n) {
return Object.keys(this.values)[n];
}
/**
* @param {string} name
* @return {?string}
*/
getItem(name) {
if (name in this.values) {
return this.values[name];
}
return null;
}
/**
* @param {string} name
* @param {*} value
* @return {?string}
*/
setItem(name, value) {
this.values[name] = String(value);
}
/**
* @param {string} name
*/
removeItem(name) {
delete this.values[name];
}
/**
*/
clear() {
Object.keys(this.values).forEach((name) => {
delete this.values[name];
});
}
}
/**
* @extends {CustomElementRegistry}
*/
export class FakeCustomElements {
/** @param {!Window} win */
constructor(win) {
/** @const */
this.win = win;
/** @type {number} */
this.count = 0;
/** @const {!Object} */
this.elements = {};
/**
* Custom Elements V0 API.
* @param {string} name
* @param {{prototype: !Prototype}} spec
*/
this.win.document.registerElement = (name, spec) => {
if (this.elements[name]) {
throw new Error('custom element already defined: ' + name);
}
this.elements[name] = spec;
this.count++;
};
}
/**
* Custom Elements V1 API.
* @param {string} name
* @param {!Function} klass
*/
define(name, klass) {
if (this.elements[name]) {
throw new Error('custom element already defined: ' + name);
}
this.elements[name] = klass.prototype;
this.count++;
}
}
export class FakeMutationObserver {
/**
* @param {function(!Array)} callback
*/
constructor(callback) {
this.callback_ = callback;
/** @type {!Array{!Object}} */
this.mutations_ = [];
/** @type {Promise} */
this.scheduled_ = null;
}
observe() {
// I'm not implementing this. Wayyyy to complicated.
}
disconnect() {
// If observe isn't implemnted, this doesn't need to be.
}
takeRecords() {
return this.takeRecords_();
}
takeRecords_() {
return this.mutations_.splice(0, Infinity);
}
/**
* This is a non-standard method that allows you to queue a mutation.
*
* @param {!Object} mutation
* @return {!Promise}
*/
__mutate(mutation) {
this.mutations_.push(mutation);
if (this.scheduled_) {
return this.scheduled_;
}
return (this.scheduled_ = Promise.resolve().then(() => {
this.scheduled_ = null;
this.callback_(this.takeRecords_());
}));
}
}
/**
* @param {!Object} obj
* @return {!Object}
*/
function freeze(obj) {
if (!Object.freeze) {
return obj;
}
return Object.freeze(obj);
}