From d9099190ab3a819bd3a9fd62a8714cef398016d5 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:57:10 +0800 Subject: [PATCH] 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 --- docs/native-investigation.md | 6 +- src/native/NativeMacOSProvider.ts | 17 +- src/native/NativeUiObservation.test.ts | 54 ++- src/native/NativeUiObservation.ts | 168 +++++---- .../mcp/nativeUiFinalizationMcp.test.ts | 112 ++++++ .../native/nativeUiFinalization.test.ts | 335 ++++++++++++++++++ 6 files changed, 609 insertions(+), 83 deletions(-) create mode 100644 tests/boundary/mcp/nativeUiFinalizationMcp.test.ts create mode 100644 tests/boundary/providers/native/nativeUiFinalization.test.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index e7fdd3f16c..5f533d4cb6 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -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 diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 19f2fed84e..341e6c77d0 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -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>; 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, { diff --git a/src/native/NativeUiObservation.test.ts b/src/native/NativeUiObservation.test.ts index 0d02693d87..36a0a6974b 100644 --- a/src/native/NativeUiObservation.test.ts +++ b/src/native/NativeUiObservation.test.ts @@ -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 () => ({ diff --git a/src/native/NativeUiObservation.ts b/src/native/NativeUiObservation.ts index 1256f59494..ef5ad94b14 100644 --- a/src/native/NativeUiObservation.ts +++ b/src/native/NativeUiObservation.ts @@ -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, AnalysisError>> => { const runtime = options.runtime ?? createNativeUiHelperRuntime(options.environment); + const signal = + options.signal === undefined + ? runtime.signal + : AbortSignal.any([options.signal, runtime.signal]); + let result: Result, 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, 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, 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["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((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, AnalysisError>, + operation: string, + signal?: AbortSignal, +): Result, AnalysisError> => + result.ok && signal?.aborted + ? err( + new AnalysisCancelledError(operation, { + cause: signal.reason, + partialObservation: result.value, + }), + ) + : result; diff --git a/tests/boundary/mcp/nativeUiFinalizationMcp.test.ts b/tests/boundary/mcp/nativeUiFinalizationMcp.test.ts new file mode 100644 index 0000000000..51c7c587bd --- /dev/null +++ b/tests/boundary/mcp/nativeUiFinalizationMcp.test.ts @@ -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(); + } + }, +); diff --git a/tests/boundary/providers/native/nativeUiFinalization.test.ts b/tests/boundary/providers/native/nativeUiFinalization.test.ts new file mode 100644 index 0000000000..2117eb1925 --- /dev/null +++ b/tests/boundary/providers/native/nativeUiFinalization.test.ts @@ -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, + 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); + }, +);