blob: 46a9d9e0764c06c640adb0f463e8ea432f032e60 [file] [log] [blame]
// Creates a new iframe in `doc`, calls `func` on it and appends it as a child
// of `doc`.
// Returns a promise that resolves to the iframe once loaded (successfully or
// not).
// The iframe is removed from `doc` once test `t` is done running.
//
// NOTE: There exists no interoperable way to check whether an iframe failed to
// load, so this should only be used when the iframe is expected to load. It
// also means we cannot wire the iframe's `error` event to a promise
// rejection. See: https://github.com/whatwg/html/issues/125
function appendIframeWith(t, doc, func) {
return new Promise(resolve => {
const child = doc.createElement("iframe");
t.add_cleanup(() => child.remove());
child.addEventListener("load", () => resolve(child), { once: true });
func(child);
doc.body.appendChild(child);
});
}
// Appends a child iframe to `doc` sourced from `src`.
//
// See `appendIframeWith()` for more details.
function appendIframe(t, doc, src) {
return appendIframeWith(t, doc, child => { child.src = src; });
}
// Registers an event listener that will resolve this promise when this
// window receives a message posted to it.
//
// `options` has the following shape:
//
// {
// source: If specified, this function waits for the first message from the
// given source only, ignoring other messages.
//
// filter: If specified, this function calls `filter` on each incoming
// message, and resolves iff it returns true.
// }
//
function futureMessage(options) {
return new Promise(resolve => {
window.addEventListener("message", (e) => {
if (options?.source && options.source !== e.source) {
return;
}
if (options?.filter && !options.filter(e.data)) {
return;
}
resolve(e.data);
});
});
};
// Like `promise_test()`, but executes tests in parallel like `async_test()`.
//
// Cribbed from COEP tests.
function promise_test_parallel(promise, description) {
async_test(test => {
promise(test)
.then(() => test.done())
.catch(test.step_func(error => { throw error; }));
}, description);
};
async function postMessageAndAwaitReply(target, message) {
const reply = futureMessage({ source: target });
target.postMessage(message, "*");
return await reply;
}
// Maps protocol (without the trailing colon) and address space to port.
const SERVER_PORTS = {
"http": {
"local": {{ports[http][0]}},
"private": {{ports[http-private][0]}},
"public": {{ports[http-public][0]}},
},
"https": {
"local": {{ports[https][0]}},
"other-local": {{ports[https][1]}},
"private": {{ports[https-private][0]}},
"public": {{ports[https-public][0]}},
},
"ws": {
"local": {{ports[ws][0]}},
},
"wss": {
"local": {{ports[wss][0]}},
},
};
// A `Server` is a web server accessible by tests. It has the following shape:
//
// {
// addressSpace: the IP address space of the server ("local", "private" or
// "public"),
// name: a human-readable name for the server,
// port: the port on which the server listens for connections,
// protocol: the protocol (including trailing colon) spoken by the server,
// }
//
// Constants below define the available servers, which can also be accessed
// programmatically with `get()`.
class Server {
// Maps the given `protocol` (without a trailing colon) and `addressSpace` to
// a server. Returns null if no such server exists.
static get(protocol, addressSpace) {
const ports = SERVER_PORTS[protocol];
if (ports === undefined) {
return null;
}
const port = ports[addressSpace];
if (port === undefined) {
return null;
}
return {
addressSpace,
name: `${protocol}-${addressSpace}`,
port,
protocol: protocol + ':',
};
}
static HTTP_LOCAL = Server.get("http", "local");
static HTTP_PRIVATE = Server.get("http", "private");
static HTTP_PUBLIC = Server.get("http", "public");
static HTTPS_LOCAL = Server.get("https", "local");
static OTHER_HTTPS_LOCAL = Server.get("https", "other-local");
static HTTPS_PRIVATE = Server.get("https", "private");
static HTTPS_PUBLIC = Server.get("https", "public");
static WS_LOCAL = Server.get("ws", "local");
static WSS_LOCAL = Server.get("wss", "local");
};
// Resolves a URL relative to the current location, returning an absolute URL.
//
// `url` specifies the relative URL, e.g. "foo.html" or "http://foo.example".
// `options`, if defined, should have the following shape:
//
// {
// // Optional. Overrides the protocol of the returned URL.
// protocol,
//
// // Optional. Overrides the port of the returned URL.
// port,
//
// // Extra headers.
// headers,
//
// // Extra search params.
// searchParams,
// }
//
function resolveUrl(url, options) {
const result = new URL(url, window.location);
if (options === undefined) {
return result;
}
const { port, protocol, headers, searchParams } = options;
if (port !== undefined) {
result.port = port;
}
if (protocol !== undefined) {
result.protocol = protocol;
}
if (headers !== undefined) {
const pipes = [];
for (key in headers) {
pipes.push(`header(${key},${headers[key]})`);
}
result.searchParams.append("pipe", pipes.join("|"));
}
if (searchParams !== undefined) {
for (key in searchParams) {
result.searchParams.append(key, searchParams[key]);
}
}
return result;
}
// Computes options to pass to `resolveUrl()` for a source document's URL.
//
// `server` identifies the server from which to load the document.
// `treatAsPublic`, if set to true, specifies that the source document should
// be artificially placed in the `public` address space using CSP.
function sourceResolveOptions({ server, treatAsPublic }) {
const options = {...server};
if (treatAsPublic) {
options.headers = { "Content-Security-Policy": "treat-as-public-address" };
}
return options;
}
// Computes the URL of a preflight handler configured with the given options.
//
// `server` identifies the server from which to load the resource.
// `behavior` specifies the behavior of the target server. It may contain:
// - `preflight`: The result of calling one of `PreflightBehavior`'s methods.
// - `response`: The result of calling one of `ResponseBehavior`'s methods.
// - `redirect`: A URL to which the target should redirect GET requests.
function preflightUrl({ server, behavior }) {
assert_not_equals(server, undefined, 'server');
const options = {...server};
if (behavior) {
const { preflight, response, redirect } = behavior;
options.searchParams = {
...preflight,
...response,
};
if (redirect !== undefined) {
options.searchParams.redirect = redirect;
}
}
return resolveUrl("resources/preflight.py", options);
}
// Methods generate behavior specifications for how `resources/preflight.py`
// should behave upon receiving a preflight request.
const PreflightBehavior = {
// The preflight response should fail with a non-2xx code.
failure: () => ({}),
// The preflight response should be missing CORS headers.
// `uuid` should be a UUID that uniquely identifies the preflight request.
noCorsHeader: (uuid) => ({
"preflight-uuid": uuid,
}),
// The preflight response should be missing PNA headers.
// `uuid` should be a UUID that uniquely identifies the preflight request.
noPnaHeader: (uuid) => ({
"preflight-uuid": uuid,
"preflight-headers": "cors",
}),
// The preflight response should succeed.
// `uuid` should be a UUID that uniquely identifies the preflight request.
success: (uuid) => ({
"preflight-uuid": uuid,
"preflight-headers": "cors+pna",
}),
optionalSuccess: (uuid) => ({
"preflight-uuid": uuid,
"preflight-headers": "cors+pna",
"is-preflight-optional": true,
}),
// The preflight response should succeed and allow service-worker header.
// `uuid` should be a UUID that uniquely identifies the preflight request.
serviceWorkerSuccess: (uuid) => ({
"preflight-uuid": uuid,
"preflight-headers": "cors+pna+sw",
}),
// The preflight response should succeed only if it is the first preflight.
// `uuid` should be a UUID that uniquely identifies the preflight request.
singlePreflight: (uuid) => ({
"preflight-uuid": uuid,
"preflight-headers": "cors+pna",
"expect-single-preflight": true,
}),
// The preflight response should succeed and allow origins and headers for
// navigations.
navigation: (uuid) => ({
"preflight-uuid": uuid,
"preflight-headers": "navigation",
}),
};
// Methods generate behavior specifications for how `resources/preflight.py`
// should behave upon receiving a regular (non-preflight) request.
const ResponseBehavior = {
// The response should succeed without CORS headers.
default: () => ({}),
// The response should succeed with CORS headers.
allowCrossOrigin: () => ({ "final-headers": "cors" }),
};
const FetchTestResult = {
SUCCESS: {
ok: true,
body: "success",
},
OPAQUE: {
ok: false,
type: "opaque",
body: "",
},
FAILURE: {
error: "TypeError: Failed to fetch",
},
};
// Runs a fetch test. Tries to fetch a given subresource from a given document.
//
// Main argument shape:
//
// {
// // Optional. Passed to `sourceResolveOptions()`.
// source,
//
// // Optional. Passed to `preflightUrl()`.
// target,
//
// // Optional. Passed to `fetch()`.
// fetchOptions,
//
// // Required. One of the values in `FetchTestResult`.
// expected,
// }
//
async function fetchTest(t, { source, target, fetchOptions, expected }) {
const sourceUrl =
resolveUrl("resources/fetcher.html", sourceResolveOptions(source));
const targetUrl = preflightUrl(target);
const iframe = await appendIframe(t, document, sourceUrl);
const reply = futureMessage({ source: iframe.contentWindow });
const message = {
url: targetUrl.href,
options: fetchOptions,
};
iframe.contentWindow.postMessage(message, "*");
const { error, ok, type, body } = await reply;
assert_equals(error, expected.error, "error");
assert_equals(ok, expected.ok, "response ok");
assert_equals(body, expected.body, "response body");
if (expected.type !== undefined) {
assert_equals(type, expected.type, "response type");
}
}
// Similar to `fetchTest`, but replaced iframes with fenced frames.
async function fencedFrameFetchTest(t, { source, target, fetchOptions, expected }) {
const fetcher_url =
resolveUrl("resources/fenced-frame-fetcher.https.html", sourceResolveOptions(source));
const target_url = preflightUrl(target);
target_url.searchParams.set("is-loaded-in-fenced-frame", true);
fetcher_url.searchParams.set("mode", fetchOptions.mode);
fetcher_url.searchParams.set("method", fetchOptions.method);
fetcher_url.searchParams.set("url", target_url);
const error_token = token();
const ok_token = token();
const body_token = token();
const type_token = token();
const source_url = generateURL(fetcher_url, [error_token, ok_token, body_token, type_token]);
const urn = await generateURNFromFledge(source_url, []);
attachFencedFrame(urn);
const error = await nextValueFromServer(error_token);
const ok = await nextValueFromServer(ok_token);
const body = await nextValueFromServer(body_token);
const type = await nextValueFromServer(type_token);
assert_equals(error, expected.error || "" , "error");
assert_equals(body, expected.body || "", "response body");
assert_equals(ok, expected.ok !== undefined ? expected.ok.toString() : "", "response ok");
if (expected.type !== undefined) {
assert_equals(type, expected.type, "response type");
}
}
const XhrTestResult = {
SUCCESS: {
loaded: true,
status: 200,
body: "success",
},
FAILURE: {
loaded: false,
status: 0,
},
};
// Runs an XHR test. Tries to fetch a given subresource from a given document.
//
// Main argument shape:
//
// {
// // Optional. Passed to `sourceResolveOptions()`.
// source,
//
// // Optional. Passed to `preflightUrl()`.
// target,
//
// // Optional. Method to use when sending the request. Defaults to "GET".
// method,
//
// // Required. One of the values in `XhrTestResult`.
// expected,
// }
//
async function xhrTest(t, { source, target, method, expected }) {
const sourceUrl =
resolveUrl("resources/xhr-sender.html", sourceResolveOptions(source));
const targetUrl = preflightUrl(target);
const iframe = await appendIframe(t, document, sourceUrl);
const reply = futureMessage();
const message = {
url: targetUrl.href,
method: method,
};
iframe.contentWindow.postMessage(message, "*");
const { loaded, status, body } = await reply;
assert_equals(loaded, expected.loaded, "response loaded");
assert_equals(status, expected.status, "response status");
assert_equals(body, expected.body, "response body");
}
const FrameTestResult = {
SUCCESS: "loaded",
FAILURE: "timeout",
};
async function iframeTest(t, { source, target, expected }) {
// Allows running tests in parallel.
const uuid = token();
const targetUrl = preflightUrl(target);
targetUrl.searchParams.set("file", "iframed.html");
targetUrl.searchParams.set("iframe-uuid", uuid);
targetUrl.searchParams.set(
"file-if-no-preflight-received",
"iframed-no-preflight-received.html",
);
const sourceUrl =
resolveUrl("resources/iframer.html", sourceResolveOptions(source));
sourceUrl.searchParams.set("url", targetUrl);
const messagePromise = futureMessage({
filter: (data) => data.uuid === uuid,
});
const iframe = await appendIframe(t, document, sourceUrl);
// The grandchild frame posts a message iff it loads successfully.
// There exists no interoperable way to check whether an iframe failed to
// load, so we use a timeout.
// See: https://github.com/whatwg/html/issues/125
const result = await Promise.race([
messagePromise.then((data) => data.message),
new Promise((resolve) => {
t.step_timeout(() => resolve("timeout"), 2000 /* ms */);
}),
]);
assert_equals(result, expected);
}
const NavigationTestResult = {
SUCCESS: "success",
FAILURE: "timeout",
};
async function windowOpenTest(t, { source, target, expected }) {
const targetUrl = preflightUrl(target);
targetUrl.searchParams.set("file", "openee.html");
targetUrl.searchParams.set(
"file-if-no-preflight-received",
"no-preflight-received.html",
);
const sourceUrl =
resolveUrl("resources/opener.html", sourceResolveOptions(source));
sourceUrl.searchParams.set("url", targetUrl);
const iframe = await appendIframe(t, document, sourceUrl);
const reply = futureMessage({ source: iframe.contentWindow });
iframe.contentWindow.postMessage({ url: targetUrl.href }, "*");
const result = await Promise.race([
reply,
new Promise((resolve) => {
t.step_timeout(() => resolve("timeout"), 10000 /* ms */);
}),
]);
assert_equals(result, expected);
}
async function windowOpenExistingTest(t, { source, target, expected }) {
const targetUrl = preflightUrl(target);
targetUrl.searchParams.set("file", "openee.html");
targetUrl.searchParams.set(
"file-if-no-preflight-received",
"no-preflight-received.html",
);
const sourceUrl = resolveUrl(
'resources/open-to-existing-window.html', sourceResolveOptions(source));
sourceUrl.searchParams.set("url", targetUrl);
sourceUrl.searchParams.set("token", token());
const iframe = await appendIframe(t, document, sourceUrl);
const reply = futureMessage({ source: iframe.contentWindow });
iframe.contentWindow.postMessage({ url: targetUrl.href }, "*");
const result = await Promise.race([
reply,
new Promise((resolve) => {
t.step_timeout(() => resolve("timeout"), 10000 /* ms */);
}),
]);
assert_equals(result, expected);
}
async function anchorTest(t, { source, target, expected }) {
const targetUrl = preflightUrl(target);
targetUrl.searchParams.set("file", "openee.html");
targetUrl.searchParams.set(
"file-if-no-preflight-received",
"no-preflight-received.html",
);
const sourceUrl =
resolveUrl("resources/anchor.html", sourceResolveOptions(source));
sourceUrl.searchParams.set("url", targetUrl);
const iframe = await appendIframe(t, document, sourceUrl);
const reply = futureMessage({ source: iframe.contentWindow });
iframe.contentWindow.postMessage({ url: targetUrl.href }, "*");
const result = await Promise.race([
reply,
new Promise((resolve) => {
t.step_timeout(() => resolve("timeout"), 10000 /* ms */);
}),
]);
assert_equals(result, expected);
}
// Similar to `iframeTest`, but replaced iframes with fenced frames.
async function fencedFrameTest(t, { source, target, expected }) {
// Allows running tests in parallel.
const target_url = preflightUrl(target);
target_url.searchParams.set("file", "fenced-frame-private-network-access-target.https.html");
target_url.searchParams.set("is-loaded-in-fenced-frame", true);
const frame_loaded_key = token();
const child_frame_target = generateURL(target_url, [frame_loaded_key]);
const source_url =
resolveUrl("resources/fenced-frame-private-network-access.https.html", sourceResolveOptions(source));
source_url.searchParams.set("fenced_frame_url", child_frame_target);
const urn = await generateURNFromFledge(source_url, []);
attachFencedFrame(urn);
// The grandchild fenced frame writes a value to the server iff it loads
// successfully.
const result = (expected == FrameTestResult.SUCCESS) ?
await nextValueFromServer(frame_loaded_key) :
await Promise.race([
nextValueFromServer(frame_loaded_key),
new Promise((resolve) => {
t.step_timeout(() => resolve("timeout"), 10000 /* ms */);
}),
]);
assert_equals(result, expected);
}
const iframeGrandparentTest = ({
name,
grandparentServer,
child,
grandchild,
expected,
}) => promise_test_parallel(async (t) => {
// Allows running tests in parallel.
const grandparentUuid = token();
const childUuid = token();
const grandchildUuid = token();
const grandparentUrl =
resolveUrl("resources/executor.html", grandparentServer);
grandparentUrl.searchParams.set("executor-uuid", grandparentUuid);
const childUrl = preflightUrl(child);
childUrl.searchParams.set("file", "executor.html");
childUrl.searchParams.set("executor-uuid", childUuid);
const grandchildUrl = preflightUrl(grandchild);
grandchildUrl.searchParams.set("file", "iframed.html");
grandchildUrl.searchParams.set("iframe-uuid", grandchildUuid);
const iframe = await appendIframe(t, document, grandparentUrl);
const addChild = (url) => new Promise((resolve) => {
const child = document.createElement("iframe");
child.src = url;
child.addEventListener("load", () => resolve(), { once: true });
document.body.appendChild(child);
});
const grandparentCtx = new RemoteContext(grandparentUuid);
await grandparentCtx.execute_script(addChild, [childUrl]);
// Add a blank grandchild frame inside the child.
// Apply a timeout to this step so that failures at this step do not block the
// execution of other tests.
const childCtx = new RemoteContext(childUuid);
await Promise.race([
childCtx.execute_script(addChild, ["about:blank"]),
new Promise((resolve, reject) => t.step_timeout(
() => reject("timeout adding grandchild"),
2000 /* ms */
)),
]);
const messagePromise = futureMessage({
filter: (data) => data.uuid === grandchildUuid,
});
await grandparentCtx.execute_script((url) => {
const child = window.frames[0];
const grandchild = child.frames[0];
grandchild.location = url;
}, [grandchildUrl]);
// The great-grandchild frame posts a message iff it loads successfully.
// There exists no interoperable way to check whether an iframe failed to
// load, so we use a timeout.
// See: https://github.com/whatwg/html/issues/125
const result = await Promise.race([
messagePromise.then((data) => data.message),
new Promise((resolve) => {
t.step_timeout(() => resolve("timeout"), 2000 /* ms */);
}),
]);
assert_equals(result, expected);
}, name);
const WebsocketTestResult = {
SUCCESS: "open",
// The code is a best guess. It is not yet entirely specified, so it may need
// to be changed in the future based on implementation experience.
FAILURE: "close: code 1006",
};
// Runs a websocket test. Attempts to open a websocket from `source` (in an
// iframe) to `target`, then checks that the result is as `expected`.
//
// Argument shape:
//
// {
// // Required. Passed to `sourceResolveOptions()`.
// source,
//
// // Required.
// target: {
// // Required. Target server.
// server,
// }
//
// // Required. Should be one of the values in `WebsocketTestResult`.
// expected,
// }
//
async function websocketTest(t, { source, target, expected }) {
const sourceUrl =
resolveUrl("resources/socket-opener.html", sourceResolveOptions(source));
const targetUrl = resolveUrl("/echo", target.server);
const iframe = await appendIframe(t, document, sourceUrl);
const reply = futureMessage();
iframe.contentWindow.postMessage(targetUrl.href, "*");
assert_equals(await reply, expected);
}
const WorkerScriptTestResult = {
SUCCESS: { loaded: true },
FAILURE: { error: "unknown error" },
};
function workerScriptUrl(target) {
const url = preflightUrl(target);
url.searchParams.append("body", "postMessage({ loaded: true })")
url.searchParams.append("mime-type", "application/javascript")
return url;
}
async function workerScriptTest(t, { source, target, expected }) {
const sourceUrl =
resolveUrl("resources/worker-fetcher.html", sourceResolveOptions(source));
const targetUrl = workerScriptUrl(target);
const iframe = await appendIframe(t, document, sourceUrl);
const reply = futureMessage();
iframe.contentWindow.postMessage({ url: targetUrl.href }, "*");
const { error, loaded } = await reply;
assert_equals(error, expected.error, "worker error");
assert_equals(loaded, expected.loaded, "response loaded");
}
async function nestedWorkerScriptTest(t, { source, target, expected }) {
const targetUrl = workerScriptUrl(target);
const sourceUrl = resolveUrl(
"resources/worker-fetcher.js", sourceResolveOptions(source));
sourceUrl.searchParams.append("url", targetUrl);
// Iframe must be same-origin with the parent worker.
const iframeUrl = new URL("worker-fetcher.html", sourceUrl);
const iframe = await appendIframe(t, document, iframeUrl);
const reply = futureMessage();
iframe.contentWindow.postMessage({ url: sourceUrl.href }, "*");
const { error, loaded } = await reply;
assert_equals(error, expected.error, "worker error");
assert_equals(loaded, expected.loaded, "response loaded");
}
async function sharedWorkerScriptTest(t, { source, target, expected }) {
const sourceUrl = resolveUrl("resources/shared-worker-fetcher.html",
sourceResolveOptions(source));
const targetUrl = preflightUrl(target);
targetUrl.searchParams.append(
"body", "onconnect = (e) => e.ports[0].postMessage({ loaded: true })")
targetUrl.searchParams.append("mime-type", "application/javascript")
const iframe = await appendIframe(t, document, sourceUrl);
const reply = futureMessage();
iframe.contentWindow.postMessage({ url: targetUrl.href }, "*");
const { error, loaded } = await reply;
assert_equals(error, expected.error, "worker error");
assert_equals(loaded, expected.loaded, "response loaded");
}
// Results that may be expected in tests.
const WorkerFetchTestResult = {
SUCCESS: { status: 200, body: "success" },
FAILURE: { error: "TypeError" },
};
async function workerFetchTest(t, { source, target, expected }) {
const targetUrl = preflightUrl(target);
const sourceUrl =
resolveUrl("resources/fetcher.js", sourceResolveOptions(source));
sourceUrl.searchParams.append("url", targetUrl.href);
const fetcherUrl = new URL("worker-fetcher.html", sourceUrl);
const reply = futureMessage();
const iframe = await appendIframe(t, document, fetcherUrl);
iframe.contentWindow.postMessage({ url: sourceUrl.href }, "*");
const { error, status, body } = await reply;
assert_equals(error, expected.error, "fetch error");
assert_equals(status, expected.status, "response status");
assert_equals(body, expected.body, "response body");
}
async function workerBlobFetchTest(t, { source, target, expected }) {
const targetUrl = preflightUrl(target);
const fetcherUrl = resolveUrl(
'resources/worker-blob-fetcher.html', sourceResolveOptions(source));
const reply = futureMessage();
const iframe = await appendIframe(t, document, fetcherUrl);
iframe.contentWindow.postMessage({ url: targetUrl.href }, "*");
const { error, status, body } = await reply;
assert_equals(error, expected.error, "fetch error");
assert_equals(status, expected.status, "response status");
assert_equals(body, expected.body, "response body");
}
async function sharedWorkerFetchTest(t, { source, target, expected }) {
const targetUrl = preflightUrl(target);
const sourceUrl =
resolveUrl("resources/shared-fetcher.js", sourceResolveOptions(source));
sourceUrl.searchParams.append("url", targetUrl.href);
const fetcherUrl = new URL("shared-worker-fetcher.html", sourceUrl);
const reply = futureMessage();
const iframe = await appendIframe(t, document, fetcherUrl);
iframe.contentWindow.postMessage({ url: sourceUrl.href }, "*");
const { error, status, body } = await reply;
assert_equals(error, expected.error, "fetch error");
assert_equals(status, expected.status, "response status");
assert_equals(body, expected.body, "response body");
}
async function sharedWorkerBlobFetchTest(t, { source, target, expected }) {
const targetUrl = preflightUrl(target);
const fetcherUrl = resolveUrl(
'resources/shared-worker-blob-fetcher.html',
sourceResolveOptions(source));
const reply = futureMessage();
const iframe = await appendIframe(t, document, fetcherUrl);
iframe.contentWindow.postMessage({ url: targetUrl.href }, "*");
const { error, status, body } = await reply;
assert_equals(error, expected.error, "fetch error");
assert_equals(status, expected.status, "response status");
assert_equals(body, expected.body, "response body");
}