fix: recheck attached worker policy after final artifacts

This commit is contained in:
morluto committed 2026-10-24 03:00:08 +08:00
1 parent 98c3d2ff93
commit 0729aba6d7
3 files changed
+105 -2

No files matched your search

+5 -1
View File
@@ -17,7 +17,11 @@ final sanitized URL capture are supplied by default. Set
`environment.service_workers` to `allow` when the application requires them.
For attached browsers, `block` rejects captures when the selected page or its
frames are controlled by a service worker or already have a registration,
including after navigation. Existing caller registrations are never removed.
including after navigation. Policy checks bracket each artifact capture, including
the final step. If an external worker takes control while artifacts are being
captured, REA rejects the capture and retains the completed artifacts in
`details.diagnostics.collected_artifacts`; they do not establish policy-compliant
completion. Existing caller registrations are never removed.
Navigation can run destination code before a newly discovered worker is rejected;
that rejection does not roll back browser effects. REA removes its owned
future-document scripts, restores live frame methods, then disconnects.
@@ -1,4 +1,5 @@
import { AnalysisCleanupLeaseGroup } from "../artifacts/AnalysisCleanupLease.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { PlaywrightBrowserScenarioProvider } from "./PlaywrightBrowserScenarioProvider.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
@@ -1484,3 +1485,77 @@ it.each(["launch", "connect"] as const)(
}
},
);
it.each([false, true])(
"checks attached worker policy after final artifacts, worker takeover=%s",
async (takeover) => {
const { page, cleanup } = fixture();
let controlled = false;
let captures = 0;
Object.assign(page, {
waitForTimeout: vi.fn(async () => undefined),
content: vi.fn(async () => {
captures += 1;
controlled = takeover && captures === 2;
return controlled ? "<worker-response/>" : "<uncontrolled-response/>";
}),
});
page.evaluate.mockImplementation(async (script: unknown) =>
controlled &&
typeof script === "string" &&
script.includes("serviceWorker.controller")
? "service_worker_blocked"
: null,
);
const input = browserScenarioSchema.parse({
...connectedScenario("block"),
capture: { after_each_step: ["dom"], at_end: ["dom"], events: [] },
});
const provider = new PlaywrightBrowserScenarioProvider(
(scenario, options) =>
PlaywrightScenarioSession.open(scenario, {}, options),
);
try {
const result = await provider.captureScenario(input, {});
expect(captures).toBe(2);
expect(cleanup.browserClosed).toBe(true);
if (takeover) {
if (result.ok)
throw new Error("Expected final worker policy rejection");
expect(projectAnalysisError(result.error)).toMatchObject({
details: {
diagnostics: {
collected_artifacts: {
dom: { value: { text: "<worker-response/>" } },
},
},
},
});
expect(result.error).toMatchObject({
reason: "service_worker_blocked",
cleanupIncomplete: false,
diagnostics: {
collected_artifacts: {
dom: { state: "captured", value: { text: "<worker-response/>" } },
},
},
partialObservation: {
capture: {
browser: { cleanup: "disconnected-external" },
steps: [{ action: "goto_start" }],
},
},
});
} else {
if (!result.ok) throw result.error;
expect(result.value.steps).toHaveLength(2);
expect(result.value.steps[1]).toMatchObject({
status: "completed",
completeness: { status: "complete", equality_eligible: true },
});
}
} finally {
await provider.close();
}
},
);
+25 -1
View File
@@ -6,6 +6,9 @@ import type { BrowserContext } from "playwright-core";
import type { BrowserScenario } from "../domain/browserScenario.js";
import type { BrowserScenarioAction } from "../domain/browserScenarioValues.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY } from "./providerIdentities.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import type { BrowserScenarioSessionPort } from "./BrowserScenarioSessionPort.js";
@@ -459,7 +462,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
undefined,
signal,
);
return withPlaywrightExecutionBoundary(
const artifacts = await withPlaywrightExecutionBoundary(
() =>
capturePlaywrightStepArtifacts({
context: this.opened.context,
@@ -470,6 +473,27 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
undefined,
signal,
);
try {
await withPlaywrightExecutionBoundary(
() => this.serviceWorkers?.assertBlocked() ?? Promise.resolve(),
undefined,
signal,
);
} catch (cause: unknown) {
const failure =
cause instanceof AnalysisError
? cause
: new ProviderAdapterError(
PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY.id,
OPERATION,
{ cause },
);
failure.retainDiagnostics({
collected_artifacts: jsonValueSchema.parse(artifacts),
});
throw failure;
}
return artifacts;
}
async close(stopLoading = this.signal?.aborted === true) {