mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
04221e5f01
commit
d9099190ab
6 files changed
+609
-83
No files matched your search
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user