/** * IMPORTANT: Before using this file, you must also import the following files: * - /common/utils.js */ 'use strict'; const ROOT_NAME = 'fetch/fetch-later'; function parallelPromiseTest(func, description) { async_test((t) => { Promise.resolve(func(t)).then(() => t.done()).catch(t.step_func((e) => { throw e; })); }, description); } /** @enum {string} */ const BeaconDataType = { String: 'String', ArrayBuffer: 'ArrayBuffer', FormData: 'FormData', URLSearchParams: 'URLSearchParams', Blob: 'Blob', File: 'File', }; /** @enum {string} */ const BeaconDataTypeToSkipCharset = { String: '', ArrayBuffer: '', FormData: '\n\r', // CRLF characters will be normalized by FormData URLSearchParams: ';,/?:@&=+$', // reserved URI characters Blob: '', File: '', }; const BEACON_PAYLOAD_KEY = 'payload'; // Creates beacon data of the given `dataType` from `data`. // @param {string} data - A string representation of the beacon data. Note that // it cannot contain UTF-16 surrogates for all `BeaconDataType` except BLOB. // @param {BeaconDataType} dataType - must be one of `BeaconDataType`. // @param {string} contentType - Request Content-Type. function makeBeaconData(data, dataType, contentType) { switch (dataType) { case BeaconDataType.String: return data; case BeaconDataType.ArrayBuffer: return new TextEncoder().encode(data).buffer; case BeaconDataType.FormData: const formData = new FormData(); if (data.length > 0) { formData.append(BEACON_PAYLOAD_KEY, data); } return formData; case BeaconDataType.URLSearchParams: if (data.length > 0) { return new URLSearchParams(`${BEACON_PAYLOAD_KEY}=${data}`); } return new URLSearchParams(); case BeaconDataType.Blob: { const options = {type: contentType || undefined}; return new Blob([data], options); } case BeaconDataType.File: { const options = {type: contentType || 'text/plain'}; return new File([data], 'file.txt', options); } default: throw Error(`Unsupported beacon dataType: ${dataType}`); } } // Create a string of `end`-`begin` characters, with characters starting from // UTF-16 code unit `begin` to `end`-1. function generateSequentialData(begin, end, skip) { const codeUnits = Array(end - begin).fill().map((el, i) => i + begin); if (skip) { return String.fromCharCode( ...codeUnits.filter(c => !skip.includes(String.fromCharCode(c)))); } return String.fromCharCode(...codeUnits); } function generatePayload(size) { if (size == 0) { return ''; } const prefix = String(size) + ':'; if (size < prefix.length) { return Array(size).fill('*').join(''); } if (size == prefix.length) { return prefix; } return prefix + Array(size - prefix.length).fill('*').join(''); } function generateSetBeaconURL(uuid, options) { const host = (options && options.host) || ''; let url = `${host}/${ROOT_NAME}/resources/set_beacon.py?uuid=${uuid}`; if (options) { if (options.expectOrigin !== undefined) { url = `${url}&expectOrigin=${options.expectOrigin}`; } if (options.expectPreflight !== undefined) { url = `${url}&expectPreflight=${options.expectPreflight}`; } if (options.expectCredentials !== undefined) { url = `${url}&expectCredentials=${options.expectCredentials}`; } if (options.useRedirectHandler) { const redirect = `${host}/common/redirect.py` + `?location=${encodeURIComponent(url)}`; url = redirect; } } return url; } async function poll(asyncFunc, expected) { const maxRetries = 30; const waitInterval = 100; // milliseconds. const delay = ms => new Promise(res => setTimeout(res, ms)); let result = {data: []}; for (let i = 0; i < maxRetries; i++) { result = await asyncFunc(); if (!expected(result)) { await delay(waitInterval); continue; } return result; } return result; } // Waits until the `options.count` number of beacon data available from the // server. Defaults to 1. // If `options.data` is set, it will be used to compare with the data from the // response. async function expectBeacon(uuid, options) { const expectedCount = (options && options.count !== undefined) ? options.count : 1; const res = await poll( async () => { const res = await fetch( `/${ROOT_NAME}/resources/get_beacon.py?uuid=${uuid}`, {cache: 'no-store'}); return await res.json(); }, (res) => { if (expectedCount == 0) { // If expecting no beacon, we should try to wait as long as possible. // So always returning false here until `poll()` decides to terminate // itself. return false; } return res.data.length == expectedCount; }); if (!options || !options.data) { assert_equals( res.data.length, expectedCount, 'Number of sent beacons does not match expected count:'); return; } if (expectedCount == 0) { assert_equals( res.data.length, 0, 'Number of sent beacons does not match expected count:'); return; } const decoder = options && options.percentDecoded ? (s) => { // application/x-www-form-urlencoded serializer encodes space as '+' // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent s = s.replace(/\+/g, '%20'); return decodeURIComponent(s); } : (s) => s; assert_equals( res.data.length, options.data.length, `The size of beacon data ${ res.data.length} from server does not match expected value ${ options.data.length}.`); for (let i = 0; i < options.data.length; i++) { assert_equals( decoder(res.data[i]), options.data[i], 'The beacon data does not match expected value.'); } } function generateHTML(script) { return `
`; } // Loads `script` into an iframe and appends it to the current document. // Returns the loaded iframe element. async function loadScriptAsIframe(script) { const iframe = document.createElement('iframe'); iframe.srcdoc = generateHTML(script); const iframeLoaded = new Promise(resolve => iframe.onload = resolve); document.body.appendChild(iframe); await iframeLoaded; return iframe; } /** * A helper to make a fetchLater request and wait for it being received. * * This function can also be used when the caller does not care about where a * fetchLater() makes request to. * * @param {!RequestInit} init The request config to pass into fetchLater() call. */ async function expectFetchLater( init, {targetUrl = undefined, uuid = undefined} = {}) { if ((targetUrl && !uuid) || (!targetUrl && uuid)) { throw new Error('uuid and targetUrl must be provided together.'); } if (uuid && targetUrl && !targetUrl.includes(uuid)) { throw new Error(`Conflicting uuid=${ uuid} is provided: must also be included in the targetUrl ${ targetUrl}`); } if (!uuid) { uuid = token(); } if (!targetUrl) { targetUrl = generateSetBeaconURL(uuid); } fetchLater(targetUrl, init); await expectBeacon(uuid, {count: 1}); } /** * A helper to append `el` into document and wait for it being loaded. * @param {!Element} el */ async function loadElement(el) { const loaded = new Promise(resolve => el.onload = resolve); document.body.appendChild(el); await loaded; } /** * The options to configure a fetchLater() call in an iframe. * @record */ class FetchLaterIframeOptions { constructor() { /** * @type {string=} The url to pass to the fetchLater() call. */ this.targetUrl; /** * @type {string=} The uuid to wait for. Must also be part of `targetUrl`. */ this.uuid; /** * @type {number=} The activateAfter field of DeferredRequestInit to pass * to the fetchLater() call. * https://whatpr.org/fetch/1647.html#dictdef-deferredrequestinit */ this.activateAfter; /** * @type {string=} The method field of DeferredRequestInit to pass to the * fetchLater() call. * https://whatpr.org/fetch/1647.html#dictdef-deferredrequestinit */ this.method; /** * @type {string=} The referrer field of DeferredRequestInit to pass to the * fetchLater() call. * https://whatpr.org/fetch/1647.html#requestinit */ this.referrer; /** * @type {string=} One of the `BeaconDataType` to tell the iframe how to * generate the body for its fetchLater() call. */ this.bodyType; /** * @type {number=} The size to tell the iframe how to generate the body of * its fetchLater() call. */ this.bodySize; /** * @type {bool} Whether to set allow="deferred-fetch" attribute for the * iframe. Combing with a Permissions-Policy header, this will enable * fetchLater() being used in a cross-origin iframe. */ this.allowDeferredFetch; /** * @type {FetchLaterIframeExpectation=} The expectation on the iframe's * behavior. */ this.expect; } } /** * The enum to classify the messages posted from an iframe that has called * fetchLater() API. * @enum {string} */ const FetchLaterIframeMessageType = { // Tells that a fetchLater() call has been executed without any error thrown. DONE: 'fetchLater.done', // Tells that there are some error thrown from a fetchLater() call. ERROR: 'fetchLater.error', }; /** * The enum to indicate what type of iframe behavior the caller is expecting. * @enum {number} */ const FetchLaterExpectationType = { // A fetchLater() call should have been made without any errors. DONE: 0, // A fetchLater() call is made and an JS error is thrown. ERROR_JS: 1, // A fetchLater() call is made and an DOMException is thrown. ERROR_DOM: 2, }; class FetchLaterExpectationError extends Error { constructor(src, actual, expected) { const message = `iframe[src=${src}] threw ${actual}, expected ${expected}`; super(message); } } class FetchLaterIframeExpectation { constructor(expectationType, expectedError) { this.expectationType = expectationType; if (expectationType == FetchLaterExpectationType.DONE && !expectedError) { this.expectedErrorType = undefined; } else if ( expectationType == FetchLaterExpectationType.ERROR_JS && typeof expectedError == 'function') { this.expectedErrorType = expectedError; } else if ( expectationType == FetchLaterExpectationType.ERROR_DOM && typeof expectedError == 'string') { this.expectedDomErrorName = expectedError; } else { throw Error(`Expectation type "${expectationType}" and expected error "${ expectedError}" do not match`); } } /** * Verifies the message from `e` against the configured expectation. * * @param {MessageEvent} e * @param {string} url The source URL of the iframe where `e` is dispatched * from. * @return {bool} * - Returns true if the expected message event is passed into the function * and the expectation is fulfilled. The caller should be able to safely * remove the message event listener afterwards. * - Returns false if the passed in event is not of the expected type. The * caller should continue waiting for another message event and call this * function again. * @throws {Error} Throws an error if the expected message event is passed but * the expectation fails. The caller should remove the message event * listener and perform test failure handling. */ run(e, url) { if (this.expectationType === FetchLaterExpectationType.DONE) { if (e.data.type === FetchLaterIframeMessageType.DONE) { return true; } if (e.data.type === FetchLaterIframeMessageType.ERROR && e.data.error !== undefined) { throw new FetchLaterExpectationError( url, e.data.error.name, 'no error'); } } if (this.expectationType === FetchLaterExpectationType.ERROR_JS) { if (e.data.type === FetchLaterIframeMessageType.DONE) { throw new FetchLaterExpectationError( url, 'nothing', this.expectedErrorType.name); } if (e.data.type === FetchLaterIframeMessageType.ERROR) { if (e.data.error.constructor === this.expectedErrorType && e.data.error.name === this.expectedErrorType.name) { return true; } throw new FetchLaterExpectationError( url, e.data.error, this.expectedErrorType.name); } } if (this.expectationType === FetchLaterExpectationType.ERROR_DOM) { if (e.data.type === FetchLaterIframeMessageType.DONE) { throw new FetchLaterExpectationError( url, 'nothing', this.expectedDomErrorName); } if (e.data.type === FetchLaterIframeMessageType.ERROR) { const actual = e.data.error.name || e.data.error.type; if (e.data.error.constructor.name === 'DOMException' && actual == this.expectedDomErrorName) { return true; } throw new FetchLaterExpectationError( url, actual, this.expectedDomErrorName); } } return false; } } /** * A helper to load an iframe of the specified `origin` that makes a fetchLater * request to `targetUrl`. * * If `targetUrl` is not provided, this function generates a target URL by * itself. * * If `expect` is not provided: * - If `targetUrl` is not provided, this function will wait for the fetchLater * request being received by the test server before returning. * - If `targetUrl` is provided and `uuid` is missing, it will NOT wait for the * request. * - If both `targetUrl` and `uuid` are provided, it will wait for the request. * * Note that the iframe posts various messages back to its parent document. * * @param {!string} origin The origin URL of the iframe to load. * @param {FetchLaterIframeOptions=} nameIgnored * @return {!HTMLIFrameElement} the loaded iframe. */ async function loadFetchLaterIframe(origin, { targetUrl = undefined, uuid = undefined, activateAfter = undefined, referrer = undefined, method = undefined, bodyType = undefined, bodySize = undefined, allowDeferredFetch = false, expect = undefined } = {}) { if (uuid && targetUrl && !targetUrl.includes(uuid)) { throw new Error(`Conflicted uuid=${ uuid} is provided: must also be included in the targetUrl ${ targetUrl}`); } if (!uuid) { uuid = targetUrl ? undefined : token(); } targetUrl = targetUrl || generateSetBeaconURL(uuid); const params = new URLSearchParams(Object.assign( {}, {url: encodeURIComponent(targetUrl)}, activateAfter !== undefined ? {activateAfter} : null, referrer !== undefined ? {referrer} : null, method !== undefined ? {method} : null, bodyType !== undefined ? {bodyType} : null, bodySize !== undefined ? {bodySize} : null, )); const url = `${origin}/fetch/fetch-later/resources/fetch-later.html?${params}`; expect = expect || new FetchLaterIframeExpectation(FetchLaterExpectationType.DONE); const iframe = document.createElement('iframe'); if (allowDeferredFetch) { iframe.allow = 'deferred-fetch'; } iframe.src = url; const messageReceived = new Promise((resolve, reject) => { addEventListener('message', function handler(e) { if (e.source !== iframe.contentWindow) { return; } try { if (expect.run(e, url)) { removeEventListener('message', handler); resolve(e.data.type); } } catch (err) { reject(err); } }); }); await loadElement(iframe); const messageType = await messageReceived; if (messageType === FetchLaterIframeMessageType.DONE && uuid) { await expectBeacon(uuid, {count: 1}); } return iframe; }