0006252d6e
- Added comprehensive test suite for `postmortem` utility error handling, covering cleanup symbol marking, cause chain depth validation, and process exception suppression. - Added tests for `browser-run-cancellation` ensuring proper `unhandledRejection` suppression and correct `ToolAbortError` propagation during teardown. - Implemented `collectUnhandledRejections` helper to verify silence of process-level rejections during async race conditions in browser runs. - Added integration-style probe tests for `postmortem` to verify that marked cleanup errors allow process survival while unmarked ones remain fatal.
157 lines
4.9 KiB
TypeScript
157 lines
4.9 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
import { postmortem } from "@oh-my-pi/pi-utils";
|
|
import { JsRuntime, type RuntimeHooks } from "../../src/eval/js/shared/runtime";
|
|
import { bindBrowserRunFacade, markHandled, waitForBrowserRun } from "../../src/tools/browser/run-cancellation";
|
|
import { ToolAbortError } from "../../src/tools/tool-errors";
|
|
|
|
async function collectUnhandledRejections(action: () => void | Promise<void>): Promise<unknown[]> {
|
|
const reasons: unknown[] = [];
|
|
const onUnhandled = (reason: unknown) => reasons.push(reason);
|
|
process.on("unhandledRejection", onUnhandled);
|
|
try {
|
|
await action();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
vi.advanceTimersByTime(0);
|
|
await Promise.resolve();
|
|
return reasons;
|
|
} finally {
|
|
process.off("unhandledRejection", onUnhandled);
|
|
}
|
|
}
|
|
|
|
describe("browser run cancellation", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("returns the same promise while preserving awaited rejection", async () => {
|
|
const rejection = new Error("browser run ended");
|
|
const promise = Promise.reject(rejection);
|
|
|
|
const handled = markHandled(promise);
|
|
|
|
expect(handled).toBe(promise);
|
|
await expect(handled).rejects.toBe(rejection);
|
|
});
|
|
|
|
it("resolves run-scoped wait when the run is not aborted", async () => {
|
|
const controller = new AbortController();
|
|
|
|
const wait = waitForBrowserRun(25, controller.signal);
|
|
vi.advanceTimersByTime(25);
|
|
|
|
await expect(wait).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("rejects run-scoped wait when the run aborts mid-sleep", async () => {
|
|
const controller = new AbortController();
|
|
const wait = waitForBrowserRun(1000, controller.signal);
|
|
|
|
controller.abort(new Error("browser run ended"));
|
|
|
|
await expect(wait).rejects.toThrow("browser run ended");
|
|
});
|
|
|
|
it("does not emit unhandledRejection for an unawaited wait aborted by run teardown", async () => {
|
|
const controller = new AbortController();
|
|
|
|
const reasons = await collectUnhandledRejections(async () => {
|
|
void waitForBrowserRun(1000, controller.signal);
|
|
controller.abort(postmortem.markExpectedCleanupError(new Error("browser run ended")));
|
|
});
|
|
|
|
expect(reasons).toEqual([]);
|
|
});
|
|
|
|
it("does not emit unhandledRejection when an unawaited facade method settles after abort", async () => {
|
|
const controller = new AbortController();
|
|
const deferred = Promise.withResolvers<string>();
|
|
const facade = bindBrowserRunFacade(
|
|
{
|
|
readTitle(): Promise<string> {
|
|
return deferred.promise;
|
|
},
|
|
},
|
|
controller.signal,
|
|
);
|
|
|
|
const reasons = await collectUnhandledRejections(async () => {
|
|
void facade.readTitle();
|
|
controller.abort(postmortem.markExpectedCleanupError(new Error("browser run ended")));
|
|
deferred.resolve("late title");
|
|
});
|
|
|
|
expect(reasons).toEqual([]);
|
|
});
|
|
|
|
it("rejects awaited facade method calls that settle after abort", async () => {
|
|
const controller = new AbortController();
|
|
const deferred = Promise.withResolvers<string>();
|
|
const facade = bindBrowserRunFacade(
|
|
{
|
|
readTitle(): Promise<string> {
|
|
return deferred.promise;
|
|
},
|
|
},
|
|
controller.signal,
|
|
);
|
|
|
|
const pending = facade.readTitle();
|
|
controller.abort(new Error("browser run ended"));
|
|
deferred.resolve("late title");
|
|
|
|
await expect(pending).rejects.toBeInstanceOf(ToolAbortError);
|
|
});
|
|
|
|
it("aborts run-scoped wait() before a stale continuation can mutate the tab", async () => {
|
|
const runtime = new JsRuntime({ initialCwd: process.cwd(), sessionId: "browser-run-cancellation-test" });
|
|
const timeoutSignal = AbortSignal.timeout(20);
|
|
const runAc = new AbortController();
|
|
const signal = AbortSignal.any([timeoutSignal, runAc.signal]);
|
|
const state: { lateNavigation?: string; displays: string[] } = { displays: [] };
|
|
const { promise: cancelRejection, reject } = Promise.withResolvers<never>();
|
|
const hooks: RuntimeHooks = {
|
|
onText: chunk => state.displays.push(chunk),
|
|
onDisplay: output => state.displays.push(JSON.stringify(output)),
|
|
callTool: async () => undefined,
|
|
};
|
|
timeoutSignal.addEventListener("abort", () => reject(new Error("Browser code execution timed out after 20ms")), {
|
|
once: true,
|
|
});
|
|
runtime.setRunScope({
|
|
wait: (ms: number): Promise<void> => waitForBrowserRun(ms, signal),
|
|
tab: bindBrowserRunFacade(
|
|
{
|
|
goto: async (url: string): Promise<void> => {
|
|
state.lateNavigation = url;
|
|
},
|
|
},
|
|
signal,
|
|
),
|
|
});
|
|
|
|
const run = Promise.race([
|
|
runtime.run(
|
|
'try { await wait(60); } catch {} await tab.goto("https://late.example"); display("late display");',
|
|
"browser-run-cancellation-test.js",
|
|
hooks,
|
|
),
|
|
cancelRejection,
|
|
]);
|
|
vi.advanceTimersByTime(20);
|
|
await expect(run).rejects.toThrow("Browser code execution timed out after 20ms");
|
|
runAc.abort(new Error("Browser run ended"));
|
|
vi.advanceTimersByTime(100);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(state.lateNavigation).toBeUndefined();
|
|
expect(state.displays).toEqual([]);
|
|
});
|
|
});
|