mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 10:43:30 +00:00
* fix(verifiers): release fixture roots only after their owners finish Several real-tool verifiers released a fixture root even when a cleanup that still used it had failed, so a surviving MCP server, session or descendant kept running against a deleted directory and the failure looked like ordinary cleanup. - scripts/lib/verifier-cleanup.mjs: new releaseAfter(dependents, release, retained) task. It attempts every dependent cleanup and releases the resource only if all succeed; otherwise it keeps the resource and reports it with the dependent failures. - verify-signal-recording: the root is released after the MCP client and transport close, and the host-limits read moves before the root is acquired so its failure cannot leak the root. - verify-recorded-crash: sessions, the sentinel and the process-lineage check must all succeed before the root is released. A transport close failure after a failed connect keeps the connect failure. - verify-wasm-artifact: both roots are released after the MCP client and transport close; the residue check and the two directories stay independent after that. - verify-native-ui: `open -W` exits only after the app exits, so the root is removed only once that exit is observed; otherwise it is retained and reported. Fixes #8561 Fixes #8562 Fixes #8564 Refs #8559 Co-Authored-By: gensokyo-dev <gensokyo-dev@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(verifiers): treat a signaled open as unproven fixture exit A terminated `open` launcher does not prove the LaunchServices app it started has exited, so only an `open -W` that exits on its own counts. Also clear the grace timer so a prompt exit does not hold the process for the rest of the grace period. Refs #8559 Co-Authored-By: gensokyo-dev <gensokyo-dev@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(verifiers): model open -W exiting after the fixture is signaled The native UI verifier now removes its root only after `open -W` exits on its own. The authored recording's `open` never exited when the fixture was signaled and had no signalCode, so the complete case waited out the grace period and kept the root. The double now behaves like `open -W`: it exits once the fixture process is signaled. Refs #8559 Co-Authored-By: gensokyo-dev <gensokyo-dev@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: gensokyo-dev <gensokyo-dev@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
333 lines
11 KiB
JavaScript
333 lines
11 KiB
JavaScript
import { execFile, spawn } from "node:child_process";
|
|
import assert from "node:assert/strict";
|
|
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify } from "node:util";
|
|
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
|
import { observeNativeUi } from "../dist/native/NativeUiObservation.js";
|
|
import { NATIVE_UI_HELPER_MAX_BUFFER } from "../dist/native/NativeUiOutputBudget.js";
|
|
import { artifactMcpResult, withArtifactMcp } from "./lib/artifact-e2e.mjs";
|
|
import { observeProcessStartIdentity } from "../dist/process/ProcessOwnershipObservation.js";
|
|
|
|
if (process.platform !== "darwin")
|
|
throw new Error(
|
|
"Native UI verification requires macOS, an interactive desktop and Xcode command-line tools",
|
|
);
|
|
const root = await mkdtemp(join(tmpdir(), "rea-ui-fixture-"));
|
|
let child;
|
|
let childExit;
|
|
let fixturePid;
|
|
let fixtureIdentity;
|
|
let report;
|
|
// `open -W` exits only after the app exits, so its own exit is the proof that
|
|
// the fixture no longer runs from the root. Without it the root is retained.
|
|
let retainedRoot;
|
|
const FIXTURE_EXIT_GRACE_MS = 10_000;
|
|
try {
|
|
const childRetrievalTest = join(root, "child-retrieval-test");
|
|
await promisify(execFile)("/usr/bin/xcrun", [
|
|
"swiftc",
|
|
fileURLToPath(
|
|
new URL("../bridge/native/NativeUIChildren.swift", import.meta.url),
|
|
),
|
|
fileURLToPath(
|
|
new URL("../bridge/native/NativeUIValues.swift", import.meta.url),
|
|
),
|
|
fileURLToPath(
|
|
new URL(
|
|
"../tests/conformance/native/native-ui-children/main.swift",
|
|
import.meta.url,
|
|
),
|
|
),
|
|
"-o",
|
|
childRetrievalTest,
|
|
]);
|
|
const childRetrieval = await promisify(execFile)(childRetrievalTest, []);
|
|
const [slashRichProducerJson, numericProducerJson] = childRetrieval.stdout
|
|
.trimEnd()
|
|
.split("\n");
|
|
if (
|
|
slashRichProducerJson === undefined ||
|
|
numericProducerJson === undefined
|
|
) {
|
|
throw new Error(
|
|
"Native UI serializer seam did not emit its producer bytes",
|
|
);
|
|
}
|
|
const slashRichConsumerJson = JSON.stringify(
|
|
JSON.parse(slashRichProducerJson),
|
|
);
|
|
if (slashRichProducerJson !== slashRichConsumerJson) {
|
|
throw new Error(
|
|
"Swift helper JSON bytes differ from the normalized JavaScript consumer representation",
|
|
);
|
|
}
|
|
const numericConsumerJson = JSON.stringify(JSON.parse(numericProducerJson));
|
|
if (
|
|
Buffer.byteLength(numericProducerJson) <=
|
|
Buffer.byteLength(numericConsumerJson)
|
|
) {
|
|
throw new Error(
|
|
"Foundation numeric fixture no longer exercises larger producer JSON bytes",
|
|
);
|
|
}
|
|
if (Buffer.byteLength(numericProducerJson) > NATIVE_UI_HELPER_MAX_BUFFER) {
|
|
throw new Error("Foundation numeric fixture exceeds the raw helper budget");
|
|
}
|
|
|
|
const contents = join(root, "Fixture.app", "Contents");
|
|
await mkdir(join(contents, "MacOS"), { recursive: true });
|
|
await writeFile(
|
|
join(contents, "Info.plist"),
|
|
`<?xml version="1.0"?><plist version="1.0"><dict><key>CFBundleExecutable</key><string>fixture</string><key>CFBundleIdentifier</key><string>io.rea.source-owned-ui-fixture</string><key>CFBundleName</key><string>REA UI Fixture</string><key>CFBundlePackageType</key><string>APPL</string></dict></plist>`,
|
|
);
|
|
const executable = join(contents, "MacOS", "fixture");
|
|
await promisify(execFile)("/usr/bin/xcrun", [
|
|
"swiftc",
|
|
"-module-cache-path",
|
|
join(root, "modules"),
|
|
fileURLToPath(
|
|
new URL("../tests/conformance/native/ui.swift", import.meta.url),
|
|
),
|
|
"-o",
|
|
executable,
|
|
]);
|
|
const coordinates = join(root, "scope.json");
|
|
await writeFile(coordinates, "");
|
|
child = spawn(
|
|
"/usr/bin/open",
|
|
["-W", "-n", "-g", "--stdout", coordinates, join(root, "Fixture.app")],
|
|
{ stdio: "ignore" },
|
|
);
|
|
childExit = new Promise((resolve) => child.once("exit", resolve));
|
|
let scope;
|
|
const deadline = Date.now() + 20000;
|
|
while (Date.now() < deadline) {
|
|
const output = await readFile(coordinates, "utf8");
|
|
if (output.includes("\n")) {
|
|
scope = JSON.parse(output.split("\n")[0]);
|
|
break;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
if (scope === undefined)
|
|
throw new Error(
|
|
"Source-owned UI fixture failed to expose one window within 20 seconds",
|
|
);
|
|
fixturePid = scope.pid;
|
|
fixtureIdentity = await observeProcessStartIdentity(fixturePid);
|
|
assert.equal(
|
|
fixtureIdentity?.state,
|
|
"readable",
|
|
"Fixture OS start identity is unavailable",
|
|
);
|
|
const target = await parseBinaryTarget(executable);
|
|
if (!target.ok) throw target.error;
|
|
const observation = await observeNativeUi(
|
|
target.value,
|
|
"observe_native_ui",
|
|
{
|
|
...scope,
|
|
},
|
|
{ environment: process.env },
|
|
);
|
|
if (
|
|
!observation.ok &&
|
|
!/accessibility-denied|screen-recording-denied/u.test(
|
|
observation.error.message,
|
|
)
|
|
)
|
|
throw observation.error;
|
|
if (!observation.ok && !process.argv.includes("--allow-permission-denial"))
|
|
throw new Error(
|
|
`Native UI E2E requires successful capture; permissions prevented coverage: ${observation.error.message}. Use --allow-permission-denial only for the separate permission diagnostic lane.`,
|
|
);
|
|
let scenarioStatus = "not-run-permission-denied";
|
|
if (observation.ok) {
|
|
const button = observation.value.initial.nodes.find(
|
|
(node) => node.title === "Increment REA fixture",
|
|
);
|
|
if (button === undefined)
|
|
throw new Error("Source-owned fixture button is absent from AX capture");
|
|
const scenario = await observeNativeUi(
|
|
target.value,
|
|
"capture_native_ui_scenario",
|
|
{
|
|
...scope,
|
|
screenshot: false,
|
|
steps: [
|
|
{ kind: "click", path: button.path },
|
|
{ kind: "wait", milliseconds: 200 },
|
|
],
|
|
},
|
|
{ environment: process.env },
|
|
);
|
|
if (!scenario.ok) throw scenario.error;
|
|
if (
|
|
scenario.value.steps.some((step) => step.outcome !== "completed") ||
|
|
!scenario.value.steps
|
|
.at(-1)
|
|
?.after?.nodes.some(
|
|
(node) => node.title === "REA fixture incremented 1",
|
|
)
|
|
)
|
|
throw new Error(
|
|
`Source-owned AX scenario failed: ${JSON.stringify(scenario.value.steps.map(({ outcome, reason }) => ({ outcome, reason })))}`,
|
|
);
|
|
scenarioStatus = "selected-AX-button-action-observed";
|
|
const cli = await promisify(execFile)(
|
|
process.execPath,
|
|
[
|
|
fileURLToPath(new URL("./rea.mjs", import.meta.url)),
|
|
"capture-native-ui-scenario",
|
|
executable,
|
|
"--pid",
|
|
String(scope.pid),
|
|
"--window-id",
|
|
String(scope.window_id),
|
|
"--steps",
|
|
JSON.stringify([
|
|
{ kind: "click", path: button.path },
|
|
{ kind: "wait", milliseconds: 200 },
|
|
]),
|
|
"--json",
|
|
],
|
|
{ maxBuffer: 32 * 1024 * 1024, timeout: 180000 },
|
|
);
|
|
const evidence = JSON.parse(cli.stdout);
|
|
if (
|
|
evidence.normalized_result?.steps?.some(
|
|
(step) => step.outcome !== "completed",
|
|
) ||
|
|
evidence.normalized_result?.steps?.length !== 2 ||
|
|
!evidence.normalized_result?.steps
|
|
?.at(-1)
|
|
?.after?.nodes.some(
|
|
(node) => node.title === "REA fixture incremented 2",
|
|
)
|
|
)
|
|
throw new Error(
|
|
`Native UI CLI did not preserve the selected scenario: ${JSON.stringify(evidence.error ?? evidence.normalized_result?.steps?.map(({ outcome, reason }) => ({ outcome, reason })))}`,
|
|
);
|
|
scenarioStatus = "selected-AX-button-action-and-CLI-parity-observed";
|
|
await withArtifactMcp(executable, async (client) => {
|
|
const observed = await artifactMcpResult(client, "observe_native_ui", {
|
|
...scope,
|
|
screenshot: false,
|
|
});
|
|
if (
|
|
!observed.initial.nodes.some(
|
|
(node) =>
|
|
node.role === "AXButton" &&
|
|
JSON.stringify(node.path) === JSON.stringify(button.path),
|
|
)
|
|
)
|
|
throw new Error("MCP omitted the selected fixture's actual AX button");
|
|
const captured = await artifactMcpResult(
|
|
client,
|
|
"capture_native_ui_scenario",
|
|
{
|
|
...scope,
|
|
screenshot: false,
|
|
steps: [
|
|
{ kind: "click", path: button.path },
|
|
{ kind: "wait", milliseconds: 200 },
|
|
],
|
|
},
|
|
);
|
|
if (
|
|
captured.steps.length !== 2 ||
|
|
captured.steps.some((step) => step.outcome !== "completed") ||
|
|
!captured.steps
|
|
.at(-1)
|
|
?.after?.nodes.some(
|
|
(node) => node.title === "REA fixture incremented 3",
|
|
)
|
|
)
|
|
throw new Error("MCP did not observe the selected fixture's AX action");
|
|
});
|
|
scenarioStatus = "selected-AX-button-action-and-CLI-MCP-parity-observed";
|
|
}
|
|
const mismatch = await observeNativeUi(
|
|
{ ...target.value, sha256: "0".repeat(64) },
|
|
"observe_native_ui",
|
|
scope,
|
|
{ environment: process.env },
|
|
);
|
|
// A selection failure carries its helper code in the issue, not in its message.
|
|
if (
|
|
mismatch.ok ||
|
|
!mismatch.error.issues?.some(({ message }) =>
|
|
message.startsWith("target-mismatch:"),
|
|
)
|
|
)
|
|
throw new Error("Real helper did not reject changed target bytes");
|
|
report = {
|
|
ok: true,
|
|
verification_status: observation.ok ? "passed" : "permission-boundary-only",
|
|
positive_e2e: observation.ok,
|
|
observation: observation.ok
|
|
? "captured-selected-fixture-window"
|
|
: "os-permission-denial-verified",
|
|
scenario: scenarioStatus,
|
|
permission_failure: observation.ok ? null : observation.error.message,
|
|
mismatch_rejected: true,
|
|
target_owned: true,
|
|
};
|
|
} finally {
|
|
if (fixturePid !== undefined && fixtureIdentity?.state === "readable") {
|
|
const current = await observeProcessStartIdentity(fixturePid);
|
|
if (current !== undefined) {
|
|
assert.ok(
|
|
current.state === "readable" &&
|
|
current.identity === fixtureIdentity.identity,
|
|
"Fixture PID identity changed; refusing to signal another process",
|
|
);
|
|
try {
|
|
process.kill(fixturePid, "SIGTERM");
|
|
} catch (cause) {
|
|
if (cause.code !== "ESRCH") throw cause;
|
|
}
|
|
}
|
|
}
|
|
const fixtureExited =
|
|
child === undefined || (await openExitedWithFixture(child, childExit));
|
|
if (fixtureExited) await rm(root, { recursive: true, force: true });
|
|
else {
|
|
// The app may have been reparented away from `open`; stopping `open`
|
|
// does not prove the fixture exited, so its root stays in place.
|
|
if (child.exitCode === null && child.signalCode === null) {
|
|
child.kill("SIGTERM");
|
|
await childExit;
|
|
}
|
|
retainedRoot = root;
|
|
process.stderr.write(
|
|
`Source-owned UI fixture exit was not observed; retained ${root}\n`,
|
|
);
|
|
}
|
|
}
|
|
if (retainedRoot !== undefined)
|
|
throw new Error(
|
|
`Source-owned UI fixture exit was not observed; retained ${retainedRoot}`,
|
|
);
|
|
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
|
|
// `open -W` exits on its own only after the app exits (or never launched it).
|
|
// A signaled `open`, or none within the grace period, leaves the app unproven.
|
|
async function openExitedWithFixture(launcher, exit) {
|
|
if (launcher.exitCode === null && launcher.signalCode === null) {
|
|
let timer;
|
|
const exited = await Promise.race([
|
|
exit.then(() => true),
|
|
new Promise((resolve) => {
|
|
timer = setTimeout(() => resolve(false), FIXTURE_EXIT_GRACE_MS);
|
|
}),
|
|
]);
|
|
clearTimeout(timer);
|
|
if (!exited) return false;
|
|
}
|
|
return launcher.exitCode !== null && launcher.signalCode === null;
|
|
}
|