fix(ci): reject false browser environment cancellation passes

Require typed cancellation and confirmed external-browser cleanup in the
real environment verifier. Close its provider and CDP observer while
preserving primary and cleanup failures. Cover six controlled outcomes
through the verifier's actual cancellation path.

Fixes #4033

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
Ysopwnable
2026-10-15 16:54:58 +08:00
committed by GitHub
parent e45eb33f03
commit e18fccc548
2 changed files with 114 additions and 19 deletions
@@ -11,6 +11,7 @@ import { browserScenarioSchema } from "../../dist/domain/browserScenario.js";
import { AnalysisCancelledError } from "../../dist/domain/analysisErrorCore.js";
import { runScenarioCli } from "./browser-scenario-verifier.mjs";
import { requireMcpEvidenceResult } from "./mcp-verifier-results.mjs";
import { closeVerifierResources } from "./verifier-cleanup.mjs";
const expected = {
timezone: "Pacific/Honolulu",
@@ -113,19 +114,21 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
assert.equal(value.result.type, "string");
return JSON.parse(value.result.value);
};
const baseline = await readEnvironment();
const scenario = environmentScenario({
endpoint,
targetId,
origin,
values: expected,
});
const assertRestored = async () => {
assert.deepEqual(await readEnvironment(), baseline);
const remaining = await (await fetch(`${endpoint}/json/list`)).json();
assert.ok(remaining.some(({ id }) => id === targetId));
};
const provider = createBrowserScenarioProvider(process.env);
let verificationError;
try {
const baseline = await readEnvironment();
const scenario = environmentScenario({
endpoint,
targetId,
origin,
values: expected,
});
const assertRestored = async () => {
assert.deepEqual(await readEnvironment(), baseline);
const remaining = await (await fetch(`${endpoint}/json/list`)).json();
assert.ok(remaining.some(({ id }) => id === targetId));
};
assertCapture((await runScenarioCli(scenario)).normalized_result);
await assertRestored();
const transport = new StdioClientTransport({
@@ -167,7 +170,6 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
await transport.close();
}
await assertRestored();
const provider = createBrowserScenarioProvider(process.env);
const initializationFailure = await provider.captureScenario({
...scenario,
environment: { ...scenario.environment, timezone: "REA/Invalid" },
@@ -204,8 +206,14 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
cancellation_cleanup: true,
external_target_preserved: true,
};
} catch (cause) {
verificationError = cause;
throw cause;
} finally {
await connection.close();
await closeVerifierResources(
[() => provider.close(), () => connection.close()],
verificationError,
);
}
}
@@ -356,7 +364,11 @@ const releaseScenario = async (connection) => {
});
};
async function verifyCancelledEnvironment(provider, connection, scenario) {
export async function verifyCancelledEnvironment(
provider,
connection,
scenario,
) {
const controller = new AbortController();
const priorSnapshot = await readScenarioSnapshot(connection);
const pending = provider.captureScenario(
@@ -378,9 +390,15 @@ async function verifyCancelledEnvironment(provider, connection, scenario) {
await settlement;
}
const cancelled = await pending;
assert.ok(
!cancelled.ok ||
if (!cancelled.ok) {
if (!(cancelled.error instanceof AnalysisCancelledError))
throw cancelled.error;
assert.equal(cancelled.error.cleanupIncomplete, false);
} else {
assert.ok(
cancelled.value.steps.some(({ status }) => status === "cancelled"),
"Cancellation unexpectedly completed every action",
);
"Cancellation unexpectedly completed every action",
);
assert.equal(cancelled.value.browser.cleanup, "disconnected-external");
}
}
@@ -0,0 +1,77 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { expect, it } from "vitest";
const execute = promisify(execFile);
it("requires actual cancellation and confirmed cleanup before the environment verifier passes", async () => {
// Execute the verifier's actual oracle with controlled provider outcomes.
// The real-browser lane separately proves emulation and restoration in Chrome.
const { stdout } = await execute(process.execPath, [
"--input-type=module",
"-e",
String.raw`
const { verifyCancelledEnvironment } = await import(process.argv[1]);
const { AnalysisCancelledError, AnalysisProtocolError } = await import(
new URL("../../dist/domain/analysisErrorCore.js", process.argv[1])
);
const outcomes = [];
for (const kind of ["cancelled", "protocol-error", "cleanup-failed", "completed", "cancelled-step", "incomplete-step"]) {
let snapshots = 0;
let aborted = false;
const connection = {
send: async () => ({ result: {
type: "string",
value: JSON.stringify({
timeOrigin: ++snapshots,
environment: { timezone: "Pacific/Honolulu", locale: "fr-FR", dpr: 2, width: 900, height: 600 },
}),
} }),
};
const error = kind === "protocol-error"
? new AnalysisProtocolError("controlled protocol failure")
: new AnalysisCancelledError("capture_browser_scenario", kind === "cleanup-failed"
? { cleanup: { reason: "controlled cleanup failure", resources: ["browser_transport"] } }
: undefined);
const result = ["completed", "cancelled-step", "incomplete-step"].includes(kind)
? { ok: true, value: {
steps: [{ status: kind === "completed" ? "completed" : "cancelled" }],
browser: { cleanup: kind === "incomplete-step" ? "incomplete" : "disconnected-external" },
} }
: { ok: false, error };
const provider = {
captureScenario: (_scenario, { signal }) => new Promise((resolve) => {
signal.addEventListener("abort", () => { aborted = true; resolve(result); }, { once: true });
}),
};
let accepted = false;
try {
await verifyCancelledEnvironment(provider, connection, { actions: [{}] });
accepted = true;
} catch {}
outcomes.push({ kind, accepted, aborted, snapshots });
}
console.log(JSON.stringify(outcomes));
`,
new URL(
"../../../scripts/lib/browser-scenario-environment-e2e.mjs",
import.meta.url,
).href,
]);
expect(JSON.parse(stdout)).toEqual(
[
["cancelled", true],
["protocol-error", false],
["cleanup-failed", false],
["completed", false],
["cancelled-step", true],
["incomplete-step", false],
].map(([kind, accepted]) => ({
kind,
accepted,
aborted: true,
snapshots: 2,
})),
);
});