606 lines
20 KiB
JavaScript
606 lines
20 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { once } from "node:events";
|
|
import { createServer } from "node:http";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
|
|
import { chromium } from "playwright-core";
|
|
|
|
import { PlaywrightScenarioSession } from "../../dist/browser/PlaywrightScenarioSession.js";
|
|
import { PlaywrightScenarioServiceWorkers } from "../../dist/browser/PlaywrightScenarioServiceWorkers.js";
|
|
import { createBrowserScenarioProvider } from "../../dist/composition/browserScenario.js";
|
|
import { browserScenarioSchema } from "../../dist/domain/browserScenario.js";
|
|
import { runScenarioCli } from "./browser-scenario-verifier.mjs";
|
|
import { verifyScenarioPolicySetup } from "./browser-scenario-service-worker-setup-e2e.mjs";
|
|
|
|
const startSite = async () => {
|
|
let otherOrigin;
|
|
const server = createServer((request, response) => {
|
|
const path = new URL(request.url, "http://localhost").pathname;
|
|
if (path === "/sw.js") {
|
|
response.setHeader("content-type", "application/javascript");
|
|
response.end(`self.addEventListener("install", () => self.skipWaiting());
|
|
self.addEventListener("activate", event => event.waitUntil(clients.claim()));
|
|
self.addEventListener("fetch", event => {
|
|
if (new URL(event.request.url).pathname === "/probe")
|
|
event.respondWith(new Response("worker"));
|
|
});`);
|
|
} else if (path === "/probe") {
|
|
response.end("network");
|
|
} else if (path === "/failed-navigation") {
|
|
response.destroy();
|
|
} else if (path === "/stalled-image.png") {
|
|
response.writeHead(200, { "content-type": "image/png" });
|
|
response.flushHeaders();
|
|
} else if (path === "/pending-image" || path === "/pending-child") {
|
|
response.setHeader("content-type", "text/html");
|
|
response.end(
|
|
'<!doctype html><img id="pending-image" src="/stalled-image.png">',
|
|
);
|
|
} else if (path === "/pending-frame") {
|
|
response.setHeader("content-type", "text/html");
|
|
response.end(
|
|
'<!doctype html><iframe id="pending-frame" src="/pending-child"></iframe>',
|
|
);
|
|
} else {
|
|
response.setHeader("content-type", "text/html");
|
|
response.end(`<!doctype html><p id="state">ready</p>
|
|
<a id="navigate" href="${otherOrigin}/">Navigate</a>
|
|
${path === "/frame" ? `<iframe src="${otherOrigin}/"></iframe>` : ""}
|
|
${
|
|
path === "/register"
|
|
? `<script>
|
|
navigator.serviceWorker.register('/sw.js').then(
|
|
() => document.querySelector('#state').textContent = 'registered',
|
|
() => {
|
|
document.querySelector('#state').textContent = 'blocked';
|
|
document.querySelector('#state').dataset.blocked = 'true';
|
|
});
|
|
</script>`
|
|
: ""
|
|
}`);
|
|
}
|
|
});
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
return {
|
|
origin: `http://127.0.0.1:${server.address().port}`,
|
|
setOtherOrigin: (origin) => {
|
|
otherOrigin = origin;
|
|
},
|
|
close: async () => {
|
|
const closed = once(server, "close");
|
|
server.close();
|
|
server.closeAllConnections();
|
|
await closed;
|
|
},
|
|
};
|
|
};
|
|
|
|
/** Real attached-page policy checks, with caller-owned workers on a second origin. */
|
|
export async function verifyScenarioServiceWorkers(endpoint, targetId) {
|
|
const first = await startSite();
|
|
const second = await startSite();
|
|
first.setOtherOrigin(second.origin);
|
|
second.setOtherOrigin(first.origin);
|
|
const observer = await chromium.connectOverCDP(endpoint);
|
|
let provider = createBrowserScenarioProvider(process.env);
|
|
let session;
|
|
let failure;
|
|
try {
|
|
let page;
|
|
for (const context of observer.contexts()) {
|
|
for (const candidate of context.pages()) {
|
|
const cdp = await context.newCDPSession(candidate);
|
|
const { targetInfo } = await cdp.send("Target.getTargetInfo");
|
|
await cdp.detach();
|
|
if (targetInfo.targetId === targetId) page = candidate;
|
|
}
|
|
}
|
|
assert.ok(page, "External fixture page disappeared");
|
|
const scenarioFor = (url, actions = [], policy = "block") =>
|
|
browserScenarioSchema.parse({
|
|
browser: {
|
|
mode: "connect",
|
|
cdp_endpoint: endpoint,
|
|
target_id: targetId,
|
|
},
|
|
start_url: { url },
|
|
environment: { service_workers: policy },
|
|
actions:
|
|
actions.length === 0
|
|
? [
|
|
{
|
|
step_id: "settle",
|
|
action: "wait_for_timeout",
|
|
duration_ms: 1,
|
|
},
|
|
]
|
|
: actions,
|
|
capture: { events: [], after_each_step: ["dom"], at_end: ["dom"] },
|
|
});
|
|
const assertRestored = async () => {
|
|
assert.equal(page.isClosed(), false);
|
|
assert.equal(
|
|
await page.evaluate(() =>
|
|
ServiceWorkerContainer.prototype.register
|
|
.toString()
|
|
.includes("[native code]"),
|
|
),
|
|
true,
|
|
);
|
|
};
|
|
const assertRejected = async (scenario) => {
|
|
const result = await provider.captureScenario(scenario);
|
|
assert.equal(
|
|
result.ok,
|
|
false,
|
|
"Worker-influenced capture unexpectedly succeeded",
|
|
);
|
|
assert.equal(result.error.reason, "target_not_allowed");
|
|
assert.equal(
|
|
result.error.partialObservation.capture.browser.cleanup,
|
|
"disconnected-external",
|
|
);
|
|
await assertRestored();
|
|
};
|
|
|
|
// A fresh attached origin blocks registration, then restores both the
|
|
// current document and future documents when its transport disconnects.
|
|
const clean = scenarioFor(`${first.origin}/register`, [
|
|
{
|
|
step_id: "registration-rejected",
|
|
action: "wait_for",
|
|
locator: { kind: "css", selector: "#state[data-blocked='true']" },
|
|
state: "visible",
|
|
},
|
|
]);
|
|
const cleanCapture = (await runScenarioCli(clean)).normalized_result;
|
|
assert.ok(cleanCapture.steps.every(({ status }) => status === "completed"));
|
|
assert.equal(cleanCapture.browser.cleanup, "disconnected-external");
|
|
await assertRestored();
|
|
await page.reload();
|
|
await page.getByText("registered", { exact: true }).waitFor();
|
|
await page.evaluate(async () => {
|
|
for (const registration of await navigator.serviceWorker.getRegistrations())
|
|
await registration.unregister();
|
|
});
|
|
await page.goto("about:blank");
|
|
|
|
// The caller installs a real worker at a different origin, then moves the
|
|
// selected target away before REA attaches. Its registration must survive.
|
|
await page.goto(second.origin);
|
|
await page.evaluate(async () => {
|
|
await navigator.serviceWorker.register("/sw.js");
|
|
await navigator.serviceWorker.ready;
|
|
});
|
|
await page.reload();
|
|
assert.equal(
|
|
await page.evaluate(async () => (await fetch("/probe")).text()),
|
|
"worker",
|
|
);
|
|
await page.goto("about:blank");
|
|
await assertRejected(scenarioFor(second.origin));
|
|
await page.goto("about:blank");
|
|
await assertRejected(
|
|
scenarioFor(first.origin, [
|
|
{
|
|
step_id: "cross-origin",
|
|
action: "goto",
|
|
destination: { url: second.origin },
|
|
wait_until: "load",
|
|
},
|
|
]),
|
|
);
|
|
await page.goto("about:blank");
|
|
await assertRejected(
|
|
scenarioFor(first.origin, [
|
|
{
|
|
step_id: "link-navigation",
|
|
action: "click",
|
|
locator: { kind: "css", selector: "#navigate" },
|
|
},
|
|
{ step_id: "loaded", action: "wait_for_timeout", duration_ms: 100 },
|
|
]),
|
|
);
|
|
await page.goto("about:blank");
|
|
await assertRejected(scenarioFor(`${first.origin}/frame`));
|
|
|
|
const allowed = await provider.captureScenario(
|
|
scenarioFor(second.origin, [], "allow"),
|
|
);
|
|
if (!allowed.ok) throw allowed.error;
|
|
assert.ok(
|
|
allowed.value.steps.every(({ status }) => status === "completed"),
|
|
);
|
|
assert.equal(
|
|
await page.evaluate(async () => (await fetch("/probe")).text()),
|
|
"worker",
|
|
);
|
|
assert.equal(
|
|
await page.evaluate(
|
|
async () => (await navigator.serviceWorker.getRegistrations()).length,
|
|
),
|
|
1,
|
|
);
|
|
|
|
// Restoration also runs after navigation failure and active cancellation.
|
|
await page.goto("about:blank");
|
|
const failed = await provider.captureScenario(
|
|
scenarioFor(`${first.origin}/failed-navigation`),
|
|
);
|
|
assert.equal(failed.ok, false);
|
|
// Chromium completes its error-document transition after goto rejects.
|
|
await page.waitForSelector("#main-frame-error", { state: "attached" });
|
|
// A context replacement can defer restoration. Retained cleanup must
|
|
// restore the replacement document before releasing the external page.
|
|
await provider.close();
|
|
provider = createBrowserScenarioProvider(process.env);
|
|
await page.goto(first.origin);
|
|
await assertRestored();
|
|
const cancellation = new AbortController();
|
|
const pending = provider.captureScenario(
|
|
scenarioFor(`${first.origin}/register`, [
|
|
{
|
|
step_id: "wait",
|
|
action: "wait_for",
|
|
locator: { kind: "css", selector: "#absent" },
|
|
state: "visible",
|
|
timeout_ms: 30_000,
|
|
},
|
|
]),
|
|
{ signal: cancellation.signal },
|
|
);
|
|
try {
|
|
const deadline = Date.now() + 10_000;
|
|
while ((await page.locator("#state").textContent()) !== "blocked") {
|
|
assert.ok(
|
|
Date.now() < deadline,
|
|
"Scenario never applied registration policy",
|
|
);
|
|
await delay(25);
|
|
}
|
|
} finally {
|
|
cancellation.abort();
|
|
}
|
|
const cancelled = await pending;
|
|
assert.ok(
|
|
!cancelled.ok ||
|
|
cancelled.value.steps.some(({ status }) => status === "cancelled"),
|
|
);
|
|
await assertRestored();
|
|
|
|
// Caller changes made after installation belong to the caller, too.
|
|
session = await PlaywrightScenarioSession.open(
|
|
scenarioFor(first.origin),
|
|
process.env,
|
|
{},
|
|
);
|
|
await page.evaluate(() => {
|
|
ServiceWorkerContainer.prototype.register =
|
|
function callerReplacement() {};
|
|
});
|
|
await session.close();
|
|
session = undefined;
|
|
assert.equal(
|
|
await page.evaluate(() => ServiceWorkerContainer.prototype.register.name),
|
|
"callerReplacement",
|
|
);
|
|
await page.reload();
|
|
await assertRestored();
|
|
await page.evaluate(() => {
|
|
delete ServiceWorkerContainer.prototype.register;
|
|
});
|
|
const policyBrowser = await chromium.connectOverCDP(endpoint);
|
|
try {
|
|
let policyPage;
|
|
for (const context of policyBrowser.contexts()) {
|
|
for (const candidate of context.pages()) {
|
|
const cdp = await context.newCDPSession(candidate);
|
|
const { targetInfo } = await cdp.send("Target.getTargetInfo");
|
|
await cdp.detach();
|
|
if (targetInfo.targetId === targetId) policyPage = candidate;
|
|
}
|
|
}
|
|
assert.ok(policyPage);
|
|
const policy = new PlaywrightScenarioServiceWorkers(
|
|
policyPage,
|
|
endpoint,
|
|
targetId,
|
|
);
|
|
await policy.initialize();
|
|
await policy.close();
|
|
} finally {
|
|
await policyBrowser.close();
|
|
}
|
|
assert.equal(
|
|
await page.evaluate(() =>
|
|
Object.hasOwn(ServiceWorkerContainer.prototype, "register"),
|
|
),
|
|
false,
|
|
);
|
|
await page.reload();
|
|
await assertRestored();
|
|
const policyCleanup = await verifyPolicyCleanup({
|
|
page,
|
|
endpoint,
|
|
targetId,
|
|
provider,
|
|
scenarioFor,
|
|
origin: first.origin,
|
|
});
|
|
return {
|
|
mocked: false,
|
|
cli: true,
|
|
production_provider: true,
|
|
initial_navigation: true,
|
|
action_navigation: true,
|
|
cross_origin_frame: true,
|
|
allow_preserves_existing_worker: true,
|
|
registration_blocked: true,
|
|
live_document_and_reload_restored: true,
|
|
failure_and_cancellation_cleanup: true,
|
|
caller_replacement_preserved: true,
|
|
caller_original_absence_preserved: true,
|
|
policyCleanup,
|
|
external_target_preserved: true,
|
|
};
|
|
} catch (cause) {
|
|
failure = cause;
|
|
throw cause;
|
|
} finally {
|
|
await session?.close();
|
|
try {
|
|
await provider.close();
|
|
} catch (cleanupFailure) {
|
|
if (failure !== undefined)
|
|
throw new AggregateError([failure, cleanupFailure]);
|
|
throw cleanupFailure;
|
|
} finally {
|
|
await observer.close();
|
|
await first.close();
|
|
await second.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
const verifyPolicyCleanup = async ({
|
|
page,
|
|
endpoint,
|
|
targetId,
|
|
provider,
|
|
scenarioFor,
|
|
origin,
|
|
}) => {
|
|
const nativeRegister = async () =>
|
|
page.evaluate(() =>
|
|
navigator.serviceWorker.register.toString().includes("[native code]"),
|
|
);
|
|
for (const path of ["/pending-image", "/pending-frame"]) {
|
|
const cancellation = new AbortController();
|
|
const pending = provider.captureScenario(scenarioFor(`${origin}${path}`), {
|
|
signal: cancellation.signal,
|
|
});
|
|
try {
|
|
await page
|
|
.locator(
|
|
path === "/pending-image" ? "#pending-image" : "#pending-frame",
|
|
)
|
|
.waitFor({ state: "attached" });
|
|
if (path === "/pending-frame") {
|
|
const child = page
|
|
.frames()
|
|
.find((frame) => frame.parentFrame() !== null);
|
|
assert.ok(child);
|
|
await child.waitForURL(`${origin}/pending-child`, {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
}
|
|
} finally {
|
|
cancellation.abort();
|
|
}
|
|
const result = await pending;
|
|
assert.equal(result.ok, false);
|
|
assert.equal(
|
|
result.error.partialObservation.capture.browser.cleanup,
|
|
"disconnected-external",
|
|
);
|
|
assert.equal(await nativeRegister(), true);
|
|
const next = await provider.captureScenario(scenarioFor(origin));
|
|
if (!next.ok) throw next.error;
|
|
assert.equal(next.value.browser.cleanup, "disconnected-external");
|
|
}
|
|
|
|
await page.addInitScript("globalThis.reaCallerInitScript = 'preserved'");
|
|
const policy = new PlaywrightScenarioServiceWorkers(page, endpoint, targetId);
|
|
await policy.initialize();
|
|
const exposure = await page.evaluate(async () => {
|
|
const register = navigator.serviceWorker.register;
|
|
const originals = Object.getOwnPropertySymbols(register).map(
|
|
(symbol) => register[symbol],
|
|
);
|
|
const restore = Object.getOwnPropertyNames(globalThis).find((name) =>
|
|
name.startsWith("rea-restore-"),
|
|
);
|
|
const denied = globalThis[restore]("not-the-session-secret");
|
|
let blocked = false;
|
|
try {
|
|
await navigator.serviceWorker.register("/sw.js");
|
|
} catch {
|
|
blocked = true;
|
|
}
|
|
return {
|
|
exposesOriginal: originals.some(
|
|
(value) => typeof value?.value === "function",
|
|
),
|
|
denied,
|
|
blocked,
|
|
registrations: (await navigator.serviceWorker.getRegistrations()).length,
|
|
};
|
|
});
|
|
assert.deepEqual(exposure, {
|
|
exposesOriginal: false,
|
|
denied: false,
|
|
blocked: true,
|
|
registrations: 0,
|
|
});
|
|
|
|
// Crossing the renderer boundary must not layer a wrapper over an earlier
|
|
// wrapper in a surviving realm, or leave an OOPIF unprotected.
|
|
const crossSite = `${origin.replace("127.0.0.1", "localhost")}/register`;
|
|
await page.evaluate((url) => {
|
|
const child = document.createElement("iframe");
|
|
child.id = "cross-site";
|
|
child.src = url;
|
|
document.body.append(child);
|
|
}, crossSite);
|
|
const child = page.frames().find((frame) => frame.parentFrame() !== null);
|
|
assert.ok(child);
|
|
await child.waitForURL(crossSite, { waitUntil: "load" });
|
|
assert.equal(await child.locator("#state").textContent(), "blocked");
|
|
await page.locator("#cross-site").evaluate((frame, url) => {
|
|
frame.src = url;
|
|
}, `${origin}/register`);
|
|
await child.waitForURL(`${origin}/register`, { waitUntil: "load" });
|
|
assert.equal(await child.locator("#state").textContent(), "blocked");
|
|
|
|
// Gate one real frame evaluation to force a new main document between live
|
|
// frame restorations. Browser replies and navigation remain real.
|
|
const originalEvaluate = child.evaluate.bind(child);
|
|
let release;
|
|
const gate = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
let entered;
|
|
const started = new Promise((resolve) => {
|
|
entered = resolve;
|
|
});
|
|
child.evaluate = async (...args) => {
|
|
entered();
|
|
await gate;
|
|
return originalEvaluate(...args);
|
|
};
|
|
const closing = policy.close();
|
|
void closing.catch(() => undefined);
|
|
try {
|
|
await started;
|
|
await page.goto(origin);
|
|
} finally {
|
|
release();
|
|
child.evaluate = originalEvaluate;
|
|
}
|
|
await closing;
|
|
assert.equal(await nativeRegister(), true);
|
|
await page.reload();
|
|
assert.equal(await nativeRegister(), true);
|
|
assert.equal(
|
|
await page.evaluate(() => globalThis.reaCallerInitScript),
|
|
"preserved",
|
|
);
|
|
assert.equal(
|
|
await page.evaluate(() =>
|
|
Object.getOwnPropertyNames(globalThis).some((name) =>
|
|
name.startsWith("rea-restore-"),
|
|
),
|
|
),
|
|
false,
|
|
);
|
|
await verifyRejectedPolicyInstallation(page.context(), endpoint, origin);
|
|
const setup = await verifyScenarioPolicySetup(
|
|
endpoint,
|
|
targetId,
|
|
origin,
|
|
page,
|
|
);
|
|
return {
|
|
stalled_image_cancelled: true,
|
|
stalled_frame_cancelled: true,
|
|
target_reused_after_cancel: true,
|
|
private_original_descriptor: true,
|
|
wrong_restore_token_rejected: true,
|
|
renderer_transition: true,
|
|
navigation_during_restoration: true,
|
|
caller_init_script_preserved: true,
|
|
failed_main_installation_rejected: true,
|
|
failed_accessor_installation_rejected: true,
|
|
failed_iframe_installation_rejected: true,
|
|
replaced_iframe_policy_rejected: true,
|
|
setup,
|
|
};
|
|
};
|
|
|
|
const verifyRejectedPolicyInstallation = async (context, endpoint, origin) => {
|
|
for (const realm of ["main", "accessor", "existing-iframe", "new-iframe"]) {
|
|
const page = await context.newPage();
|
|
let policy;
|
|
try {
|
|
await page.goto(origin);
|
|
const cdp = await context.newCDPSession(page);
|
|
const { targetInfo } = await cdp.send("Target.getTargetInfo");
|
|
await cdp.detach();
|
|
policy = new PlaywrightScenarioServiceWorkers(
|
|
page,
|
|
endpoint,
|
|
targetInfo.targetId,
|
|
);
|
|
if (realm === "new-iframe") await policy.initialize();
|
|
let frame = page.mainFrame();
|
|
if (realm === "existing-iframe" || realm === "new-iframe") {
|
|
const crossSite = origin.replace("127.0.0.1", "localhost");
|
|
await page.evaluate((url) => {
|
|
const child = document.createElement("iframe");
|
|
child.src = url;
|
|
document.body.append(child);
|
|
}, crossSite);
|
|
frame = page
|
|
.frames()
|
|
.find((candidate) => candidate.parentFrame() !== null);
|
|
assert.ok(frame);
|
|
await frame.waitForURL(crossSite, { waitUntil: "load" });
|
|
}
|
|
await frame.evaluate((kind) => {
|
|
const prototype = ServiceWorkerContainer.prototype;
|
|
const descriptor = Object.getOwnPropertyDescriptor(
|
|
prototype,
|
|
"register",
|
|
);
|
|
if (kind === "accessor") {
|
|
const original = descriptor.value;
|
|
Object.defineProperty(prototype, "register", {
|
|
configurable: true,
|
|
enumerable: descriptor.enumerable,
|
|
get: () => original,
|
|
});
|
|
return;
|
|
}
|
|
Object.defineProperty(prototype, "register", {
|
|
...descriptor,
|
|
configurable: false,
|
|
...(kind === "new-iframe"
|
|
? { value: function callerReplacement() {} }
|
|
: {}),
|
|
});
|
|
}, realm);
|
|
await assert.rejects(
|
|
realm === "new-iframe" ? policy.assertBlocked() : policy.initialize(),
|
|
(error) => error.reason === "target_not_allowed",
|
|
);
|
|
await policy.close();
|
|
assert.equal(
|
|
await frame.evaluate((replaced) => {
|
|
const register = navigator.serviceWorker.register;
|
|
return replaced
|
|
? register.name === "callerReplacement"
|
|
: register.toString().includes("[native code]");
|
|
}, realm === "new-iframe"),
|
|
true,
|
|
);
|
|
} finally {
|
|
try {
|
|
await policy?.close();
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
}
|
|
}
|
|
};
|