mirror of
https://github.com/morluto/rea
synced 2026-10-27 06:33:32 -04:00
36d156ff05
Record caller cancellation versus the owned deadline once and share that provenance through helper execution, scenario waits and finalization. Preserve independent failures and partial reports, consolidate scenario failure handling and dispose listeners on every exit. Fixes #5056
336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
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 timeout");
|
|
const primary = resolvedAnalysisError(
|
|
result.error.cleanupPrimary ?? result.error,
|
|
);
|
|
expect(primary._tag).toBe("AnalysisTimeoutError");
|
|
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);
|
|
},
|
|
);
|