Files
rea/tests/boundary/process/nativeUiVerifierLane.test.ts
Kaoru0822andClaude Opus 5.5 26c3e9e413 fix(native): select off-screen windows so the UI lane can complete (#8654)
* fix(native): stop calling an off-screen window absent

The native UI helper looked up its selected window with
`.optionIncludingWindow`, which returned nothing while that window was off
screen. An existing window owned by the selected process was reported as
`window-mismatch`, before any permission check, so the verifier could not reach
its permission boundary. Select from all windows instead; absent and foreign
windows still fail.

The verifier's changed-bytes check also read `error.message`, but since
cc90771af a selection failure carries its helper code in `error.issues`, so the
check could not pass. Read the issue. Together these let the permissions lane
complete on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(native): own UI test processes from the moment they launch

The off-screen test registered cleanup only after parsing the ready record, so a
startup failure left `/usr/bin/open` and its app running. The lane test killed
only the verifier on a timeout, leaving its launcher and detached fixture alive
until they expired.

Register the off-screen owner immediately after the launch; the app inherits the
run token, so the sweep needs no ready record. In the lane test, every stand-in
records itself and leaves on one release the test's cleanup awaits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:57:32 +08:00

223 lines
8.1 KiB
TypeScript

import { spawn } from "node:child_process";
import { readdir, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import { expect, it, onTestFinished } from "vitest";
import { z } from "zod";
import { systemProcessOwnershipHost } from "../../../src/process/ProcessOwnershipObservation.js";
import { liveProcesses } from "../../../src/process/ProcessOwnershipProcessTree.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
// Stands in for /usr/bin/open: starts a detached app that inherits its environment.
// Each stand-in records itself and leaves when the test releases it, or expires.
const standInPrelude = `
import { appendFileSync, existsSync, readFileSync } from "node:fs";
const config = JSON.parse(readFileSync(new URL("./config.json", import.meta.url), "utf8"));
const record = (kind) => appendFileSync(config.events, JSON.stringify({ kind, pid: process.pid }) + "\\n");
setInterval(() => {
if (existsSync(config.release)) process.exit(0);
}, 10);
setTimeout(() => process.exit(0), 60000);
`;
const launcherSource = `
import { spawn } from "node:child_process";
import { openSync } from "node:fs";
${standInPrelude}
record("launcher");
const args = process.argv.slice(2);
const coordinates = args[args.indexOf("--stdout") + 1];
spawn(process.execPath, [new URL("./fixture.mjs", import.meta.url).pathname], {
detached: true,
stdio: ["ignore", openSync(coordinates, "a"), "ignore"],
}).unref();
`;
const fixtureSource = `
import { writeSync } from "node:fs";
${standInPrelude}
record("fixture");
writeSync(1, JSON.stringify({ pid: process.pid, window_id: 7 }) + "\\n");
`;
// Runs the actual verifier and observation mapping. Only the compiler and launcher
// are replaced: the compiled helper is an authored script answering as the real
// helper does, so this covers the lane's assertions and not native capture.
const preloadSource = `
import { chmodSync, copyFileSync, readFileSync, writeFileSync } from "node:fs";
import { createRequire, syncBuiltinESMExports } from "node:module";
import { basename } from "node:path";
import { promisify } from "node:util";
const config = JSON.parse(readFileSync(new URL("./config.json", import.meta.url), "utf8"));
const childProcess = createRequire(import.meta.url)("node:child_process");
const { execFile, spawn } = childProcess;
const script = (path, body) => {
writeFileSync(path, "#!/bin/sh\\n" + body + "\\n");
chmodSync(path, 0o755);
};
const compile = (args) => {
const output = args[args.indexOf("-o") + 1];
if (basename(output) === "fixture") copyFileSync("/usr/bin/true", output);
else if (basename(output) === "child-retrieval-test")
script(output, "printf '%s\\\\n' '{\\"a\\":\\"b\\"}' '{\\"n\\":1.0}'");
else script(output, config.helper);
};
const compiled = (file, args) => file === "/usr/bin/xcrun" && args.includes("-o");
childProcess.execFile = (file, args, ...rest) => {
if (!compiled(file, args)) return execFile(file, args, ...rest);
compile(args);
const done = rest.at(-1);
queueMicrotask(() => done(null, "", ""));
return spawn("/usr/bin/true", [], { stdio: "ignore" });
};
childProcess.execFile[promisify.custom] = (file, args, ...rest) => {
if (!compiled(file, args)) return execFile[promisify.custom](file, args, ...rest);
compile(args);
return Promise.resolve({ stdout: "", stderr: "" });
};
childProcess.spawn = (file, args, options) => {
if (compiled(file, args)) {
compile(args);
return spawn("/usr/bin/true", [], options);
}
return file === "/usr/bin/open"
? spawn(process.execPath, [config.launcherEntry, ...args], options)
: spawn(file, args, options);
};
syncBuiltinESMExports();
`;
const denied = `printf '%s' '{"ok":false,"code":"screen-recording-denied","message":"authored permission denial"}'`;
const answersChangedBytes = `case "$1" in *'"sha256":"0000000000'*) printf '%s' '{"ok":false,"code":"target-mismatch","message":"authored digest refusal"}'; exit 0;; esac; ${denied}`;
const standInSchema = z.object({
kind: z.enum(["launcher", "fixture"]),
pid: z.number().int().positive(),
});
const runVerifier = async (helper: string, flags: readonly string[]) => {
const workspace = await createTestTempDirectory("rea-native-ui-lane-");
const paths = {
events: join(workspace, "events.jsonl"),
release: join(workspace, "release"),
launcherEntry: join(workspace, "launcher.mjs"),
};
await writeFile(paths.events, "");
await writeFile(paths.launcherEntry, launcherSource);
await writeFile(join(workspace, "fixture.mjs"), fixtureSource);
await writeFile(join(workspace, "preload.mjs"), preloadSource);
await writeFile(
join(workspace, "config.json"),
JSON.stringify({ ...paths, helper }),
);
const child = spawn(
process.execPath,
[
"--import",
pathToFileURL(join(workspace, "preload.mjs")).href,
resolve("scripts/verify-native-ui.mjs"),
...flags,
],
{
env: { ...process.env, TMPDIR: workspace },
stdio: ["ignore", "pipe", "pipe"],
},
);
const standIns = async () =>
(await readFile(paths.events, "utf8"))
.split("\n")
.filter((line) => line.length > 0)
.map((line) => standInSchema.parse(JSON.parse(line)));
const isLive = async (pid: number) =>
liveProcesses(await systemProcessOwnershipHost.listProcesses()).some(
(process) => process.pid === pid,
);
const untilGone = async (pid: number) => {
const deadline = performance.now() + 15_000;
while ((await isLive(pid)) && performance.now() < deadline) await delay(25);
return !(await isLive(pid));
};
// Runs on a timeout or failed assertion too: the verifier cannot release a stand-in
// once killed, and its fixture would otherwise outlive the test until it expired.
onTestFinished(async () => {
child.kill("SIGKILL");
await writeFile(paths.release, "");
for (const { pid } of await standIns())
expect(await untilGone(pid)).toBe(true);
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf8");
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
});
const code = await new Promise<number | null>((settle) => {
child.once("close", settle);
});
const fixture = (await standIns()).find(({ kind }) => kind === "fixture");
expect(fixture, stderr).toBeDefined();
return {
code,
stdout,
stderr,
fixtureLive: !(await untilGone(fixture?.pid ?? -1)),
roots: (await readdir(workspace)).filter((name) =>
name.startsWith("rea-ui-fixture-"),
),
};
};
// The verifier refuses other hosts before any fixture is created.
const macosIt = it.skipIf(process.platform !== "darwin");
macosIt(
"completes at the permission boundary when the helper refuses changed target bytes",
{ timeout: 30_000 },
async () => {
const result = await runVerifier(answersChangedBytes, [
"--allow-permission-denial",
]);
expect(result.code, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({
ok: true,
verification_status: "permission-boundary-only",
positive_e2e: false,
mismatch_rejected: true,
});
expect(result.fixtureLive).toBe(false);
expect(result.roots).toEqual([]);
},
);
macosIt(
"fails when the helper does not refuse changed target bytes",
{ timeout: 30_000 },
async () => {
const result = await runVerifier(denied, ["--allow-permission-denial"]);
expect(result.code).toBe(1);
expect(result.stderr).toContain(
"Real helper did not reject changed target bytes",
);
expect(result.stdout).toBe("");
expect(result.fixtureLive).toBe(false);
expect(result.roots).toEqual([]);
},
);
macosIt(
"keeps a permission denial a failure in the positive lane",
{ timeout: 30_000 },
async () => {
const result = await runVerifier(answersChangedBytes, []);
expect(result.code).toBe(1);
expect(result.stderr).toContain("requires successful capture");
expect(result.stdout).toBe("");
expect(result.fixtureLive).toBe(false);
expect(result.roots).toEqual([]);
},
);