fix(native)!: retain UI outcomes through finalization

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
This commit is contained in:
morluto committed 2026-10-17 17:57:50 +08:00
1 parent 04221e5f01
commit d9099190ab
6 files changed
+609 -83

No files matched your search

+5 -1
View File
@@ -425,7 +425,11 @@ decrement scrolling and text-value entry, or bounded waits. No global event
injection is used. Unsupported AX actions fail explicitly. The result preserves
ordered before/after captures and gaps; an action may have occurred before a
post-action capture fails. Application state is left as-is; REA does not attempt
to restore it.
to restore it. Failed or cancelled scenarios return a typed error with the
collected report in `partial_observation`, including ordered step outcomes.
Cancellation preserves the caller's reason and remains observable through helper
cleanup. A later abort does not replace an earlier helper or output failure.
Cleanup failures retain their runtime owner until a confirmed cleanup retry.
Scenarios accept caller-selected action lists and accessibility node counts.
Individual waits cannot exceed the operation's 180-second deadline, and the
+15 -2
View File
@@ -11,7 +11,10 @@ import {
nativeMacOSCapabilities,
} from "./NativeMacOSProviderMetadata.js";
import { inspectAppleDispatchMetadata } from "./AppleDispatchMetadata.js";
import { observeNativeUi } from "./NativeUiObservation.js";
import {
observeNativeUi,
finishNativeUiObservation,
} from "./NativeUiObservation.js";
import { NativeUiCleanupError } from "./NativeUiCleanupError.js";
import { LldbCallTracer, type NativeCallTracer } from "./LldbCallTracer.js";
import { observeNativeCalls } from "./NativeCallObservation.js";
@@ -359,12 +362,16 @@ class NativeMacOSClient implements AnalysisClient {
this.#nativeUiCleanupFailure(earlierCleanupFailures, operation),
);
const runtime = createNativeUiHelperRuntime(this.environment);
const observationSignal =
signal === undefined
? runtime.signal
: AbortSignal.any([signal, runtime.signal]);
this.#pendingNativeUiRuntimes.add(runtime);
let result: Awaited<ReturnType<typeof observeNativeUi>>;
try {
result = await observeNativeUi(this.target, operation, parameters, {
environment: this.environment,
signal,
signal: observationSignal,
runtime,
});
} catch (cause: unknown) {
@@ -378,6 +385,11 @@ class NativeMacOSClient implements AnalysisClient {
await runtime.close();
this.#pendingNativeUiRuntimes.delete(runtime);
} catch (cause: unknown) {
result = finishNativeUiObservation(
result,
operation,
observationSignal,
);
const cleanup = this.#nativeUiCleanupFailure(
[{ runtime, cause }],
operation,
@@ -393,6 +405,7 @@ class NativeMacOSClient implements AnalysisClient {
).bindCleanupDependencies([result.error, cleanup]),
);
}
result = finishNativeUiObservation(result, operation, observationSignal);
return result.ok
? ok(
createAnalysisExecution(result.value, IDENTITY, {
+35 -19
View File
@@ -227,12 +227,14 @@ describe("native UI capture selection and budgets", () => {
},
},
);
expect(result.ok).toBe(true);
if (result.ok)
expect(result.value.steps[0]).toMatchObject({
kind: "wait",
outcome: "cancelled",
});
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 = {
@@ -251,12 +253,18 @@ describe("native UI capture selection and budgets", () => {
invoke: async () => ({ ok: true, result: largeSnapshot }),
},
);
expect(result.ok).toBe(true);
if (result.ok)
expect(result.value.steps[0]).toMatchObject({
outcome: "failed",
reason: expect.stringContaining("64 MiB output budget"),
});
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"),
},
],
});
});
});
@@ -327,11 +335,13 @@ describe("native UI target and cancellation failures", () => {
launch_time: 1,
action: { kind: "scroll", path: [1] },
});
expect(result.ok).toBe(true);
if (result.ok)
expect(result.value.steps).toMatchObject([
{ index: 0, outcome: "failed", before: snapshot, after: null },
]);
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();
@@ -357,8 +367,14 @@ describe("native UI target and cancellation failures", () => {
},
);
expect(calls).toBe(1);
expect(result.ok).toBe(true);
if (result.ok) expect(result.value.steps[0]?.outcome).toBe("cancelled");
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 () => ({
+107 -61
View File
@@ -1,3 +1,4 @@
import { waitForAbortableDelay } from "../process/ProviderDeadline.js";
import { fileURLToPath } from "node:url";
import { z } from "zod";
import {
@@ -16,7 +17,10 @@ import {
AnalysisInputError,
} from "../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { ExecFileOutputCancelledError } from "../process/ExecFileOutput.js";
import { NativeUiCleanupError } from "./NativeUiCleanupError.js";
import { err, ok, type Result } from "../domain/result.js";
import {
createNativeUiHelperRuntime,
@@ -30,6 +34,9 @@ import {
const helper = fileURLToPath(
new URL("../../bridge/native/ReaNativeUI.swift", import.meta.url),
);
const passiveInputSchema = nativeUiObservationInputSchema.transform(
(input) => ({ ...input, steps: [] }),
);
const responseSchema = z.discriminatedUnion("ok", [
z.strictObject({ ok: z.literal(true), result: nativeUiSnapshotSchema }),
z.strictObject({
@@ -58,25 +65,56 @@ export const observeNativeUi = async (
): Promise<Result<z.infer<typeof nativeUiResultSchema>, AnalysisError>> => {
const runtime =
options.runtime ?? createNativeUiHelperRuntime(options.environment);
const signal =
options.signal === undefined
? runtime.signal
: AbortSignal.any([options.signal, runtime.signal]);
let result: Result<z.infer<typeof nativeUiResultSchema>, AnalysisError>;
try {
return await observeWithHelper(target, operation, parameters, {
...options,
result = await observeWithHelper(target, operation, parameters, {
signal,
invoke: options.invoke ?? runtime.invoke,
});
} finally {
if (options.runtime === undefined) await runtime.close();
} catch (cause: unknown) {
result = err(
cause instanceof AnalysisError
? cause
: new ProviderAdapterError("native-macos", operation, { cause }),
);
}
if (options.runtime === undefined) {
try {
await runtime.close();
} catch (cause: unknown) {
result = finishNativeUiObservation(result, operation, signal);
const cleanup = new NativeUiCleanupError(
[{ runtime, cause }],
operation,
result.ok ? result.value : undefined,
);
return result.ok
? err(cleanup)
: err(
analysisErrorWithCleanupFailure(
result.error,
cleanup,
operation,
).bindCleanupDependencies([result.error, cleanup]),
);
}
}
return finishNativeUiObservation(result, operation, signal);
};
const observeWithHelper = async (
target: BinaryTarget,
operation: "observe_native_ui" | "capture_native_ui_scenario",
parameters: unknown,
options: { signal?: AbortSignal | undefined; invoke: NativeUiHelper },
options: { signal: AbortSignal; invoke: NativeUiHelper },
): Promise<Result<z.infer<typeof nativeUiResultSchema>, AnalysisError>> => {
const parsed = (
operation === "observe_native_ui"
? nativeUiObservationInputSchema
? passiveInputSchema
: nativeUiScenarioInputSchema
).safeParse(parameters);
if (!parsed.success)
@@ -96,21 +134,18 @@ const observeWithHelper = async (
cause: new Error("Enable screenshot or accessibility capture"),
}),
);
const steps =
operation === "capture_native_ui_scenario"
? nativeUiScenarioInputSchema.parse(parameters).steps
: [];
const signal =
options.signal === undefined
? AbortSignal.timeout(180_000)
: AbortSignal.any([options.signal, AbortSignal.timeout(180_000)]);
const steps = input.steps;
const signal = options.signal;
const invoke = options.invoke;
let launchTime: number | undefined;
let outputBytes = 0;
const take = async (
action?: unknown,
): Promise<Result<z.infer<typeof nativeUiSnapshotSchema>, AnalysisError>> => {
if (signal.aborted) return err(new AnalysisCancelledError(operation));
if (signal.aborted)
return err(
new AnalysisCancelledError(operation, { cause: signal.reason }),
);
try {
const response = responseSchema.parse(
await invoke(
@@ -163,10 +198,11 @@ const observeWithHelper = async (
),
);
return ok(response.result);
} catch (cause) {
} catch (cause: unknown) {
if (cause instanceof AnalysisError) return err(cause);
return err(
signal.aborted
? new AnalysisCancelledError(operation)
cause instanceof ExecFileOutputCancelledError
? new AnalysisCancelledError(operation, { cause: cause.cause })
: new ProviderAdapterError("native-macos", operation, {
cause,
diagnostics: {
@@ -183,8 +219,12 @@ const observeWithHelper = async (
if (!initial.ok) return initial;
const results: z.infer<typeof nativeUiResultSchema>["steps"] = [];
let before = initial.value;
let terminalFailure: AnalysisError | undefined;
for (const [index, step] of steps.entries()) {
if (signal.aborted) {
terminalFailure = new AnalysisCancelledError(operation, {
cause: signal.reason,
});
results.push({
index,
kind: step.kind,
@@ -195,36 +235,26 @@ const observeWithHelper = async (
});
break;
}
if (step.kind === "wait") {
try {
await new Promise<void>((resolve, reject) => {
const abort = () => {
clearTimeout(timer);
reject(new Error("cancelled"));
};
const timer = setTimeout(() => {
signal.removeEventListener("abort", abort);
resolve();
}, step.milliseconds);
signal.addEventListener("abort", abort, { once: true });
if (signal.aborted) abort();
});
} catch (cause: unknown) {
// Cancellation is recorded as a cancelled outcome.
void cause;
results.push({
index,
kind: step.kind,
before,
after: null,
outcome: "cancelled",
reason: "Scenario cancelled during wait",
});
break;
}
if (
step.kind === "wait" &&
(await waitForAbortableDelay(step.milliseconds, signal)) === "aborted"
) {
terminalFailure = new AnalysisCancelledError(operation, {
cause: signal.reason,
});
results.push({
index,
kind: step.kind,
before,
after: null,
outcome: "cancelled",
reason: "Scenario cancelled during wait",
});
break;
}
const after = await take(step.kind === "wait" ? undefined : step);
if (!after.ok) {
terminalFailure = after.error;
results.push({
index,
kind: step.kind,
@@ -248,19 +278,35 @@ const observeWithHelper = async (
});
before = after.value;
}
return ok(
nativeUiResultSchema.parse({
target_sha256: target.sha256,
initial: initial.value,
steps: results,
restore: "leave-as-is",
limitations: [
"Only the explicitly selected already-running process/window is admitted. REA starts no target process and does not change permissions, foreground applications, or restore application data.",
"Click uses AXPress; scroll uses AXIncrement/AXDecrement; key-entry sets the selected element's AXValue. Unsupported elements fail without global event fallback.",
"Each completed action is followed immediately by capture; delayed UI changes require explicit wait steps. A failed after-capture can follow a completed action, so failure does not prove absence of application effects.",
"Scenarios can change app data, cause app network activity and persist changes. The caller explicitly chooses leave-as-is; automatic restoration is unsupported.",
"Window matching uses exact PID and window ID for screenshots and unique accessibility geometry. Accessibility paths can change as the UI changes; stale or ambiguous paths fail. Screenshots are scaled to at most 2048 pixels; scenario output is budgeted at 64 MiB and execution is cancelled after 180 seconds.",
],
}),
);
const report = nativeUiResultSchema.parse({
target_sha256: target.sha256,
initial: initial.value,
steps: results,
restore: "leave-as-is",
limitations: [
"Only the explicitly selected already-running process/window is admitted. REA starts no target process and does not change permissions, foreground applications, or restore application data.",
"Click uses AXPress; scroll uses AXIncrement/AXDecrement; key-entry sets the selected element's AXValue. Unsupported elements fail without global event fallback.",
"Each completed action is followed immediately by capture; delayed UI changes require explicit wait steps. A failed after-capture can follow a completed action, so failure does not prove absence of application effects.",
"Scenarios can change app data, cause app network activity and persist changes. The caller explicitly chooses leave-as-is; automatic restoration is unsupported.",
"Window matching uses exact PID and window ID for screenshots and unique accessibility geometry. Accessibility paths can change as the UI changes; stale or ambiguous paths fail. Screenshots are scaled to at most 2048 pixels; scenario output is budgeted at 64 MiB and execution is cancelled after 180 seconds.",
],
});
return terminalFailure === undefined
? finishNativeUiObservation(ok(report), operation, signal)
: err(terminalFailure.retainPartialObservation(report));
};
/** A validated report survives cancellation observed at its finalization boundary. */
export const finishNativeUiObservation = (
result: Result<z.infer<typeof nativeUiResultSchema>, AnalysisError>,
operation: string,
signal?: AbortSignal,
): Result<z.infer<typeof nativeUiResultSchema>, AnalysisError> =>
result.ok && signal?.aborted
? err(
new AnalysisCancelledError(operation, {
cause: signal.reason,
partialObservation: result.value,
}),
)
: result;
@@ -0,0 +1,112 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it, vi } from "vitest";
import { AnalysisCleanupLease } from "../../../src/artifacts/AnalysisCleanupLease.js";
import * as runtimes from "../../../src/native/NativeUiHelperRuntime.js";
import { NativeMacOSProvider } from "../../../src/native/NativeMacOSProvider.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { NativeFixtureRunner } from "../../fixtures/nativeCommands.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
it.each([false, true])(
"retains native UI reports over MCP when its deadline expires during close (cleanup fails: %s)",
async (fails) => {
const directory = await createTestTempDirectory("rea-native-ui-mcp-");
const path = join(directory, "Inert");
const header = Buffer.alloc(32);
header.writeUInt32LE(0xfeedfacf, 0);
header.writeUInt32LE(0x0100000c, 4);
header.writeUInt32LE(2, 12);
await writeFile(path, header);
const snapshot = {
window: {
pid: 123,
window_id: 456,
executable: path,
launch_time: 1,
title: "Fixture",
},
nodes: [],
screenshot: null,
gaps: [],
truncated: false,
};
const deadline = new AbortController();
let allowClose = !fails;
const cleanupLease = new AnalysisCleanupLease(async () => {
deadline.abort({ phase: "mcp-owned-finalization-deadline" });
if (!allowClose) throw new Error("controlled cleanup failure");
});
const runtime = {
signal: deadline.signal,
cleanupLease,
invoke: async () => ({ ok: true, result: snapshot }),
close: () => cleanupLease.release(),
resources: () => (cleanupLease.released ? [] : ["controlled-ui-runtime"]),
};
const factory = vi
.spyOn(runtimes, "createNativeUiHelperRuntime")
.mockReturnValue(runtime);
const session = createTestBinarySession(
new NativeMacOSProvider({}, new NativeFixtureRunner(), "darwin"),
);
const server = createServer({ kind: "session", session });
const client = new Client({ name: "native-ui-finalization", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const opened = await client.callTool({
name: "open_binary",
arguments: { path },
});
expect(opened.isError).not.toBe(true);
const result = await client.callTool({
name: "observe_native_ui",
arguments: {
pid: 123,
window_id: 456,
screenshot: false,
accessibility: true,
},
});
expect(parseMcpToolError(result)).toMatchObject({
error: {
code: fails ? "cleanup_incomplete" : "cancelled",
details: {
cleanup: fails ? "incomplete" : "complete",
...(fails
? {
execution_failure: "execution_failure",
diagnostics: { primary_error: { code: "cancelled" } },
resources: ["controlled-ui-runtime"],
}
: {}),
partial_observation: {
initial: snapshot,
steps: [],
restore: "leave-as-is",
},
},
},
});
allowClose = true;
const closed = await client.callTool({
name: "close_binary",
arguments: {},
});
expect(closed.isError).not.toBe(true);
expect(cleanupLease.released).toBe(true);
} finally {
allowClose = true;
await session.close();
await client.close();
await server.close();
factory.mockRestore();
}
},
);
@@ -0,0 +1,335 @@
import { AnalysisCleanupLease } from "../../../../src/artifacts/AnalysisCleanupLease.js";
import { expect, it, vi, onTestFinished } from "vitest";
import * as runtimes from "../../../../src/native/NativeUiHelperRuntime.js";
import { observeNativeUi } from "../../../../src/native/NativeUiObservation.js";
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
import {
NativeFixtureRunner,
nativeMachoTarget,
} from "../../../fixtures/nativeCommands.js";
import { ExecFileOutputCancelledError } from "../../../../src/process/ExecFileOutput.js";
import { resolvedAnalysisError } from "../../../../src/domain/analysisErrorBase.js";
const target = nativeMachoTarget("/fixture/App");
const input = {
pid: 123,
window_id: 456,
screenshot: false,
accessibility: true,
};
const snapshot = {
window: {
pid: 123,
window_id: 456,
executable: target.path,
launch_time: 1,
title: "Fixture",
},
nodes: [],
screenshot: null,
gaps: [],
truncated: false,
};
const controlledRuntime = (
cleanup: () => Promise<void>,
signal = new AbortController().signal,
) => {
const cleanupLease = new AnalysisCleanupLease(cleanup);
return {
cleanupLease,
signal,
invoke: vi.fn(async () => ({ ok: true, result: snapshot })),
close: vi.fn(() => cleanupLease.release()),
resources: () => (cleanupLease.released ? [] : ["controlled-ui-runtime"]),
};
};
it("preserves the exact native UI admission cancellation reason", async () => {
const controller = new AbortController();
const reason = { phase: "admission" };
controller.abort(reason);
const invoke = vi.fn(async () => ({ ok: true, result: snapshot }));
const result = await observeNativeUi(target, "observe_native_ui", input, {
environment: {},
signal: controller.signal,
invoke,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
expect(invoke).not.toHaveBeenCalled();
});
it("uses the command owner's cancellation fact without guessing from ambient signal", async () => {
const reason = { phase: "command-owner" };
const result = await observeNativeUi(target, "observe_native_ui", input, {
environment: {},
invoke: async () => {
throw new ExecFileOutputCancelledError(reason);
},
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected command cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
});
it("preserves malformed helper output when a later abort is already visible", async () => {
const controller = new AbortController();
const result = await observeNativeUi(target, "observe_native_ui", input, {
environment: {},
signal: controller.signal,
invoke: async () => {
controller.abort({ phase: "late-output" });
return { ok: true, result: {} };
},
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected invalid output");
expect(result.error._tag).toBe("ProviderAdapterError");
expect(result.error.cause).toMatchObject({ name: "ZodError" });
});
it("retains native UI facts and caller reason when cancellation arrives during final cleanup", async () => {
const controller = new AbortController();
const reason = { phase: "runtime-close" };
const runtime = controlledRuntime(async () => {
controller.abort(reason);
});
const factory = vi
.spyOn(runtimes, "createNativeUiHelperRuntime")
.mockReturnValue(runtime);
const client = new NativeMacOSProvider(
{},
new NativeFixtureRunner(),
"darwin",
).createClient(target);
onTestFinished(async () => {
factory.mockRestore();
await client.close();
});
const result = await client.execute("observe_native_ui", input, {
signal: controller.signal,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected finalization cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
expect(result.error.partialObservation).toMatchObject({
initial: snapshot,
steps: [],
});
expect(result.error.cleanupIncomplete).toBe(false);
expect(runtime.close).toHaveBeenCalledTimes(1);
});
it("resolves every native UI cleanup error only after its exact runtime releases", async () => {
let allowClose = false;
const runtime = controlledRuntime(async () => {
if (!allowClose) throw new Error("controlled native UI cleanup failure");
});
const factory = vi
.spyOn(runtimes, "createNativeUiHelperRuntime")
.mockReturnValue(runtime);
const client = new NativeMacOSProvider(
{},
new NativeFixtureRunner(),
"darwin",
).createClient(target);
onTestFinished(async () => {
allowClose = true;
await client.close();
factory.mockRestore();
});
const observed = await client.execute("observe_native_ui", input);
expect(observed.ok).toBe(false);
if (observed.ok) throw new Error("Expected cleanup failure");
expect(observed.error.cleanupIncomplete).toBe(true);
expect(observed.error.partialObservation).toMatchObject({
initial: snapshot,
});
const blocked = await client.execute("observe_native_ui", input);
expect(blocked.ok).toBe(false);
if (blocked.ok) throw new Error("Expected retained admission blocker");
expect(runtime.invoke).toHaveBeenCalledTimes(1);
const retry = await client.close();
expect(retry.ok).toBe(false);
allowClose = true;
expect((await client.close()).ok).toBe(true);
for (const error of [
observed.error,
blocked.error,
...(retry.ok ? [] : [retry.error]),
]) {
expect(error.cleanupIncomplete).toBe(false);
expect(error.cleanupResources).toEqual([]);
expect(resolvedAnalysisError(error).cleanupIncomplete).toBe(false);
}
});
it("retains a completed standalone report and its exact runtime after cleanup failure", async () => {
const controller = new AbortController();
const reason = { phase: "standalone-close" };
let allowClose = false;
const runtime = controlledRuntime(async () => {
controller.abort(reason);
if (!allowClose) throw new Error("controlled standalone close failure");
});
const factory = vi
.spyOn(runtimes, "createNativeUiHelperRuntime")
.mockReturnValue(runtime);
onTestFinished(async () => {
allowClose = true;
await runtime.close();
factory.mockRestore();
});
const result = await observeNativeUi(target, "observe_native_ui", input, {
environment: {},
signal: controller.signal,
});
expect(result.ok).toBe(false);
if (result.ok)
throw new Error("Expected standalone cancellation and cleanup failure");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toEqual(["controlled-ui-runtime"]);
expect(result.error.partialObservation).toMatchObject({ initial: snapshot });
expect(result.error.cleanupPrimary?._tag).toBe("AnalysisCancelledError");
expect(result.error.cleanupPrimary?.cause).toBe(reason);
allowClose = true;
await runtime.close();
expect(result.error.cleanupIncomplete).toBe(false);
expect(resolvedAnalysisError(result.error).cause).toBe(reason);
});
it("preserves a physical helper failure and all cleanup facts across a late abort", async () => {
const controller = new AbortController();
const failure = new Error("controlled physical helper failure");
let allowClose = false;
const runtime = controlledRuntime(async () => {
controller.abort({ phase: "late-failing-close" });
if (!allowClose) throw new Error("controlled cleanup failure");
});
runtime.invoke.mockImplementation(async () => {
throw failure;
});
const factory = vi
.spyOn(runtimes, "createNativeUiHelperRuntime")
.mockReturnValue(runtime);
const client = new NativeMacOSProvider(
{},
new NativeFixtureRunner(),
"darwin",
).createClient(target);
onTestFinished(async () => {
allowClose = true;
await client.close();
factory.mockRestore();
});
const result = await client.execute("observe_native_ui", input, {
signal: controller.signal,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected helper and cleanup failures");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupPrimary?._tag).toBe("ProviderAdapterError");
expect(result.error.cleanupPrimary?.cause).toBe(failure);
allowClose = true;
expect((await client.close()).ok).toBe(true);
expect(result.error.cleanupIncomplete).toBe(false);
expect(resolvedAnalysisError(result.error).cause).toBe(failure);
});
it.each([false, true])(
"keeps the runtime deadline through final cleanup (close fails: %s)",
async (fails) => {
const deadline = new AbortController();
const reason = { phase: "owned-deadline-close" };
let allowClose = !fails;
const runtime = controlledRuntime(async () => {
deadline.abort(reason);
if (!allowClose) throw new Error("controlled deadline close failure");
}, deadline.signal);
const factory = vi
.spyOn(runtimes, "createNativeUiHelperRuntime")
.mockReturnValue(runtime);
const client = new NativeMacOSProvider(
{},
new NativeFixtureRunner(),
"darwin",
).createClient(target);
onTestFinished(async () => {
allowClose = true;
await client.close();
factory.mockRestore();
});
const result = await client.execute("observe_native_ui", input);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected owned deadline cancellation");
const primary = resolvedAnalysisError(
result.error.cleanupPrimary ?? result.error,
);
expect(primary._tag).toBe("AnalysisCancelledError");
expect(primary.cause).toBe(reason);
expect(result.error.partialObservation).toMatchObject({
initial: snapshot,
});
expect(result.error.cleanupIncomplete).toBe(fails);
allowClose = true;
expect((await client.close()).ok).toBe(true);
expect(result.error.cleanupIncomplete).toBe(false);
},
);
it.each(["physical", "cancelled"] as const)(
"preserves the scenario step's %s outcome through report finalization",
async (outcome) => {
const controller = new AbortController();
const reason = { phase: "scenario-command-owner" };
const failure = new Error("controlled action capture failure");
let calls = 0;
const result = await observeNativeUi(
target,
"capture_native_ui_scenario",
{
...input,
steps: [
{ kind: "click", path: [] },
{ kind: "click", path: [1] },
],
},
{
environment: {},
signal: controller.signal,
invoke: async () => {
if (++calls === 1) return { ok: true, result: snapshot };
if (outcome === "cancelled")
throw new ExecFileOutputCancelledError(reason);
controller.abort({ phase: "later-caller-abort" });
throw failure;
},
},
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected typed scenario failure");
expect(result.error._tag).toBe(
outcome === "cancelled"
? "AnalysisCancelledError"
: "ProviderAdapterError",
);
expect(result.error.cause).toBe(outcome === "cancelled" ? reason : failure);
expect(result.error.partialObservation).toMatchObject({
initial: snapshot,
steps: [
{
index: 0,
before: snapshot,
after: null,
outcome: outcome === "cancelled" ? "cancelled" : "failed",
},
],
});
expect(calls).toBe(2);
},
);