mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +01:00
Diagnostics only for Sentry PAPERCLIP-FRONTEND-G: record trusted/synthetic unhandledrejection origin, promise kind, and coarse page lifecycle facts without reading rejected values. Co-Authored-By: Amy (Opus) <claude@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
152 lines
6.3 KiB
TypeScript
152 lines
6.3 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
__resetPageDiagnostics,
|
|
buildBrowserErrorContext,
|
|
readPageLifecycle,
|
|
sanitizeComponentStack,
|
|
startPageDiagnostics,
|
|
takeRejectionEventSnapshot,
|
|
} from "./browser-error-context";
|
|
|
|
afterEach(() => {
|
|
__resetPageDiagnostics();
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
document.documentElement.className = "";
|
|
});
|
|
|
|
describe("browser error context", () => {
|
|
it("keeps React component names without locations or unrecognized content", () => {
|
|
const stack = `
|
|
at TaskDetail (https://tenant.example/assets/app.js?token=private-value:42:7)
|
|
at div (<anonymous>)
|
|
in AppShell (created by Router)
|
|
TaskPanel@https://tenant.example/tasks/private-task:12:3
|
|
at ErrorBoundary (/workspace/private/src/Boundary.tsx:15:9)
|
|
at https://tenant.example/private-page:17:3
|
|
unrecognized private content`;
|
|
expect(sanitizeComponentStack(stack)).toBe(
|
|
"\n at TaskDetail\n at div\n at AppShell\n at TaskPanel\n at ErrorBoundary",
|
|
);
|
|
});
|
|
|
|
it("bounds malformed and oversized component stacks", () => {
|
|
expect(sanitizeComponentStack(` at ${"a".repeat(101)} (private)`)).toBeUndefined();
|
|
expect(sanitizeComponentStack("x".repeat(16_384) + "\n at SecretSuffix")).toBeUndefined();
|
|
expect(sanitizeComponentStack("\n at TaskDetail (private)".repeat(100))?.match(/at TaskDetail/g)).toHaveLength(40);
|
|
});
|
|
|
|
it.each([undefined, null, "", "private unrecognized data"])("omits absent or unrecognized stacks: %s", (stack) => {
|
|
expect(sanitizeComponentStack(stack)).toBeUndefined();
|
|
});
|
|
|
|
it.each(["translated-ltr", "translated-rtl"])("records only a translation-marker boolean for %s", (marker) => {
|
|
document.documentElement.className = `${marker} private-customer-class`;
|
|
const result = buildBrowserErrorContext({ boundary: "route", componentStack: "\n at TaskDetail" });
|
|
expect(result).toMatchObject({
|
|
tags: { react_error_boundary: "route" },
|
|
contexts: { react: { componentStack: "\n at TaskDetail" }, browser_state: { translation_marker: true } },
|
|
});
|
|
expect(JSON.stringify(result)).not.toContain("private-customer-class");
|
|
});
|
|
|
|
it("captures a snapshot rather than reading changed DOM state later", () => {
|
|
vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
|
|
vi.spyOn(document, "readyState", "get").mockReturnValue("interactive");
|
|
const result = buildBrowserErrorContext({ boundary: "app" });
|
|
document.documentElement.classList.add("translated-ltr");
|
|
expect(result.contexts.browser_state).toEqual({
|
|
ready_state: "interactive", visibility_state: "hidden", translation_marker: false,
|
|
});
|
|
});
|
|
|
|
it("keeps the component trace when browser-state reads throw", () => {
|
|
vi.spyOn(document, "readyState", "get").mockImplementation(() => { throw new Error("unavailable"); });
|
|
vi.spyOn(document.documentElement, "classList", "get").mockImplementation(() => { throw new Error("unavailable"); });
|
|
expect(buildBrowserErrorContext({ boundary: "app", componentStack: "\n at TaskDetail" })).toEqual({
|
|
tags: { react_error_boundary: "app" },
|
|
contexts: {
|
|
react: { componentStack: "\n at TaskDetail" },
|
|
browser_state: { visibility_state: document.visibilityState },
|
|
page_lifecycle: expect.objectContaining({ pagehide_seen: false }),
|
|
},
|
|
});
|
|
});
|
|
|
|
it("does not require a browser document", () => {
|
|
vi.stubGlobal("document", undefined);
|
|
expect(buildBrowserErrorContext({ boundary: "app" }).contexts.browser_state).toEqual({});
|
|
});
|
|
});
|
|
|
|
/** A dispatched (untrusted) rejection event; jsdom has no PromiseRejectionEvent. */
|
|
function dispatchRejection(reason: unknown, promise?: unknown) {
|
|
const event = new Event("unhandledrejection");
|
|
Object.defineProperty(event, "reason", { value: reason });
|
|
Object.defineProperty(event, "promise", { value: promise });
|
|
window.dispatchEvent(event);
|
|
}
|
|
|
|
describe("page diagnostics", () => {
|
|
startPageDiagnostics();
|
|
|
|
function setNow(ms: number) {
|
|
vi.spyOn(performance, "now").mockReturnValue(ms);
|
|
}
|
|
|
|
it("records only the latest lifecycle transition with coarse timing", () => {
|
|
setNow(5_000);
|
|
expect(readPageLifecycle()).toEqual({ since_load: "1-10s", pagehide_seen: false });
|
|
|
|
vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
setNow(65_000);
|
|
expect(readPageLifecycle()).toEqual({
|
|
since_load: "1-10m", pagehide_seen: false, last_transition: "hidden", since_transition: "1-10m",
|
|
});
|
|
|
|
document.dispatchEvent(new Event("freeze"));
|
|
document.dispatchEvent(new Event("resume"));
|
|
setNow(65_500);
|
|
expect(readPageLifecycle()).toMatchObject({ last_transition: "resume", since_transition: "<1s" });
|
|
});
|
|
|
|
it("keeps a pagehide marker until the page is shown again", () => {
|
|
setNow(1_000_000);
|
|
window.dispatchEvent(new Event("pagehide"));
|
|
expect(readPageLifecycle()).toEqual({
|
|
since_load: ">10m", pagehide_seen: true, last_transition: "pagehide", since_transition: "<1s",
|
|
});
|
|
window.dispatchEvent(new Event("pageshow"));
|
|
expect(readPageLifecycle()).toMatchObject({ pagehide_seen: false, last_transition: "pageshow" });
|
|
});
|
|
|
|
it.each([
|
|
["native", Promise.resolve()],
|
|
["subclass", new (class Custom<T> extends Promise<T> {})(() => {})],
|
|
["other", { then() {} }],
|
|
["missing", undefined],
|
|
])("classifies a %s rejection promise without reading the reason", (kind, promise) => {
|
|
setNow(100);
|
|
const reason = { get secret() { throw new Error("must not be read"); } };
|
|
dispatchRejection(reason, promise);
|
|
expect(takeRejectionEventSnapshot("object")).toEqual({ trusted: false, promise: kind });
|
|
});
|
|
|
|
it("pairs a snapshot once, with the same rejection kind, inside the pairing window", () => {
|
|
setNow(100);
|
|
dispatchRejection(undefined, Promise.resolve());
|
|
expect(takeRejectionEventSnapshot("undefined")).toEqual({ trusted: false, promise: "native" });
|
|
expect(takeRejectionEventSnapshot("undefined")).toBeUndefined();
|
|
|
|
dispatchRejection(undefined, Promise.resolve());
|
|
expect(takeRejectionEventSnapshot("string")).toBeUndefined();
|
|
|
|
dispatchRejection(undefined, Promise.resolve());
|
|
setNow(5_101);
|
|
expect(takeRejectionEventSnapshot("undefined")).toBeUndefined();
|
|
});
|
|
});
|