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.
This commit is contained in:
SnapOtter
2026-07-21 23:18:50 +08:00
committed by GitHub
parent 82f5708193
commit 6a0768b39d
35 changed files with 502 additions and 73 deletions
+34 -3
View File
@@ -36,10 +36,41 @@ const NATIVE_ERRORS = new Set([
"SyntaxError",
"ReferenceError",
"DOMException",
"SecurityError",
"NotSupportedError",
"QuotaExceededError",
// DOMExceptions report their specific name via err.name, not "DOMException",
// so the base entry alone dropped the diagnostic browser message for the
// whole family (WEB-3/4/6 arrived as "NotFoundError: NotFoundError"). This is
// the full WebIDL DOMException name table: every message is browser-authored
// and still passes through scrubText's url/path redaction.
"AbortError",
"ConstraintError",
"DataCloneError",
"DataError",
"EncodingError",
"HierarchyRequestError",
"IndexSizeError",
"InUseAttributeError",
"InvalidAccessError",
"InvalidCharacterError",
"InvalidModificationError",
"InvalidNodeTypeError",
"InvalidStateError",
"NamespaceError",
"NetworkError",
"NoModificationAllowedError",
"NotAllowedError",
"NotFoundError",
"NotReadableError",
"NotSupportedError",
"OperationError",
"QuotaExceededError",
"ReadOnlyError",
"SecurityError",
"TimeoutError",
"TransactionInactiveError",
"UnknownError",
"URLMismatchError",
"VersionError",
"WrongDocumentError",
]);
// Per-session runaway guard (Sentry de-dupes by fingerprint server-side, so 500
@@ -1,7 +1,12 @@
import { randomUUID } from "node:crypto";
import { readFile, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
import {
type ProgressCallback,
parseStdoutJson,
runPythonWithProgress,
toSidecarError,
} from "./bridge.js";
export type GifBgFormat = "webp" | "gif" | "apng";
@@ -103,7 +108,7 @@ export async function removeBackgroundAnimated(
const result = parseStdoutJson(stdout);
if (!result.success) {
if (result.error === "canceled") throw new AnimatedRemovalCanceledError();
throw new Error((result.error as string) || "Animated background removal failed");
throw toSidecarError(result.error, "Animated background removal failed");
}
const buffer = await readFile(outputPath);
return { buffer, format, contentType: FORMAT_CONTENT_TYPE[format], ext: FORMAT_EXT[format] };
+8 -14
View File
@@ -2,9 +2,13 @@ import { randomUUID } from "node:crypto";
import { readFile, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { isSafeMessageError, SafeError } from "@snapotter/shared";
import sharp from "sharp";
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
import {
type ProgressCallback,
parseStdoutJson,
runPythonWithProgress,
toSidecarError,
} from "./bridge.js";
export interface RemoveBackgroundOptions {
model?: string;
@@ -29,18 +33,8 @@ export function isMemoryAllocError(err: unknown): boolean {
);
}
/**
* Wrap a background-removal failure in a SafeError so its message survives the
* API's Sentry scrubber, which otherwise reduces a plain Error to "Error:
* Error". The specific sidecar reason is kept as the message (callers and the
* existing tests rely on it, matching the ai-bridge behavior); an empty reason
* falls back to a constant. Errors we already author (the bridge's SafeError
* timeout/OOM) pass through unchanged so their kind is not masked.
*/
function toBgRemovalError(reason: unknown): Error {
if (isSafeMessageError(reason)) return reason;
const message = reason instanceof Error ? reason.message : String(reason ?? "");
return new SafeError(message || "Background removal failed", { kind: "bug" });
return toSidecarError(reason, "Background removal failed");
}
export async function removeBackground(
@@ -104,7 +98,7 @@ async function runAndParse(
);
const result = parseStdoutJson(stdout);
if (!result.success) {
throw new Error(result.error || "Background removal failed");
throw toBgRemovalError(result.error || "Background removal failed");
}
return readFile(outputPath);
} catch (err) {
+72 -11
View File
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { context, propagation, SpanStatusCode, trace } from "@opentelemetry/api";
import { SafeError } from "@snapotter/shared";
import { isSafeMessageError, SafeError } from "@snapotter/shared";
import { missingBundleForScript } from "./feature-gate.js";
import { acquireVenvRead, tryAcquireVenvRead } from "./venv-lock.js";
@@ -113,6 +113,24 @@ function pythonExitError(code: number | null, signal: string | null, extracted:
});
}
/**
* Wrap a sidecar-reported failure (a `{success: false, error}` payload or a
* thrown reason) in a SafeError so its message survives the API's Sentry
* scrubber, which reduces a plain Error to "Error: Error" (NODE-24, NODE-2N,
* NODE-1R). The specific sidecar reason is kept as the message, matching
* pythonExitError; an empty reason falls back to the tool's constant message.
* Memory-allocation reasons classify as operational (the deployment's
* hardware), everything else as a bug. Errors we already author (the bridge's
* SafeError timeout/OOM) pass through unchanged so their kind is not masked.
*/
export function toSidecarError(reason: unknown, fallback: string): Error {
if (isSafeMessageError(reason)) return reason;
const message = reason instanceof Error ? reason.message : String(reason ?? "");
return new SafeError(message || fallback, {
kind: OOM_EXIT_TEXT.test(message) ? "operational" : "bug",
});
}
function extractPythonError(error: unknown): string {
if (error && typeof error === "object") {
const pErr = error as {
@@ -245,11 +263,17 @@ export class PythonDispatcher {
);
}
/** Reject and drop the pending requests written to one child generation. */
private rejectPendingForGeneration(generation: number, message: string): void {
/**
* Reject and drop the pending requests written to one child generation.
* Always rejects with a SafeError: these rejections reach tool wrappers and
* (when the per-request retry cannot save them) Sentry, where a plain Error
* is scrubbed to "Error: Error". run()'s crash-retry matches on the exact
* message, so callers must keep the two dispatcher-crash messages verbatim.
*/
private rejectPendingForGeneration(generation: number, error: Error): void {
for (const [id, req] of this.pending.entries()) {
if (req.generation !== generation) continue;
req.reject(new Error(message));
req.reject(error);
this.pending.delete(id);
}
}
@@ -270,7 +294,13 @@ export class PythonDispatcher {
console.error(
`[bridge] Dispatcher stdin pipe broken (${err.code}), rejecting pending requests`,
);
this.rejectPendingForGeneration(gen, "Python dispatcher stdin closed unexpectedly");
this.rejectPendingForGeneration(
gen,
new SafeError("Python dispatcher stdin closed unexpectedly", {
kind: "operational",
code: "dispatcher-stdin-closed",
}),
);
// An intentional shutdown() ends stdin then SIGTERMs the child,
// which can surface here as an EPIPE/ERR_STREAM_DESTROYED. That is
// not a crash: counting it would let repeated legitimate restarts
@@ -387,7 +417,13 @@ export class PythonDispatcher {
// marks the child stopped before killing it); mirrors "close".
this.recordCrash();
}
this.rejectPendingForGeneration(gen, extractPythonError(err));
this.rejectPendingForGeneration(
gen,
new SafeError(extractPythonError(err) || "Python dispatcher process error", {
kind: "operational",
code: err.code ?? "dispatcher-error",
}),
);
if (this.child === proc) {
this.child = null;
this.childReady = false;
@@ -395,7 +431,13 @@ export class PythonDispatcher {
});
proc.on("close", (code) => {
this.rejectPendingForGeneration(gen, "Python dispatcher exited unexpectedly");
this.rejectPendingForGeneration(
gen,
new SafeError("Python dispatcher exited unexpectedly", {
kind: "operational",
code: "dispatcher-exit",
}),
);
if (code !== 0 && !this.stoppedChildren.has(proc)) {
this.recordCrash();
}
@@ -488,7 +530,12 @@ export class PythonDispatcher {
} catch {
this.pending.delete(id);
clearTimeout(timer);
rejectPromise(new Error("Python dispatcher stdin closed unexpectedly"));
rejectPromise(
new SafeError("Python dispatcher stdin closed unexpectedly", {
kind: "operational",
code: "dispatcher-stdin-closed",
}),
);
}
});
}
@@ -509,7 +556,12 @@ export class PythonDispatcher {
// when the dispatcher is down (e.g. right after a model repair). Reject
// with the same message the dispatcher path surfaces.
if (missingBundleForScript(scriptName)) {
return Promise.reject(new Error("feature_not_installed"));
return Promise.reject(
new SafeError("feature_not_installed", {
kind: "operational",
code: "feature_not_installed",
}),
);
}
const scriptPath = resolve(PYTHON_DIR, scriptName);
const timeout =
@@ -566,7 +618,12 @@ export class PythonDispatcher {
if (err.code === "ENOENT" && !isFallback) {
trySpawn("python3", true);
} else {
rejectPromise(new Error(extractPythonError(err)));
rejectPromise(
new SafeError(extractPythonError(err) || "Failed to start Python process", {
kind: "operational",
code: err.code ?? "spawn-error",
}),
);
}
});
@@ -807,7 +864,11 @@ export function runPythonWithProgress(
// biome-ignore lint/suspicious/noExplicitAny: matches JSON.parse return type
export function parseStdoutJson(stdout: string): any {
const matched = stdout.match(/\{[\s\S]*\}$/);
if (!matched) throw new Error("No JSON response from Python script");
if (!matched) {
// SafeError so the broken-contract reason reaches Sentry instead of the
// scrubber's type-only "Error: Error" fallback.
throw new SafeError("No JSON response from Python script", { kind: "bug", code: "no-json" });
}
return JSON.parse(matched[0]);
}
+7 -2
View File
@@ -1,7 +1,12 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import sharp from "sharp";
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
import {
type ProgressCallback,
parseStdoutJson,
runPythonWithProgress,
toSidecarError,
} from "./bridge.js";
export interface ColorizeOptions {
intensity?: number;
@@ -34,7 +39,7 @@ export async function colorize(
const result = parseStdoutJson(stdout);
if (!result.success) {
throw new Error(result.error || "Colorization failed");
throw toSidecarError(result.error, "Colorization failed");
}
const actualOutputPath = result.output_path || outputPath;
+8 -3
View File
@@ -2,7 +2,12 @@ import { readFile, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import sharp from "sharp";
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
import {
type ProgressCallback,
parseStdoutJson,
runPythonWithProgress,
toSidecarError,
} from "./bridge.js";
export interface BlurFacesOptions {
blurRadius?: number;
@@ -50,7 +55,7 @@ export async function blurFaces(
const result = parseStdoutJson(stdout);
if (!result.success) {
throw new Error(result.error || "Face detection failed");
throw toSidecarError(result.error, "Face detection failed");
}
const buffer = await readFile(outputPath);
@@ -79,7 +84,7 @@ export async function detectFaces(
const result = parseStdoutJson(stdout);
if (!result.success) {
throw new Error(result.error || "Face detection failed");
throw toSidecarError(result.error, "Face detection failed");
}
return {
+7 -2
View File
@@ -1,7 +1,12 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import sharp from "sharp";
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
import {
type ProgressCallback,
parseStdoutJson,
runPythonWithProgress,
toSidecarError,
} from "./bridge.js";
export interface EnhanceFacesOptions {
model?: "auto" | "gfpgan" | "codeformer";
@@ -36,7 +41,7 @@ export async function enhanceFaces(
const result = parseStdoutJson(stdout);
if (!result.success) {
throw new Error(result.error || "Face enhancement failed");
throw toSidecarError(result.error, "Face enhancement failed");
}
const buffer = await readFile(outputPath);
+7 -2
View File
@@ -2,7 +2,12 @@ import { unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import sharp from "sharp";
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
import {
type ProgressCallback,
parseStdoutJson,
runPythonWithProgress,
toSidecarError,
} from "./bridge.js";
export interface FaceLandmarkPoint {
x: number;
@@ -44,7 +49,7 @@ export async function detectFaceLandmarks(
const result = parseStdoutJson(stdout);
if (!result.success) {
throw new Error(result.error || "Face landmark detection failed");
throw toSidecarError(result.error, "Face landmark detection failed");
}
return {
+7 -2
View File
@@ -1,7 +1,12 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import sharp from "sharp";
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
import {
type ProgressCallback,
parseStdoutJson,
runPythonWithProgress,
toSidecarError,
} from "./bridge.js";
/**
* Inpainting backend. "fast" is the always-available LaMa ONNX path
@@ -35,7 +40,7 @@ export async function inpaint(
const result = parseStdoutJson(stdout);
if (!result.success) {
throw new Error(result.error || "Inpainting failed");
throw toSidecarError(result.error, "Inpainting failed");
}
return readFile(outputPath);
+7 -2
View File
@@ -1,7 +1,12 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import sharp from "sharp";
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
import {
type ProgressCallback,
parseStdoutJson,
runPythonWithProgress,
toSidecarError,
} from "./bridge.js";
export interface NoiseRemovalOptions {
tier?: string;
@@ -44,7 +49,7 @@ export async function noiseRemoval(
const result = parseStdoutJson(stdout);
if (!result.success) {
throw new Error(result.error || "Noise removal failed");
throw toSidecarError(result.error, "Noise removal failed");
}
const actualOutputPath = result.output_path || outputPath;
+2 -2
View File
@@ -1,6 +1,6 @@
import { dirname, join } from "node:path";
import sharp from "sharp";
import type { ProgressCallback } from "./bridge.js";
import { type ProgressCallback, toSidecarError } from "./bridge.js";
import { runOcrRuntime } from "./ocr-runtime-dispatcher.js";
import { runAdaptiveTesseract, type TesseractLanguage } from "./tesseract.js";
import { preparePdfOcrPages, runTesseractPdf } from "./tesseract-pdf.js";
@@ -71,7 +71,7 @@ function parseAccurateResult(resultValue: unknown, quality: OcrQuality): OcrResu
}
const result = resultValue as Record<string, unknown>;
if (result.success !== true) {
throw new Error((result.error as string | undefined) || "OCR failed");
throw toSidecarError(result.error, "OCR failed");
}
const metadataValid =
+7 -2
View File
@@ -1,7 +1,12 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import sharp from "sharp";
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
import {
type ProgressCallback,
parseStdoutJson,
runPythonWithProgress,
toSidecarError,
} from "./bridge.js";
export interface OutpaintOptions {
extendTop: number;
@@ -39,7 +44,7 @@ export async function outpaint(
const result = parseStdoutJson(stdout);
if (!result.success) {
throw new Error(result.error || "Outpainting failed");
throw toSidecarError(result.error, "Outpainting failed");
}
return readFile(outputPath);
+7 -2
View File
@@ -1,7 +1,12 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import sharp from "sharp";
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
import {
type ProgressCallback,
parseStdoutJson,
runPythonWithProgress,
toSidecarError,
} from "./bridge.js";
export interface RedEyeRemovalOptions {
sensitivity?: number;
@@ -38,7 +43,7 @@ export async function removeRedEye(
const result = parseStdoutJson(stdout);
if (!result.success) {
throw new Error(result.error || "Red eye removal failed");
throw toSidecarError(result.error, "Red eye removal failed");
}
const actualOutputPath = result.output_path || outputPath;
+7 -2
View File
@@ -1,7 +1,12 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import sharp from "sharp";
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
import {
type ProgressCallback,
parseStdoutJson,
runPythonWithProgress,
toSidecarError,
} from "./bridge.js";
export interface RestorePhotoOptions {
scratchRemoval?: boolean;
@@ -43,7 +48,7 @@ export async function restorePhoto(
const result = parseStdoutJson(stdout);
if (!result.success) {
throw new Error(result.error || "Photo restoration failed");
throw toSidecarError(result.error, "Photo restoration failed");
}
const actualOutputPath = result.output_path || outputPath;
+7 -2
View File
@@ -1,4 +1,9 @@
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
import {
type ProgressCallback,
parseStdoutJson,
runPythonWithProgress,
toSidecarError,
} from "./bridge.js";
/**
* A single timed segment of transcribed speech.
@@ -38,7 +43,7 @@ export async function transcribeAudio(
const result = parseStdoutJson(stdout);
if (result.error) {
throw new Error(result.error);
throw toSidecarError(result.error, "Transcription failed");
}
// Map python segment keys {start, end, text} to {startS, endS, text}.
+2 -1
View File
@@ -6,6 +6,7 @@ import {
type ProgressCallback,
parseStdoutJson,
runPythonWithProgress,
toSidecarError,
} from "./bridge.js";
export interface UpscaleOptions {
@@ -53,7 +54,7 @@ export async function upscale(
const result = parseStdoutJson(stdout);
if (!result.success) {
throw new Error(result.error || "Upscaling failed");
throw toSidecarError(result.error, "Upscaling failed");
}
// Python may write to a different path when the output format changes
@@ -6,10 +6,14 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
// `./bridge.js` import receives these stubs.
const runPythonWithProgress = vi.fn();
const parseStdoutJson = vi.fn();
vi.mock("../../../packages/ai/src/bridge.js", () => ({
runPythonWithProgress: (...args: unknown[]) => runPythonWithProgress(...args),
parseStdoutJson: (...args: unknown[]) => parseStdoutJson(...args),
}));
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),
parseStdoutJson: (...args: unknown[]) => parseStdoutJson(...args),
};
});
import { isSafeMessageError, SafeError } from "@snapotter/shared";
import { removeBackground } from "../../../packages/ai/src/background-removal.js";
+2 -1
View File
@@ -17,7 +17,8 @@ vi.mock("node:fs/promises", () => ({
unlink: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../../packages/ai/src/bridge.js", () => ({
vi.mock("../../../packages/ai/src/bridge.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../packages/ai/src/bridge.js")>()),
runPythonWithProgress: vi.fn(),
parseStdoutJson: vi.fn(),
}));
+133
View File
@@ -0,0 +1,133 @@
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");
});
});
+2 -1
View File
@@ -14,7 +14,8 @@ vi.mock("node:fs/promises", () => ({
writeFile: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../../packages/ai/src/bridge.js", () => ({
vi.mock("../../../packages/ai/src/bridge.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../packages/ai/src/bridge.js")>()),
runPythonWithProgress: vi.fn(),
parseStdoutJson: vi.fn(),
}));
+2 -1
View File
@@ -15,7 +15,8 @@ vi.mock("node:fs/promises", () => ({
unlink: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../../packages/ai/src/bridge.js", () => ({
vi.mock("../../../packages/ai/src/bridge.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../packages/ai/src/bridge.js")>()),
runPythonWithProgress: vi.fn(),
parseStdoutJson: vi.fn(),
}));
+2 -1
View File
@@ -14,7 +14,8 @@ vi.mock("node:fs/promises", () => ({
writeFile: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../../packages/ai/src/bridge.js", () => ({
vi.mock("../../../packages/ai/src/bridge.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../packages/ai/src/bridge.js")>()),
runPythonWithProgress: vi.fn(),
parseStdoutJson: vi.fn(),
}));
+2 -1
View File
@@ -14,7 +14,8 @@ vi.mock("node:fs/promises", () => ({
unlink: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../../packages/ai/src/bridge.js", () => ({
vi.mock("../../../packages/ai/src/bridge.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../packages/ai/src/bridge.js")>()),
runPythonWithProgress: vi.fn(),
parseStdoutJson: vi.fn(),
}));
+2 -1
View File
@@ -14,7 +14,8 @@ vi.mock("node:fs/promises", () => ({
writeFile: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../../packages/ai/src/bridge.js", () => ({
vi.mock("../../../packages/ai/src/bridge.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../packages/ai/src/bridge.js")>()),
runPythonWithProgress: vi.fn(),
parseStdoutJson: vi.fn(),
}));
+2 -1
View File
@@ -14,7 +14,8 @@ vi.mock("node:fs/promises", () => ({
writeFile: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../../packages/ai/src/bridge.js", () => ({
vi.mock("../../../packages/ai/src/bridge.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../packages/ai/src/bridge.js")>()),
runPythonWithProgress: vi.fn(),
parseStdoutJson: vi.fn(),
}));
+2 -1
View File
@@ -15,7 +15,8 @@ vi.mock("node:fs/promises", () => ({
writeFile: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../../packages/ai/src/bridge.js", () => ({
vi.mock("../../../packages/ai/src/bridge.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../packages/ai/src/bridge.js")>()),
runPythonWithProgress: vi.fn(),
parseStdoutJson: vi.fn(),
}));
+2 -1
View File
@@ -14,7 +14,8 @@ vi.mock("node:fs/promises", () => ({
writeFile: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../../packages/ai/src/bridge.js", () => ({
vi.mock("../../../packages/ai/src/bridge.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../packages/ai/src/bridge.js")>()),
runPythonWithProgress: vi.fn(),
parseStdoutJson: vi.fn(),
}));
+2 -1
View File
@@ -14,7 +14,8 @@ vi.mock("node:fs/promises", () => ({
writeFile: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../../packages/ai/src/bridge.js", () => ({
vi.mock("../../../packages/ai/src/bridge.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../packages/ai/src/bridge.js")>()),
runPythonWithProgress: vi.fn(),
parseStdoutJson: vi.fn(),
}));
+2 -1
View File
@@ -14,7 +14,8 @@ vi.mock("node:fs/promises", () => ({
writeFile: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../../packages/ai/src/bridge.js", () => ({
vi.mock("../../../packages/ai/src/bridge.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../packages/ai/src/bridge.js")>()),
runPythonWithProgress: vi.fn(),
parseStdoutJson: vi.fn(),
}));
+108
View File
@@ -0,0 +1,108 @@
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");
});
});
+2 -1
View File
@@ -23,7 +23,8 @@ vi.mock("node:fs/promises", () => ({
}));
// Mock the bridge module
vi.mock("../../../packages/ai/src/bridge.js", () => ({
vi.mock("../../../packages/ai/src/bridge.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../packages/ai/src/bridge.js")>()),
runPythonWithProgress: vi.fn(),
parseStdoutJson: vi.fn(),
isGpuAvailable: vi.fn(() => false),
+2 -1
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../packages/ai/src/bridge.js", () => ({
vi.mock("../../../packages/ai/src/bridge.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../packages/ai/src/bridge.js")>()),
runPythonWithProgress: vi.fn(),
parseStdoutJson: vi.fn(),
}));
+2 -1
View File
@@ -15,7 +15,8 @@ vi.mock("node:fs/promises", () => ({
writeFile: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../../packages/ai/src/bridge.js", () => ({
vi.mock("../../../packages/ai/src/bridge.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../packages/ai/src/bridge.js")>()),
runPythonWithProgress: vi.fn(),
parseStdoutJson: vi.fn(),
isGpuAvailable: vi.fn().mockReturnValue(false),
+2 -1
View File
@@ -62,7 +62,8 @@ const {
};
});
vi.mock("../../../packages/ai/src/bridge.js", () => ({
vi.mock("../../../packages/ai/src/bridge.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../packages/ai/src/bridge.js")>()),
runPythonWithProgress: mockRunPythonWithProgress,
parseStdoutJson: mockParseStdoutJson,
isGpuAvailable: mockIsGpuAvailable,
+27
View File
@@ -27,6 +27,33 @@ describe("scrubBrowserMessage", () => {
it("drops messages for non-native error names", () => {
expect(scrubBrowserMessage("CustomerDataError", "contains secret.pdf")).toBeNull();
});
// DOMExceptions report their specific name ("NotFoundError"), not
// "DOMException", so listing only the base name dropped the diagnostic
// browser message for the whole family (WEB-3/4/6 showed as
// "NotFoundError: NotFoundError" with no way to tell which DOM call failed).
it("keeps messages for specific DOMException names, still redacted", () => {
expect(
scrubBrowserMessage(
"NotFoundError",
"Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.",
),
).toBe(
"Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.",
);
expect(scrubBrowserMessage("InvalidStateError", "The object is in an invalid state.")).toBe(
"The object is in an invalid state.",
);
expect(scrubBrowserMessage("NotAllowedError", "Write permission denied.")).toBe(
"Write permission denied.",
);
expect(scrubBrowserMessage("NotReadableError", "error reading /Users/bob/file.png")).toBe(
"error reading <path>",
);
expect(scrubBrowserMessage("DataCloneError", "could not be cloned.")).toBe(
"could not be cloned.",
);
});
});
describe("static filter lists", () => {