mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
AI sidecar failures reached Sentry as 'Error: Error': the scrubber type-onlys plain Errors and the tool wrappers threw them from result.error. The bridge now exports toSidecarError(), wrapping the sidecar reason in a SafeError (memory-allocation text classifies as operational, the rest as bug); all 14 wrappers use it, plus the dispatcher crash/stdin/spawn rejection paths and parseStdoutJson. toBgRemovalError from #535 delegates to the shared helper. On the web side, DOMExceptions report their specific name via err.name, so the NATIVE_ERRORS allowlist dropped the whole family's browser-authored messages. It now carries the full WebIDL DOMException name table; messages still pass through url/path redaction. Bridge-mocking test files switched to importOriginal passthrough mocks.
134 lines
4.2 KiB
TypeScript
134 lines
4.2 KiB
TypeScript
import { type ChildProcess, spawn } from "node:child_process";
|
|
import { EventEmitter } from "node:events";
|
|
import { Writable } from "node:stream";
|
|
import { isSafeMessageError, type SafeError } from "@snapotter/shared";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// Mock child_process.spawn before importing the bridge module
|
|
vi.mock("node:child_process", () => ({
|
|
spawn: vi.fn(),
|
|
}));
|
|
|
|
function createMockProcess(): {
|
|
process: ChildProcess;
|
|
stdout: EventEmitter;
|
|
stderr: EventEmitter;
|
|
emitEvent: (event: string, ...args: unknown[]) => void;
|
|
} {
|
|
const stdin = new Writable({
|
|
write(_chunk, _encoding, callback) {
|
|
callback();
|
|
},
|
|
});
|
|
const stdout = new EventEmitter();
|
|
const stderr = new EventEmitter();
|
|
const proc = new EventEmitter() as unknown as ChildProcess;
|
|
Object.assign(proc, {
|
|
stdin,
|
|
stdout,
|
|
stderr,
|
|
pid: 12345,
|
|
killed: false,
|
|
kill: vi.fn(() => {
|
|
(proc as { killed: boolean }).killed = true;
|
|
return true;
|
|
}),
|
|
});
|
|
return {
|
|
process: proc,
|
|
stdout,
|
|
stderr,
|
|
emitEvent: (event: string, ...args: unknown[]) => proc.emit(event, ...args),
|
|
};
|
|
}
|
|
|
|
// Every rejection the bridge hands to a tool wrapper must be a SafeError:
|
|
// plain Errors get reduced to "Error: Error" by the API's Sentry scrubber
|
|
// (NODE-24, NODE-1R), losing the failure reason.
|
|
describe("bridge rejections are SafeErrors", () => {
|
|
let bridge: typeof import("../../../packages/ai/src/bridge.js");
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
vi.mocked(spawn).mockReset();
|
|
bridge = await import("../../../packages/ai/src/bridge.js");
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("parseStdoutJson throws a SafeError (bug) when the sidecar returns no JSON", () => {
|
|
let caught: unknown;
|
|
try {
|
|
bridge.parseStdoutJson("not json at all");
|
|
} catch (e) {
|
|
caught = e;
|
|
}
|
|
expect(isSafeMessageError(caught)).toBe(true);
|
|
expect((caught as SafeError).kind).toBe("bug");
|
|
expect((caught as SafeError).message).toBe("No JSON response from Python script");
|
|
});
|
|
|
|
it("per-request spawn failure (non-ENOENT) rejects with an operational SafeError", async () => {
|
|
const mockDisp = createMockProcess();
|
|
const mockPerReq = createMockProcess();
|
|
let callCount = 0;
|
|
vi.mocked(spawn).mockImplementation(() => {
|
|
callCount++;
|
|
if (callCount === 1) return mockDisp.process;
|
|
return mockPerReq.process;
|
|
});
|
|
|
|
const promise = bridge.runPythonWithProgress("test.py", []);
|
|
|
|
// Kill the dispatcher attempt so the per-request fallback runs.
|
|
const enoent = new Error("ENOENT") as NodeJS.ErrnoException;
|
|
enoent.code = "ENOENT";
|
|
mockDisp.emitEvent("error", enoent);
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
const eacces = new Error("spawn python3 EACCES") as NodeJS.ErrnoException;
|
|
eacces.code = "EACCES";
|
|
mockPerReq.emitEvent("error", eacces);
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await promise;
|
|
} catch (e) {
|
|
caught = e;
|
|
}
|
|
expect(isSafeMessageError(caught)).toBe(true);
|
|
expect((caught as SafeError).kind).toBe("operational");
|
|
expect((caught as SafeError).message).toBe("spawn python3 EACCES");
|
|
});
|
|
|
|
it("a dispatcher process error mid-request rejects pending requests with an operational SafeError", async () => {
|
|
const mockDisp = createMockProcess();
|
|
vi.mocked(spawn).mockReturnValue(mockDisp.process);
|
|
|
|
// Let the dispatcher come up, then issue a request against it.
|
|
const initPromise = bridge.initDispatcher(1_000);
|
|
mockDisp.stderr.emit("data", Buffer.from('{"ready": true, "gpu": false}\n'));
|
|
await initPromise;
|
|
|
|
const promise = bridge.runPythonWithProgress("test.py", []);
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
// A process-level error whose message matches no retry rule propagates
|
|
// straight to the caller, so it must already be a SafeError.
|
|
const eagain = new Error("spawn EAGAIN") as NodeJS.ErrnoException;
|
|
eagain.code = "EAGAIN";
|
|
mockDisp.emitEvent("error", eagain);
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await promise;
|
|
} catch (e) {
|
|
caught = e;
|
|
}
|
|
expect(isSafeMessageError(caught)).toBe(true);
|
|
expect((caught as SafeError).kind).toBe("operational");
|
|
});
|
|
});
|