mirror of
https://github.com/morluto/rea.git
synced 2026-11-06 23:06:18 +00:00
Fixes #4388. Refs #4027. Observe cancellation during first-window waits and state reads; preserve cancellation and caller reasons before cleanup and retain partial observations when cleanup fails. Keep late cancellation and cleanup failure precedence. Validation: 49 focused cases, static/docs checks, six controlled verifier-oracle cases and 39 current-head CI checks passed. Codex and Greptile found no remaining blocking issues. Real Joplin cancellation phases verified; complete Linux cleanup/first-signal CLI exit and packaged-runtime startup remain explicitly unverified/failed. Both verifiers now fail incomplete cleanup, including after main-process exit. Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com>
556 lines
16 KiB
TypeScript
556 lines
16 KiB
TypeScript
// Fixture-backed action coverage; real Electron verification lives in
|
|
// `npm run verify:electron`.
|
|
import { afterEach, expect, it, vi } from "vitest";
|
|
import type { ElectronApplication } from "playwright-core";
|
|
|
|
import {
|
|
electronActiveObservationInputSchema,
|
|
electronActiveObservationResultSchema,
|
|
} from "../domain/javascript/electronActiveObservation.js";
|
|
import { createElectronActiveObservationFixtureResult } from "../domain/javascript/electronActiveObservation.fixture.js";
|
|
import {
|
|
readFinalApplicationState,
|
|
runElectronActions,
|
|
runWithExecutionLimits,
|
|
} from "./PlaywrightElectronActiveActions.js";
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("requires observed Electron retention and coverage instead of repairing old results", () => {
|
|
const result = createElectronActiveObservationFixtureResult("/opt/app");
|
|
for (const field of ["retention", "timeline", "coverage"] as const) {
|
|
expect(
|
|
electronActiveObservationResultSchema.safeParse({
|
|
...result,
|
|
[field]: undefined,
|
|
}).success,
|
|
).toBe(false);
|
|
}
|
|
for (const section of ["ipc", "timeline"] as const) {
|
|
expect(
|
|
electronActiveObservationResultSchema.safeParse({
|
|
...result,
|
|
[section]: { ...result[section], dropped: undefined },
|
|
}).success,
|
|
).toBe(false);
|
|
}
|
|
expect(
|
|
electronActiveObservationResultSchema.safeParse({
|
|
...result,
|
|
retention: null,
|
|
}).success,
|
|
).toBe(false);
|
|
expect(
|
|
electronActiveObservationResultSchema.safeParse({
|
|
...result,
|
|
ipc: {
|
|
...result.ipc,
|
|
events: [{ ...result.ipc.events[0], source: undefined }],
|
|
},
|
|
}).success,
|
|
).toBe(false);
|
|
});
|
|
|
|
it("parses window, renderer, and deep-link actions for agents", () => {
|
|
const input = electronActiveObservationInputSchema.parse({
|
|
executable_path: "/opt/electron",
|
|
application_path: "/opt/app/main.js",
|
|
application_root: "/opt/app",
|
|
actions: [
|
|
{ step_id: "first-window", kind: "click", selector: "#run" },
|
|
{ step_id: "second-window", kind: "renderer-reload", window_index: 1 },
|
|
{ step_id: "restart", kind: "renderer-crash", window_index: 1 },
|
|
{
|
|
step_id: "deep-link",
|
|
kind: "deep-link",
|
|
delivery: "second-instance",
|
|
url: "rea-fixture://open/item",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(input.actions).toEqual([
|
|
{
|
|
step_id: "first-window",
|
|
kind: "click",
|
|
selector: "#run",
|
|
window_index: 0,
|
|
},
|
|
{ step_id: "second-window", kind: "renderer-reload", window_index: 1 },
|
|
{ step_id: "restart", kind: "renderer-crash", window_index: 1 },
|
|
{
|
|
step_id: "deep-link",
|
|
kind: "deep-link",
|
|
delivery: "second-instance",
|
|
url: "rea-fixture://open/item",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("rejects non-absolute deep-link values", () => {
|
|
const result = electronActiveObservationInputSchema.safeParse({
|
|
executable_path: "/opt/electron",
|
|
application_path: "/opt/app/main.js",
|
|
application_root: "/opt/app",
|
|
actions: [
|
|
{
|
|
step_id: "bad-link",
|
|
kind: "deep-link",
|
|
delivery: "open-url",
|
|
url: "not a URL",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it("keeps long Electron arguments and deep-link URLs intact", () => {
|
|
const longArgument = "x".repeat(5_000);
|
|
const longUrl = `rea-fixture://open/${"item".repeat(2_000)}`;
|
|
const longStepId = "step".repeat(100);
|
|
const input = electronActiveObservationInputSchema.parse({
|
|
executable_path: "/opt/electron",
|
|
application_path: "/opt/app/main.js",
|
|
application_root: "/opt/app",
|
|
args: [longArgument],
|
|
actions: [
|
|
{
|
|
step_id: longStepId,
|
|
kind: "deep-link",
|
|
delivery: "open-url",
|
|
url: longUrl,
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(input.args).toEqual([longArgument]);
|
|
expect(input.actions[0]).toMatchObject({
|
|
step_id: longStepId,
|
|
url: longUrl,
|
|
});
|
|
expect(
|
|
electronActiveObservationResultSchema.parse({
|
|
...createElectronActiveObservationFixtureResult("/opt/app"),
|
|
actions: [
|
|
{
|
|
step_id: longStepId,
|
|
kind: "deep-link",
|
|
window_index: null,
|
|
target: null,
|
|
status: "completed",
|
|
elapsed_ms: 0,
|
|
error: null,
|
|
},
|
|
],
|
|
}).actions[0]?.step_id,
|
|
).toBe(longStepId);
|
|
});
|
|
|
|
it("preserves local Playwright action diagnostics", async () => {
|
|
const selector = "#secret-selector";
|
|
const input = electronActiveObservationInputSchema.parse({
|
|
executable_path: "/opt/electron",
|
|
application_path: "/opt/app/main.js",
|
|
application_root: "/opt/app",
|
|
actions: [{ step_id: "click-step", kind: "click", selector }],
|
|
});
|
|
const page = {
|
|
locator: () => ({
|
|
click: async () => {
|
|
throw new Error(`locator ${selector} failed with token=raw-secret`);
|
|
},
|
|
}),
|
|
};
|
|
const application = {
|
|
windows: () => [page],
|
|
};
|
|
|
|
const result = await runElectronActions(application as never, input, {});
|
|
|
|
expect(result[0]).toMatchObject({
|
|
status: "failed",
|
|
error: `locator ${selector} failed with token=raw-secret`,
|
|
});
|
|
expect(result[0]?.error).toContain(selector);
|
|
expect(result[0]?.error).toContain("raw-secret");
|
|
});
|
|
|
|
it("retains complete action errors and Electron window text", async () => {
|
|
const longError = `locator failed ${"detail ".repeat(300)}`;
|
|
const input = electronActiveObservationInputSchema.parse({
|
|
executable_path: "/opt/electron",
|
|
application_path: "/opt/app/main.js",
|
|
application_root: "/opt/app",
|
|
actions: [{ step_id: "click-step", kind: "click", selector: "#run" }],
|
|
});
|
|
const page = {
|
|
locator: () => ({
|
|
click: async () => Promise.reject(new Error(longError)),
|
|
}),
|
|
};
|
|
const actions = await runElectronActions(
|
|
{ windows: () => [page] } as never,
|
|
input,
|
|
{},
|
|
);
|
|
const result = createElectronActiveObservationFixtureResult("/opt/app");
|
|
const longUrl = `file://${"/segment".repeat(10_000)}`;
|
|
const longTitle = "title".repeat(4_000);
|
|
|
|
expect(actions[0]?.error).toBe(longError);
|
|
expect(
|
|
electronActiveObservationResultSchema.parse({
|
|
...result,
|
|
windows: [{ ...result.windows[0], url: longUrl, title: longTitle }],
|
|
actions: [
|
|
{
|
|
step_id: "click-step",
|
|
kind: "click",
|
|
window_index: 0,
|
|
target: "window:0",
|
|
status: "failed",
|
|
elapsed_ms: 1,
|
|
error: longError,
|
|
},
|
|
],
|
|
}).windows[0],
|
|
).toMatchObject({ url: longUrl, title: longTitle });
|
|
});
|
|
|
|
it("accepts complete Electron input and observation strings", () => {
|
|
const longText = "evidence-".repeat(3_000);
|
|
const longPath = `/opt/${"directory/".repeat(2_000)}app`;
|
|
const input = electronActiveObservationInputSchema.parse({
|
|
executable_path: longPath,
|
|
application_path: `${longPath}/main.js`,
|
|
application_root: longPath,
|
|
actions: [
|
|
{ step_id: longText, kind: "click", selector: longText },
|
|
{
|
|
step_id: longText,
|
|
kind: "deep-link",
|
|
delivery: "open-url",
|
|
url: `custom://${longText}`,
|
|
},
|
|
],
|
|
});
|
|
const fixture = createElectronActiveObservationFixtureResult("/opt/app");
|
|
const event = {
|
|
...fixture.ipc.events[0],
|
|
correlation_id: longText,
|
|
event: longText,
|
|
channel: longText,
|
|
sender: longText,
|
|
receiver: longText,
|
|
frame: longText,
|
|
target: longText,
|
|
result_shape: longText,
|
|
process_type: longText,
|
|
source: longText,
|
|
artifact_path: longPath,
|
|
};
|
|
const result = electronActiveObservationResultSchema.parse({
|
|
...fixture,
|
|
application: {
|
|
...fixture.application,
|
|
application_path: longPath,
|
|
electron_version: longText,
|
|
},
|
|
actions: [
|
|
{
|
|
...fixture.actions[0],
|
|
step_id: longText,
|
|
target: longText,
|
|
error: longText,
|
|
status: "failed",
|
|
},
|
|
],
|
|
windows: [
|
|
{
|
|
...fixture.windows[0],
|
|
window_id: longText,
|
|
web_contents_id: longText,
|
|
url: longText,
|
|
title: longText,
|
|
},
|
|
],
|
|
ipc: { events: [event], observed: 1, dropped: 0 },
|
|
timeline: {
|
|
events: [
|
|
{
|
|
...event,
|
|
kind: "navigation",
|
|
phase: "observed",
|
|
direction: null,
|
|
capture_method: "event-emitter",
|
|
},
|
|
],
|
|
observed: 1,
|
|
dropped: 0,
|
|
},
|
|
limitations: [longText],
|
|
});
|
|
|
|
expect(input.actions[0]?.step_id).toBe(longText);
|
|
expect(input.actions[0]?.kind === "click" && input.actions[0].selector).toBe(
|
|
longText,
|
|
);
|
|
expect(result.ipc.events[0]?.channel).toBe(longText);
|
|
expect(result.windows[0]?.title).toBe(longText);
|
|
expect(result.limitations[0]).toBe(longText);
|
|
});
|
|
|
|
it("runs an untargeted deep-link without requiring a BrowserWindow", async () => {
|
|
const input = electronActiveObservationInputSchema.parse({
|
|
executable_path: "/opt/electron",
|
|
application_path: "/opt/app/main.js",
|
|
application_root: "/opt/app",
|
|
actions: [
|
|
{
|
|
step_id: "open-deep-link",
|
|
kind: "deep-link",
|
|
delivery: "second-instance",
|
|
url: "rea-fixture://open/item",
|
|
},
|
|
],
|
|
});
|
|
const application = {
|
|
windows: () => [],
|
|
evaluate: async () => true,
|
|
};
|
|
|
|
const result = await runElectronActions(application as never, input, {});
|
|
|
|
expect(result).toMatchObject([
|
|
{
|
|
step_id: "open-deep-link",
|
|
status: "completed",
|
|
error: null,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("honors caller-selected Electron waits beyond the former action timeout", async () => {
|
|
const input = electronActiveObservationInputSchema.parse({
|
|
executable_path: "/opt/electron",
|
|
application_path: "/opt/app/main.js",
|
|
application_root: "/opt/app",
|
|
actions: [{ step_id: "wait-long", kind: "wait", duration_ms: 60_000 }],
|
|
});
|
|
let duration = 0;
|
|
const page = {
|
|
waitForTimeout: async (value: number) => {
|
|
duration = value;
|
|
},
|
|
};
|
|
const application = {
|
|
windows: () => [page],
|
|
firstWindow: async () => page,
|
|
};
|
|
|
|
const result = await runElectronActions(application as never, input, {});
|
|
|
|
expect(result[0]).toMatchObject({ status: "completed" });
|
|
expect(duration).toBe(60_000);
|
|
});
|
|
|
|
it("rejects action outcomes that disagree with their error state", () => {
|
|
const result = createElectronActiveObservationFixtureResult("/opt/app");
|
|
const action = result.actions[0];
|
|
expect(action).toBeDefined();
|
|
if (action === undefined) return;
|
|
|
|
expect(
|
|
electronActiveObservationResultSchema.safeParse({
|
|
...result,
|
|
actions: [{ ...action, error: "unexpected failure" }],
|
|
}).success,
|
|
).toBe(false);
|
|
expect(
|
|
electronActiveObservationResultSchema.safeParse({
|
|
...result,
|
|
actions: [{ ...action, status: "failed", error: null }],
|
|
}).success,
|
|
).toBe(false);
|
|
});
|
|
|
|
it("cancels a wait without window_index while the app has no window", async () => {
|
|
const controller = new AbortController();
|
|
const application = {
|
|
firstWindow: vi.fn(() => {
|
|
queueMicrotask(() => controller.abort());
|
|
return new Promise<never>(() => undefined);
|
|
}),
|
|
} as unknown as ElectronApplication;
|
|
|
|
const actions = await runElectronActions(
|
|
application,
|
|
electronActiveObservationInputSchema.parse({
|
|
executable_path: "/opt/electron",
|
|
application_path: "/opt/app/main.js",
|
|
application_root: "/opt/app",
|
|
actions: [{ step_id: "settle", kind: "wait", duration_ms: 10 }],
|
|
}),
|
|
{ signal: controller.signal },
|
|
);
|
|
|
|
expect(actions).toEqual([
|
|
{
|
|
step_id: "settle",
|
|
kind: "wait",
|
|
window_index: null,
|
|
target: null,
|
|
status: "cancelled",
|
|
elapsed_ms: expect.any(Number),
|
|
error: expect.stringContaining("cancel"),
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("bounds a final state read that is still pending after cancellation", async () => {
|
|
vi.useFakeTimers();
|
|
const controller = new AbortController();
|
|
const application = {
|
|
evaluate: vi.fn(() => new Promise<never>(() => undefined)),
|
|
} as unknown as ElectronApplication;
|
|
|
|
const reason = new Error("caller-reason");
|
|
|
|
const state = readFinalApplicationState(application, controller.signal);
|
|
const outcome = state.then(
|
|
() => null,
|
|
(cause: unknown) => cause,
|
|
);
|
|
controller.abort(reason);
|
|
await vi.advanceTimersByTimeAsync(4_000);
|
|
expect(await Promise.race([outcome, Promise.resolve("pending")])).toBe(
|
|
"pending",
|
|
);
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
|
|
const cause = await outcome;
|
|
expect(cause).toMatchObject({
|
|
_tag: "BrowserObservationError",
|
|
reason: "cancelled",
|
|
diagnostics: {
|
|
final_state_read: "cancellation_grace_expired",
|
|
cancellation_grace_ms: 5_000,
|
|
},
|
|
});
|
|
expect((cause as Error).cause).toBe(reason);
|
|
});
|
|
|
|
it("keeps a final state read failure inside the cancellation grace period primary", async () => {
|
|
const controller = new AbortController();
|
|
controller.abort(new Error("caller-reason"));
|
|
const failure = new Error("renderer state unavailable");
|
|
const application = {
|
|
evaluate: vi.fn(() => Promise.reject(failure)),
|
|
} as unknown as ElectronApplication;
|
|
|
|
await expect(
|
|
readFinalApplicationState(application, controller.signal),
|
|
).rejects.toBe(failure);
|
|
});
|
|
|
|
const finalState = {
|
|
windows: [],
|
|
metrics: [],
|
|
electronVersion: "44.0.0",
|
|
hookSnapshot: {
|
|
events: [],
|
|
retention_budget_bytes: 0,
|
|
estimated_retained_bytes: 0,
|
|
event_serialized_byte_upper_bound: 0,
|
|
retained: 0,
|
|
dropped: 0,
|
|
dropped_ipc: 0,
|
|
dropped_runtime: 0,
|
|
dropped_event_families: [],
|
|
dropped_event_roles: [],
|
|
observed: 0,
|
|
observed_ipc: 0,
|
|
observed_runtime: 0,
|
|
hook_error: false,
|
|
},
|
|
};
|
|
|
|
/** An application whose four state reads settle only when `release` runs. */
|
|
const gatedStateApplication = () => {
|
|
let release = (): void => undefined;
|
|
const gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const evaluate = vi
|
|
.fn()
|
|
.mockImplementationOnce(() => gate.then(() => finalState.windows))
|
|
.mockImplementationOnce(() => gate.then(() => finalState.metrics))
|
|
.mockImplementationOnce(() => gate.then(() => finalState.electronVersion))
|
|
.mockImplementationOnce(() => gate.then(() => finalState.hookSnapshot));
|
|
return {
|
|
application: { evaluate } as unknown as ElectronApplication,
|
|
release,
|
|
};
|
|
};
|
|
|
|
it("returns a final state read that settles in the grace period after cancellation during the read", async () => {
|
|
vi.useFakeTimers();
|
|
const controller = new AbortController();
|
|
const { application, release } = gatedStateApplication();
|
|
|
|
const state = readFinalApplicationState(application, controller.signal);
|
|
controller.abort();
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
release();
|
|
|
|
await expect(state).resolves.toEqual(finalState);
|
|
});
|
|
|
|
it("returns a final state read that settles in the grace period when cancellation preceded it", async () => {
|
|
vi.useFakeTimers();
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
const { application, release } = gatedStateApplication();
|
|
|
|
const state = readFinalApplicationState(application, controller.signal);
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
release();
|
|
|
|
await expect(state).resolves.toEqual(finalState);
|
|
});
|
|
|
|
it("observes an operation abandoned by an already-cancelled signal", async () => {
|
|
const unhandled: unknown[] = [];
|
|
const recordUnhandled = (reason: unknown) => {
|
|
unhandled.push(reason);
|
|
};
|
|
process.on("unhandledRejection", recordUnhandled);
|
|
try {
|
|
const controller = new AbortController();
|
|
const reason = new Error("caller-reason");
|
|
controller.abort(reason);
|
|
let reject = (_cause: Error): void => undefined;
|
|
const operation = new Promise<never>((_, rejectOperation) => {
|
|
reject = rejectOperation;
|
|
});
|
|
|
|
await expect(
|
|
runWithExecutionLimits(operation, controller.signal),
|
|
).rejects.toMatchObject({
|
|
_tag: "BrowserObservationError",
|
|
reason: "cancelled",
|
|
cause: reason,
|
|
});
|
|
reject(new Error("application closed during cleanup"));
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(unhandled).toEqual([]);
|
|
} finally {
|
|
process.off("unhandledRejection", recordUnhandled);
|
|
}
|
|
});
|