diff options
Diffstat (limited to 'testing/web-platform/tests/html/browsers/browsing-the-web/remote-context-helper/resources/remote-context-helper.js')
-rw-r--r-- | testing/web-platform/tests/html/browsers/browsing-the-web/remote-context-helper/resources/remote-context-helper.js | 592 |
1 files changed, 592 insertions, 0 deletions
diff --git a/testing/web-platform/tests/html/browsers/browsing-the-web/remote-context-helper/resources/remote-context-helper.js b/testing/web-platform/tests/html/browsers/browsing-the-web/remote-context-helper/resources/remote-context-helper.js new file mode 100644 index 0000000000..aa24b36e8b --- /dev/null +++ b/testing/web-platform/tests/html/browsers/browsing-the-web/remote-context-helper/resources/remote-context-helper.js @@ -0,0 +1,592 @@ +'use strict'; + +// Requires: +// - /common/dispatcher/dispatcher.js +// - /common/utils.js +// - /common/get-host-info.sub.js if automagic conversion of origin names to +// URLs is used. + +/** + * This provides a more friendly interface to remote contexts in dispatches.js. + * The goal is to make it easy to write multi-window/-frame/-worker tests where + * the logic is entirely in 1 test file and there is no need to check in any + * other file (although it is often helpful to check in files of JS helper + * functions that are shared across remote context). + * + * So for example, to test that history traversal works, we create a new window, + * navigate it to a new document, go back and then go forward. + * + * @example + * promise_test(async t => { + * const rcHelper = new RemoteContextHelper(); + * const rc1 = await rcHelper.addWindow(); + * const rc2 = await rc1.navigateToNew(); + * assert_equals(await rc2.executeScript(() => 'here'), 'here', 'rc2 is live'); + * rc2.historyBack(); + * assert_equals(await rc1.executeScript(() => 'here'), 'here', 'rc1 is live'); + * rc1.historyForward(); + * assert_equals(await rc2.executeScript(() => 'here'), 'here', 'rc2 is live'); + * }); + * + * Note on the correspondence between remote contexts and + * `RemoteContextWrapper`s. A remote context is entirely determined by its URL. + * So navigating away from one and then back again will result in a remote + * context that can be controlled by the same `RemoteContextWrapper` instance + * before and after navigation. Messages sent to a remote context while it is + * destroyed or in BFCache will be queued and processed if that that URL is + * navigated back to. + * + * Navigation: + * This framework does not keep track of the history of the frame tree and so it + * is up to the test script to keep track of what remote contexts are currently + * active and to keep references to the corresponding `RemoteContextWrapper`s. + * + * Any action that leads to navigation in the remote context must be executed + * using + * @see RemoteContextWrapper.navigate. + */ + +{ + const RESOURCES_PATH = + '/html/browsers/browsing-the-web/remote-context-helper/resources'; + const WINDOW_EXECUTOR_PATH = `${RESOURCES_PATH}/executor-window.py`; + const WORKER_EXECUTOR_PATH = `${RESOURCES_PATH}/executor-worker.js`; + + /** + * Turns a string into an origin. If `origin` is null this will return the + * current document's origin. If `origin` contains not '/', this will attempt + * to use it as an index in `get_host_info()`. Otherwise returns the input + * origin. + * @private + * @param {string|null} origin The input origin. + * @return {string|null} The output origin. + * @throws {RangeError} is `origin` cannot be found in + * `get_host_info()`. + */ + function finalizeOrigin(origin) { + if (!origin) { + return location.origin; + } + if (!origin.includes('/')) { + const origins = get_host_info(); + if (origin in origins) { + return origins[origin]; + } else { + throw new RangeError( + `${origin} is not a key in the get_host_info() object`); + } + } + return origin; + } + + /** + * @private + * @param {string} url + * @returns {string} Absolute url using `location` as the base. + */ + function makeAbsolute(url) { + return new URL(url, location).toString(); + } + + /** + * Represents a configuration for a remote context executor. + */ + class RemoteContextConfig { + /** + * @param {Object} [options] + * @param {string} [options.origin] A URL or a key in `get_host_info()`. + * @see finalizeOrigin for how origins are handled. + * @param {string[]} [options.scripts] A list of script URLs. The current + * document will be used as the base for relative URLs. + * @param {[string, string][]} [options.headers] A list of pairs of name + * and value. The executor will be served with these headers set. + * @param {string} [options.startOn] If supplied, the executor will start + * when this event occurs, e.g. "pageshow", + * (@see window.addEventListener). This only makes sense for + * window-based executors, not worker-based. + * @param {string} [options.status] If supplied, the executor will pass + * this value in the "status" parameter to the executor. The default + * executor will default to a status code of 200, if the parameter is + * not supplied. + */ + constructor( + {origin, scripts = [], headers = [], startOn, status} = {}) { + this.origin = origin; + this.scripts = scripts; + this.headers = headers; + this.startOn = startOn; + this.status = status; + } + + /** + * If `config` is not already a `RemoteContextConfig`, one is constructed + * using `config`. + * @private + * @param {object} [config] + * @returns + */ + static ensure(config) { + if (!config) { + return DEFAULT_CONTEXT_CONFIG; + } + return new RemoteContextConfig(config); + } + + /** + * Merges `this` with another `RemoteContextConfig` and to give a new + * `RemoteContextConfig`. `origin` is replaced by the other if present, + * `headers` and `scripts` are concatenated with `this`'s coming first. + * @param {RemoteContextConfig} extraConfig + * @returns {RemoteContextConfig} + */ + merged(extraConfig) { + let origin = this.origin; + if (extraConfig.origin) { + origin = extraConfig.origin; + } + let startOn = this.startOn; + if (extraConfig.startOn) { + startOn = extraConfig.startOn; + } + let status = this.status; + if (extraConfig.status) { + status = extraConfig.status; + } + const headers = this.headers.concat(extraConfig.headers); + const scripts = this.scripts.concat(extraConfig.scripts); + return new RemoteContextConfig({ + origin, + headers, + scripts, + startOn, + status + }); + } + } + + /** + * The default `RemoteContextConfig` to use if none is supplied. It has no + * origin, headers or scripts. + * @constant {RemoteContextConfig} + */ + const DEFAULT_CONTEXT_CONFIG = new RemoteContextConfig(); + + /** + * This class represents a configuration for creating remote contexts. This is + * the entry-point + * for creating remote contexts, providing @see addWindow . + */ + class RemoteContextHelper { + /** + * @param {RemoteContextConfig|object} config The configuration + * for this remote context. + */ + constructor(config) { + this.config = RemoteContextConfig.ensure(config); + } + + /** + * Creates a new remote context and returns a `RemoteContextWrapper` giving + * access to it. + * @private + * @param {Object} options + * @param {(url: string) => Promise<undefined>} [options.executorCreator] A + * function that takes a URL and causes the browser to navigate some + * window to that URL, e.g. via an iframe or a new window. If this is + * not supplied, then the returned RemoteContextWrapper won't actually + * be communicating with something yet, and something will need to + * navigate to it using its `url` property, before communication can be + * established. + * @param {RemoteContextConfig|object} [options.extraConfig] If supplied, + * extra configuration for this remote context to be merged with + * `this`'s existing config. If it's not a `RemoteContextConfig`, it + * will be used to construct a new one. + * @returns {Promise<RemoteContextWrapper>} + */ + async createContext({ + executorCreator, + extraConfig, + isWorker = false, + }) { + const config = + this.config.merged(RemoteContextConfig.ensure(extraConfig)); + + const origin = finalizeOrigin(config.origin); + const url = new URL( + isWorker ? WORKER_EXECUTOR_PATH : WINDOW_EXECUTOR_PATH, origin); + + // UUID is needed for executor. + const uuid = token(); + url.searchParams.append('uuid', uuid); + + if (config.headers) { + addHeaders(url, config.headers); + } + for (const script of config.scripts) { + url.searchParams.append('script', makeAbsolute(script)); + } + + if (config.startOn) { + url.searchParams.append('startOn', config.startOn); + } + + if (config.status) { + url.searchParams.append('status', config.status); + } + + if (executorCreator) { + await executorCreator(url.href); + } + + return new RemoteContextWrapper(new RemoteContext(uuid), this, url.href); + } + + /** + * Creates a window with a remote context. @see createContext for + * @param {RemoteContextConfig|object} [extraConfig] Will be + * merged with `this`'s config. + * @param {Object} [options] + * @param {string} [options.target] Passed to `window.open` as the + * 2nd argument + * @param {string} [options.features] Passed to `window.open` as the + * 3rd argument + * @returns {Promise<RemoteContextWrapper>} + */ + addWindow(extraConfig, options) { + return this.createContext({ + executorCreator: windowExecutorCreator(options), + extraConfig, + }); + } + } + // Export this class. + self.RemoteContextHelper = RemoteContextHelper; + + /** + * Attaches header to the URL. See + * https://web-platform-tests.org/writing-tests/server-pipes.html#headers + * @param {string} url the URL to which headers should be attached. + * @param {[[string, string]]} headers a list of pairs of head-name, + * header-value. + */ + function addHeaders(url, headers) { + function escape(s) { + return s.replace('(', '\\(').replace(')', '\\)'); + } + const formattedHeaders = headers.map((header) => { + return `header(${escape(header[0])}, ${escape(header[1])})`; + }); + url.searchParams.append('pipe', formattedHeaders.join('|')); + } + + function windowExecutorCreator({target = '_blank', features} = {}) { + return url => { + window.open(url, target, features); + }; + } + + function elementExecutorCreator( + remoteContextWrapper, elementName, attributes) { + return url => { + return remoteContextWrapper.executeScript((url, elementName, attributes) => { + const el = document.createElement(elementName); + for (const attribute in attributes) { + el.setAttribute(attribute, attributes[attribute]); + } + el.src = url; + document.body.appendChild(el); + }, [url, elementName, attributes]); + }; + } + + function iframeSrcdocExecutorCreator(remoteContextWrapper, attributes) { + return async (url) => { + // `url` points to the content needed to run an `Executor` in the frame. + // So we download the content and pass it via the `srcdoc` attribute, + // setting the iframe's `src` to `undefined`. + attributes['srcdoc'] = await fetch(url).then(r => r.text()); + elementExecutorCreator( + remoteContextWrapper, 'iframe', attributes)(undefined); + }; + } + + function workerExecutorCreator() { + return url => { + new Worker(url); + }; + } + + function navigateExecutorCreator(remoteContextWrapper) { + return url => { + return remoteContextWrapper.navigate((url) => { + window.location = url; + }, [url]); + }; + } + + /** + * This class represents a remote context running an executor (a + * window/frame/worker that can receive commands). It is the interface for + * scripts to control remote contexts. + * + * Instances are returned when new remote contexts are created (e.g. + * `addFrame` or `navigateToNew`). + */ + class RemoteContextWrapper { + /** + * This should only be constructed by `RemoteContextHelper`. + * @private + */ + constructor(context, helper, url) { + this.context = context; + this.helper = helper; + this.url = url; + } + + /** + * Executes a script in the remote context. + * @param {function} fn The script to execute. + * @param {any[]} args An array of arguments to pass to the script. + * @returns {Promise<any>} The return value of the script (after + * being serialized and deserialized). + */ + async executeScript(fn, args) { + return this.context.execute_script(fn, args); + } + + /** + * Adds a string of HTML to the executor's document. + * @param {string} html + * @returns {Promise<undefined>} + */ + async addHTML(html) { + return this.executeScript((htmlSource) => { + document.body.insertAdjacentHTML('beforebegin', htmlSource); + }, [html]); + } + + /** + * Adds scripts to the executor's document. + * @param {string[]} urls A list of URLs. URLs are relative to the current + * document. + * @returns {Promise<undefined>} + */ + async addScripts(urls) { + if (!urls) { + return []; + } + return this.executeScript(urls => { + return addScripts(urls); + }, [urls.map(makeAbsolute)]); + } + + /** + * Adds an iframe with `src` attribute to the current document. + * @param {RemoteContextConfig} [extraConfig] + * @param {[string, string][]} [attributes] A list of pairs of strings + * of attribute name and value these will be set on the iframe element + * when added to the document. + * @returns {Promise<RemoteContextWrapper>} The remote context. + */ + addIframe(extraConfig, attributes = {}) { + return this.helper.createContext({ + executorCreator: elementExecutorCreator(this, 'iframe', attributes), + extraConfig, + }); + } + + /** + * Adds an iframe with `srcdoc` attribute to the current document + * @param {RemoteContextConfig} [extraConfig] + * @param {[string, string][]} [attributes] A list of pairs of strings + * of attribute name and value these will be set on the iframe element + * when added to the document. + * @returns {Promise<RemoteContextWrapper>} The remote context. + */ + addIframeSrcdoc(extraConfig, attributes = {}) { + return this.helper.createContext({ + executorCreator: iframeSrcdocExecutorCreator(this, attributes), + extraConfig, + }); + } + + /** + * Adds a dedicated worker to the current document. + * @param {RemoteContextConfig} [extraConfig] + * @returns {Promise<RemoteContextWrapper>} The remote context. + */ + addWorker(extraConfig) { + return this.helper.createContext({ + executorCreator: workerExecutorCreator(), + extraConfig, + isWorker: true, + }); + } + + /** + * Gets a `Headers` object containing the request headers that were used + * when the browser requested this document. + * + * Currently, this only works for `RemoteContextHelper`s representing + * windows, not workers. + * @returns {Promise<Headers>} + */ + async getRequestHeaders() { + // This only works in window environments for now. We could make it work + // for workers too; if you have a need, just share or duplicate the code + // that's in executor-window.py. Anyway, we explicitly use `window` in + // the script so that we get a clear error if you try using it on a + // worker. + + // We need to serialize and deserialize the `Headers` object manually. + const asNestedArrays = await this.executeScript(() => [...window.__requestHeaders]); + return new Headers(asNestedArrays); + } + + /** + * Executes a script in the remote context that will perform a navigation. + * To do this safely, we must suspend the executor and wait for that to + * complete before executing. This ensures that all outstanding requests are + * completed and no more can start. It also ensures that the executor will + * restart if the page goes into BFCache or it was a same-document + * navigation. It does not return a value. + * + * NOTE: We cannot monitor whether and what navigations are happening. The + * logic has been made as robust as possible but is not fool-proof. + * + * Foolproof rule: + * - The script must perform exactly one navigation. + * - If that navigation is a same-document history traversal, you must + * `await` the result of `waitUntilLocationIs`. (Same-document non-traversal + * navigations do not need this extra step.) + * + * More complex rules: + * - The script must perform a navigation. If it performs no navigation, + * the remote context will be left in the suspended state. + * - If the script performs a direct same-document navigation, it is not + * necessary to use this function but it will work as long as it is the only + * navigation performed. + * - If the script performs a same-document history navigation, you must + * `await` the result of `waitUntilLocationIs`. + * + * @param {function} fn The script to execute. + * @param {any[]} args An array of arguments to pass to the script. + * @returns {Promise<undefined>} + */ + navigate(fn, args) { + return this.executeScript((fnText, args) => { + executeScriptToNavigate(fnText, args); + }, [fn.toString(), args]); + } + + /** + * Navigates to the given URL, by executing a script in the remote + * context that will perform navigation with the `location.href` + * setter. + * + * Be aware that performing a cross-document navigation using this + * method will cause this `RemoteContextWrapper` to become dormant, + * since the remote context it points to is no longer active and + * able to receive messages. You also won't be able to reliably + * tell when the navigation finishes; the returned promise will + * fulfill when the script finishes running, not when the navigation + * is done. As such, this is most useful for testing things like + * unload behavior (where it doesn't matter) or prerendering (where + * there is already a `RemoteContextWrapper` for the destination). + * For other cases, using `navigateToNew()` will likely be better. + * + * @param {string|URL} url The URL to navigate to. + * @returns {Promise<undefined>} + */ + navigateTo(url) { + return this.navigate(url => { + location.href = url; + }, [url.toString()]); + } + + /** + * Navigates the context to a new document running an executor. + * @param {RemoteContextConfig} [extraConfig] + * @returns {Promise<RemoteContextWrapper>} The remote context. + */ + async navigateToNew(extraConfig) { + return this.helper.createContext({ + executorCreator: navigateExecutorCreator(this), + extraConfig, + }); + } + + ////////////////////////////////////// + // Navigation Helpers. + // + // It is up to the test script to know which remote context will be + // navigated to and which `RemoteContextWrapper` should be used after + // navigation. + // + // NOTE: For a same-document history navigation, the caller use `await` a + // call to `waitUntilLocationIs` in order to know that the navigation has + // completed. For convenience the method below can return the promise to + // wait on, if passed the expected location. + + async waitUntilLocationIs(expectedLocation) { + return this.executeScript(async (expectedLocation) => { + if (location.href === expectedLocation) { + return; + } + + // Wait until the location updates to the expected one. + await new Promise(resolve => { + const listener = addEventListener('hashchange', (event) => { + if (event.newURL === expectedLocation) { + removeEventListener(listener); + resolve(); + } + }); + }); + }, [expectedLocation]); + } + + /** + * Performs a history traversal. + * @param {integer} n How many steps to traverse. @see history.go + * @param {string} [expectedLocation] If supplied will be passed to @see waitUntilLocationIs. + * @returns {Promise<undefined>} + */ + async historyGo(n, expectedLocation) { + await this.navigate((n) => { + history.go(n); + }, [n]); + if (expectedLocation) { + await this.waitUntilLocationIs(expectedLocation); + } + } + + /** + * Performs a history traversal back. + * @param {string} [expectedLocation] If supplied will be passed to @see waitUntilLocationIs. + * @returns {Promise<undefined>} + */ + async historyBack(expectedLocation) { + await this.navigate(() => { + history.back(); + }); + if (expectedLocation) { + await this.waitUntilLocationIs(expectedLocation); + } + } + + /** + * Performs a history traversal back. + * @param {string} [expectedLocation] If supplied will be passed to @see waitUntilLocationIs. + * @returns {Promise<undefined>} + */ + async historyForward(expectedLocation) { + await this.navigate(() => { + history.forward(); + }); + if (expectedLocation) { + await this.waitUntilLocationIs(expectedLocation); + } + } + } +} |