102 lines
3.4 KiB
TypeScript
102 lines
3.4 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
buildFrontendErrorReport,
|
|
deriveTaskID,
|
|
scheduleFrontendErrorReport,
|
|
sendFrontendErrorReport,
|
|
} from "./frontend-error-log-api";
|
|
|
|
describe("frontend error log API", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
window.history.replaceState({}, "", "/");
|
|
});
|
|
|
|
it.each([
|
|
["/t/task-one", "task-one"],
|
|
["/tasks/task-two", "task-two"],
|
|
["/office/tasks/task-three", "task-three"],
|
|
["/settings?taskId=query-task", "query-task"],
|
|
["/office/projects/task-four", undefined],
|
|
])("derives task IDs only from recognized routes: %s", (route, expected) => {
|
|
expect(deriveTaskID(new URL(route, "http://localhost"))).toBe(expected);
|
|
});
|
|
|
|
it("builds bounded browser context without serializing arbitrary React nodes", () => {
|
|
window.history.replaceState({}, "", "/t/task-one?token=secret#private");
|
|
const report = buildFrontendErrorReport({
|
|
source: "sonner",
|
|
title: "Failed to save",
|
|
description: { type: "div", props: { secret: "must-not-be-walked" } },
|
|
error: new TypeError("Failed to fetch"),
|
|
});
|
|
|
|
expect(report.source).toBe("sonner");
|
|
expect(report.title).toBe("Failed to save");
|
|
expect(report.description).toBe("[object]");
|
|
expect(report.task_id).toBe("task-one");
|
|
expect(report.error).toMatchObject({ name: "TypeError", message: "Failed to fetch" });
|
|
expect(report.url).toBe(`${window.location.origin}/t/task-one`);
|
|
expect(report.url).not.toContain("secret");
|
|
expect(report.viewport).toEqual({ width: window.innerWidth, height: window.innerHeight });
|
|
expect(JSON.stringify(report)).not.toContain("must-not-be-walked");
|
|
});
|
|
|
|
it("builds backend-reload reports without a synthetic toast stack", () => {
|
|
const report = buildFrontendErrorReport({
|
|
source: "backend-reload",
|
|
title: "boot_id_changed",
|
|
});
|
|
|
|
expect(report.stack).toBeUndefined();
|
|
});
|
|
|
|
it("omits error objects from backend-reload reports", () => {
|
|
const report = buildFrontendErrorReport({
|
|
source: "backend-reload",
|
|
title: "boot_id_changed",
|
|
error: new Error("not a toast error"),
|
|
});
|
|
|
|
expect(report.error).toBeUndefined();
|
|
});
|
|
|
|
it("omits descriptions from backend-reload reports", () => {
|
|
const report = buildFrontendErrorReport({
|
|
source: "backend-reload",
|
|
title: "boot_id_changed",
|
|
description: "should be suppressed",
|
|
});
|
|
|
|
expect(report.description).toBeUndefined();
|
|
});
|
|
|
|
it("uses an authenticated two-second request and silently accepts 204", async () => {
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockResolvedValue(new Response(null, { status: 204 }));
|
|
await sendFrontendErrorReport({
|
|
source: "sonner",
|
|
title: "Failed",
|
|
client_timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
expect.stringContaining("/api/v1/system/logs/frontend-errors"),
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
credentials: "include",
|
|
signal: expect.any(AbortSignal),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("schedules collection off the toast call stack and swallows failures", async () => {
|
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("offline"));
|
|
scheduleFrontendErrorReport({ source: "toast-provider", title: "Failed" });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
|
|
});
|
|
});
|