514 lines
16 KiB
JavaScript
514 lines
16 KiB
JavaScript
/**
|
|
* 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 `<!DOCTYPE html><body><script>${script}</script></body>`;
|
|
}
|
|
|
|
// 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;
|
|
}
|