mirror of
https://github.com/morluto/rea
synced 2026-10-28 15:07:26 -04:00
d9099190ab
Carry caller and runtime deadline cancellation through helper cleanup, preserve command-owner reasons and physical failures, and retain collected scenario reports with typed terminal errors. BREAKING CHANGE: failed or cancelled native UI scenarios now return typed errors with the collected report in partial_observation instead of successful reports containing failed or cancelled steps. Refs #4227
388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import { observeNativeUi } from "./NativeUiObservation.js";
|
|
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
|
|
|
const target: BinaryTarget = {
|
|
path: "/fixture/App",
|
|
sha256: "a".repeat(64),
|
|
kind: "executable",
|
|
format: "mach-o",
|
|
architecture: "arm64",
|
|
availableArchitectures: ["arm64"],
|
|
};
|
|
const scope = {
|
|
pid: 123,
|
|
window_id: 456,
|
|
screenshot: false,
|
|
};
|
|
const snapshot = {
|
|
window: {
|
|
pid: 123,
|
|
window_id: 456,
|
|
executable: target.path,
|
|
launch_time: 1,
|
|
title: "Fixture",
|
|
},
|
|
nodes: [],
|
|
screenshot: null,
|
|
gaps: [],
|
|
truncated: false,
|
|
};
|
|
|
|
const validPng = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jv4sAAAAASUVORK5CYII=",
|
|
"base64",
|
|
);
|
|
|
|
/** Screenshot payload as the native helper reports one PNG buffer. */
|
|
const screenshotOf = (bytes: Buffer) => ({
|
|
mime_type: "image/png",
|
|
base64: bytes.toString("base64"),
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
width: bytes.readUInt32BE(16),
|
|
height: bytes.readUInt32BE(20),
|
|
});
|
|
|
|
/** Observe the fixture snapshot carrying one screenshot payload. */
|
|
const observeWithScreenshot = (screenshot: Record<string, unknown>) =>
|
|
observeNativeUi(
|
|
target,
|
|
"observe_native_ui",
|
|
{ ...scope, screenshot: true },
|
|
{
|
|
environment: {},
|
|
invoke: async () => ({ ok: true, result: { ...snapshot, screenshot } }),
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ offset: 8, value: 12 },
|
|
{ offset: 16, value: 0x8000_0000 },
|
|
{ offset: 20, value: 0x8000_0000 },
|
|
])(
|
|
"rejects invalid IHDR metadata with a matching digest",
|
|
async ({ offset, value }) => {
|
|
const bytes = Buffer.from(validPng);
|
|
bytes.writeUInt32BE(value, offset);
|
|
const result = await observeWithScreenshot(screenshotOf(bytes));
|
|
expect(result.ok).toBe(false);
|
|
},
|
|
);
|
|
describe("native UI screenshot validation", () => {
|
|
it("rejects malformed or mismatched screenshot bytes from the helper", async () => {
|
|
const result = await observeWithScreenshot({
|
|
...screenshotOf(validPng),
|
|
sha256: "0".repeat(64),
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
{ base64: "%%%=" },
|
|
{ base64: Buffer.from("not a PNG").toString("base64") },
|
|
{ width: 2 },
|
|
])(
|
|
"rejects invalid screenshot field variants from the helper",
|
|
async (change) => {
|
|
const result = await observeWithScreenshot({
|
|
...screenshotOf(validPng),
|
|
...change,
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
},
|
|
);
|
|
|
|
it("rejects a PNG whose IHDR type only matches after ASCII folding", async () => {
|
|
const bytes = Buffer.from(validPng);
|
|
const type = bytes.subarray(12, 16);
|
|
type[0] = (type[0] ?? 0) | 0x80;
|
|
expect(type.toString("ascii")).toBe("IHDR");
|
|
const result = await observeWithScreenshot(screenshotOf(bytes));
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it("accepts a self-consistent PNG screenshot from the helper", async () => {
|
|
const result = await observeWithScreenshot(screenshotOf(validPng));
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
it("preserves an unknown AX child count and its truncation gap", async () => {
|
|
const partial = {
|
|
...snapshot,
|
|
nodes: [
|
|
{
|
|
path: [],
|
|
role: "AXWindow",
|
|
title: "Fixture",
|
|
value: null,
|
|
actions: [],
|
|
children_count: null,
|
|
},
|
|
],
|
|
truncated: true,
|
|
gaps: ["AX child count unavailable at path []: AXError -25204"],
|
|
};
|
|
const result = await observeNativeUi(
|
|
target,
|
|
"observe_native_ui",
|
|
{ ...scope, accessibility: true },
|
|
{ environment: {}, invoke: async () => ({ ok: true, result: partial }) },
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok)
|
|
expect(result.value.initial).toMatchObject({
|
|
truncated: true,
|
|
gaps: ["AX child count unavailable at path []: AXError -25204"],
|
|
nodes: [{ children_count: null }],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("native UI capture selection and budgets", () => {
|
|
it("accepts observations and scenarios without compatibility flags", async () => {
|
|
let calls = 0;
|
|
const invoke = async () => {
|
|
calls++;
|
|
return { ok: true, result: snapshot };
|
|
};
|
|
const observed = await observeNativeUi(
|
|
target,
|
|
"observe_native_ui",
|
|
{ pid: 123, window_id: 456, screenshot: false, accessibility: true },
|
|
{ environment: {}, invoke },
|
|
);
|
|
expect(observed.ok).toBe(true);
|
|
const captured = await observeNativeUi(
|
|
target,
|
|
"capture_native_ui_scenario",
|
|
{
|
|
pid: 123,
|
|
window_id: 456,
|
|
screenshot: false,
|
|
accessibility: true,
|
|
steps: [{ kind: "click", path: [] }],
|
|
},
|
|
{ environment: {}, invoke },
|
|
);
|
|
expect(captured.ok).toBe(true);
|
|
expect(calls).toBe(3);
|
|
});
|
|
it("returns caller-selected large observations and complete action lists", async () => {
|
|
const nodeLimits: unknown[] = [];
|
|
const steps = Array.from({ length: 17 }, () => ({
|
|
kind: "wait" as const,
|
|
milliseconds: 0,
|
|
}));
|
|
const result = await observeNativeUi(
|
|
target,
|
|
"capture_native_ui_scenario",
|
|
{
|
|
pid: 123,
|
|
window_id: 456,
|
|
screenshot: false,
|
|
accessibility: true,
|
|
max_nodes: 2_001,
|
|
steps,
|
|
},
|
|
{
|
|
environment: {},
|
|
invoke: async (parameters) => {
|
|
nodeLimits.push(parameters.max_nodes);
|
|
return { ok: true, result: snapshot };
|
|
},
|
|
},
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
expect(nodeLimits).toEqual(Array.from({ length: 18 }, () => 2_001));
|
|
if (result.ok) {
|
|
expect(result.value.steps).toHaveLength(17);
|
|
expect(
|
|
result.value.steps.every(({ outcome }) => outcome === "completed"),
|
|
).toBe(true);
|
|
}
|
|
});
|
|
it("accepts caller-selected waits beyond the old aggregate cutoff", async () => {
|
|
const controller = new AbortController();
|
|
const result = await observeNativeUi(
|
|
target,
|
|
"capture_native_ui_scenario",
|
|
{
|
|
pid: 123,
|
|
window_id: 456,
|
|
screenshot: false,
|
|
accessibility: true,
|
|
steps: Array.from({ length: 4 }, () => ({
|
|
kind: "wait" as const,
|
|
milliseconds: 10_001,
|
|
})),
|
|
},
|
|
{
|
|
environment: {},
|
|
signal: controller.signal,
|
|
invoke: async () => {
|
|
controller.abort();
|
|
return { ok: true, result: snapshot };
|
|
},
|
|
},
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("Expected scenario cancellation");
|
|
expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
expect(result.error.cause).toBe(controller.signal.reason);
|
|
expect(result.error.partialObservation).toMatchObject({
|
|
initial: snapshot,
|
|
steps: [{ kind: "wait", outcome: "cancelled" }],
|
|
});
|
|
});
|
|
it("reports aggregate output exhaustion after individually valid captures", async () => {
|
|
const largeSnapshot = {
|
|
...snapshot,
|
|
window: { ...snapshot.window, title: "x".repeat(17 * 1024 * 1024) },
|
|
};
|
|
const result = await observeNativeUi(
|
|
target,
|
|
"capture_native_ui_scenario",
|
|
{
|
|
...scope,
|
|
steps: [{ kind: "wait", milliseconds: 0 }],
|
|
},
|
|
{
|
|
environment: {},
|
|
invoke: async () => ({ ok: true, result: largeSnapshot }),
|
|
},
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("Expected scenario output limit failure");
|
|
expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
|
expect(result.error.partialObservation).toMatchObject({
|
|
initial: largeSnapshot,
|
|
steps: [
|
|
{
|
|
outcome: "failed",
|
|
reason: expect.stringContaining("64 MiB output budget"),
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("native UI target and cancellation failures", () => {
|
|
it.each([
|
|
"accessibility-denied",
|
|
"screen-recording-denied",
|
|
"ambiguous-window",
|
|
])("preserves actionable %s without fallback", async (code) => {
|
|
const result = await observeNativeUi(target, "observe_native_ui", scope, {
|
|
environment: {},
|
|
invoke: async () => ({
|
|
ok: false,
|
|
code,
|
|
message: "Grant permission or select an unambiguous window",
|
|
}),
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error.message).toContain(code);
|
|
});
|
|
it("preserves the underlying helper failure reason in diagnostics", async () => {
|
|
const result = await observeNativeUi(target, "observe_native_ui", scope, {
|
|
environment: {},
|
|
invoke: async () => {
|
|
throw new Error(
|
|
"Native helper returned invalid JSON: Unexpected token",
|
|
);
|
|
},
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.error).toMatchObject({
|
|
diagnostics: {
|
|
reason: "Native helper returned invalid JSON: Unexpected token",
|
|
remediation:
|
|
"Native helper failed, timed out, or returned malformed capture data; install compatible Xcode command-line tools and inspect local OS permissions",
|
|
},
|
|
});
|
|
});
|
|
it("stops on a failed action and returns ordered before/capture-gap evidence", async () => {
|
|
const calls: Readonly<Record<string, unknown>>[] = [];
|
|
const result = await observeNativeUi(
|
|
target,
|
|
"capture_native_ui_scenario",
|
|
{
|
|
...scope,
|
|
steps: [
|
|
{ kind: "scroll", path: [1], direction: "increment" },
|
|
{ kind: "click", path: [2] },
|
|
],
|
|
},
|
|
{
|
|
environment: {},
|
|
invoke: async (parameters) => {
|
|
calls.push(parameters);
|
|
return calls.length === 1
|
|
? { ok: true, result: snapshot }
|
|
: {
|
|
ok: false,
|
|
code: "action-failed",
|
|
message: "AXIncrement unsupported",
|
|
};
|
|
},
|
|
},
|
|
);
|
|
expect(calls).toHaveLength(2);
|
|
expect(calls[1]).toMatchObject({
|
|
launch_time: 1,
|
|
action: { kind: "scroll", path: [1] },
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("Expected failed action");
|
|
expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
|
expect(result.error.partialObservation).toMatchObject({
|
|
initial: snapshot,
|
|
steps: [{ index: 0, outcome: "failed", before: snapshot, after: null }],
|
|
});
|
|
});
|
|
it("rejects target replacement and stops subsequent actions after cancellation", async () => {
|
|
const controller = new AbortController();
|
|
let calls = 0;
|
|
const result = await observeNativeUi(
|
|
target,
|
|
"capture_native_ui_scenario",
|
|
{
|
|
...scope,
|
|
steps: [
|
|
{ kind: "wait", milliseconds: 1000 },
|
|
{ kind: "click", path: [] },
|
|
],
|
|
},
|
|
{
|
|
environment: {},
|
|
signal: controller.signal,
|
|
invoke: async () => {
|
|
calls++;
|
|
controller.abort();
|
|
return { ok: true, result: snapshot };
|
|
},
|
|
},
|
|
);
|
|
expect(calls).toBe(1);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("Expected scenario cancellation");
|
|
expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
expect(result.error.cause).toBe(controller.signal.reason);
|
|
expect(result.error.partialObservation).toMatchObject({
|
|
initial: snapshot,
|
|
steps: [{ outcome: "cancelled" }],
|
|
});
|
|
const replaced = await observeNativeUi(target, "observe_native_ui", scope, {
|
|
environment: {},
|
|
invoke: async () => ({
|
|
ok: true,
|
|
result: { ...snapshot, window: { ...snapshot.window, pid: 999 } },
|
|
}),
|
|
});
|
|
expect(replaced.ok).toBe(false);
|
|
});
|
|
});
|