Files
SnapOtter/tests/unit/ai/sidecar-error.test.ts
T
SnapOtterandGitHub 6a0768b39d fix(telemetry): surface AI sidecar and DOMException failure reasons in Sentry (#612)
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.
2026-07-21 23:18:50 +08:00

109 lines
3.8 KiB
TypeScript

import { tmpdir } from "node:os";
import { isSafeMessageError, SafeError } from "@snapotter/shared";
import sharp from "sharp";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
// Mock only the process-spawning entry point; toSidecarError and
// parseStdoutJson stay real so the wrappers exercise the actual wrap logic.
const runPythonWithProgress = vi.fn();
vi.mock("../../../packages/ai/src/bridge.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../packages/ai/src/bridge.js")>();
return {
...actual,
runPythonWithProgress: (...args: unknown[]) => runPythonWithProgress(...args),
};
});
import { toSidecarError } from "../../../packages/ai/src/bridge.js";
import { transcribeAudio } from "../../../packages/ai/src/transcription.js";
import { upscale } from "../../../packages/ai/src/upscaling.js";
beforeEach(() => {
runPythonWithProgress.mockReset();
});
describe("toSidecarError", () => {
it("wraps a sidecar reason string in a SafeError bug that keeps the reason as message", () => {
const err = toSidecarError("rembg model load failed", "Background removal failed");
expect(isSafeMessageError(err)).toBe(true);
expect(err.message).toBe("rembg model load failed");
expect((err as SafeError).kind).toBe("bug");
});
it("classifies memory-allocation reasons as operational (environment, not our bug)", () => {
for (const reason of [
"CUDA out of memory",
"Failed to allocate memory for requested buffer",
"CUBLAS_STATUS_ALLOC_FAILED",
"std::bad_alloc",
]) {
const err = toSidecarError(reason, "Upscaling failed") as SafeError;
expect(err.kind).toBe("operational");
}
});
it("falls back to the constant tool message when the reason is empty", () => {
for (const reason of [undefined, null, ""]) {
const err = toSidecarError(reason, "Upscaling failed");
expect(isSafeMessageError(err)).toBe(true);
expect(err.message).toBe("Upscaling failed");
}
});
it("passes an existing SafeError through unchanged so its kind is not masked", () => {
const timeout = new SafeError("Python script timed out", {
kind: "operational",
code: "timeout",
});
expect(toSidecarError(timeout, "Upscaling failed")).toBe(timeout);
});
it("uses an Error reason's message", () => {
const err = toSidecarError(new Error("model weights corrupt"), "Upscaling failed");
expect(isSafeMessageError(err)).toBe(true);
expect(err.message).toBe("model weights corrupt");
});
});
describe("wrapper propagation (sidecar reason survives the Sentry scrubber)", () => {
let png: Buffer;
beforeAll(async () => {
png = await sharp({
create: { width: 4, height: 4, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
})
.png()
.toBuffer();
});
it("upscale throws a SafeError carrying the sidecar reason", async () => {
runPythonWithProgress.mockResolvedValue({
stdout: JSON.stringify({ success: false, error: "RealESRGAN weights not found" }),
});
let caught: unknown;
try {
await upscale(png, tmpdir(), { scale: 2 });
} catch (e) {
caught = e;
}
expect(isSafeMessageError(caught)).toBe(true);
expect((caught as SafeError).message).toBe("RealESRGAN weights not found");
expect((caught as SafeError).kind).toBe("bug");
});
it("transcribeAudio throws a SafeError carrying the sidecar reason", async () => {
runPythonWithProgress.mockResolvedValue({
stdout: JSON.stringify({ error: "audio stream unreadable" }),
});
let caught: unknown;
try {
await transcribeAudio("/nonexistent/input.wav", {});
} catch (e) {
caught = e;
}
expect(isSafeMessageError(caught)).toBe(true);
expect((caught as SafeError).message).toBe("audio stream unreadable");
});
});