1
0
Fork 0
firefox/testing/web-platform/tests/fetch/fetch-later/resources/fetch-later-helper.js
Daniel Baumann 5e9a113729
Adding upstream version 140.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
2025-06-25 09:37:52 +02:00

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;
}