Files
kandev/apps/web/lib/logger/capture.test.ts
T
2026-09-09 11:46:14 +01:00

317 lines
9.3 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { handleBrowserLogCapture } from "./capture";
import { prepareLogEntry, type LogEntry } from "./buffer";
const captureMocks = vi.hoisted(() => ({
upload: vi.fn(),
begin: vi.fn(),
browserInstallationID: vi.fn(),
browserLogMetadata: vi.fn(),
}));
vi.mock("@/lib/api/domains/system-api", () => ({
uploadFrontendBundleChunk: captureMocks.upload,
}));
vi.mock("./runtime", () => ({
beginBrowserLogCapture: captureMocks.begin,
browserInstallationID: captureMocks.browserInstallationID,
browserLogMetadata: captureMocks.browserLogMetadata,
}));
beforeEach(() => {
captureMocks.upload.mockReset().mockResolvedValue(undefined);
captureMocks.begin.mockReset();
captureMocks.browserInstallationID.mockReset().mockReturnValue("browser");
captureMocks.browserLogMetadata.mockReset().mockReturnValue({
storage_mode: "indexeddb",
persistence_failures: 0,
});
});
describe("frontend log capture", () => {
it("uploads each page before it reads the next page", async () => {
const entries: LogEntry[] = [
{
timestamp: new Date(1).toISOString(),
level: "info",
source: "console",
message: "x".repeat(60 * 1024),
},
{
timestamp: new Date(2).toISOString(),
level: "info",
source: "console",
message: "y".repeat(60 * 1024),
},
];
const prepared = entries.map(prepareLogEntry);
const events: string[] = [];
let releaseSecondPage: (() => void) | undefined;
const secondPage = new Promise<void>((resolve) => {
releaseSecondPage = resolve;
});
let readCount = 0;
let resolveFirstUpload: (() => void) | undefined;
const firstUploadStarted = new Promise<void>((resolve) => {
resolveFirstUpload = resolve;
});
captureMocks.upload.mockImplementation(async (_id, chunk) => {
events.push(`upload-${chunk.chunk_index}`);
if (chunk.chunk_index === 0) resolveFirstUpload?.();
});
const readPage = vi.fn(async (maxBytes: number) => {
events.push(`read-${readCount}`);
readCount += 1;
if (readCount === 1) {
expect(maxBytes).toBe(128 * 1024);
return {
entries: prepared.slice(0, 1),
nextCursor: { timestamp_ms: 1, primary_key: 1 },
done: false,
};
}
await secondPage;
return { entries: prepared.slice(1), nextCursor: null, done: true };
});
captureMocks.begin.mockResolvedValue({
storageMode: "indexeddb",
flushTimeout: false,
memoryEntries: prepared,
readPage,
});
const capture = handleBrowserLogCapture(
{
bundle_id: "bundle",
capture_deadline: new Date(Date.now() + 60_000).toISOString(),
capture_timeout_ms: 15_000,
max_chunk_bytes: 1024 * 1024,
max_browser_profiles: 4,
},
"identity",
);
await firstUploadStarted;
expect(events).toEqual(["read-0", "upload-0"]);
expect(readPage).toHaveBeenCalledTimes(1);
const firstEntries = captureMocks.upload.mock.calls[0]?.[1].entries as LogEntry[];
const firstBytes = firstEntries.reduce(
(total, entry) => total + new TextEncoder().encode(JSON.stringify(entry)).byteLength,
0,
);
expect(firstBytes).toBeLessThanOrEqual(128 * 1024);
releaseSecondPage?.();
await capture;
expect(captureMocks.upload).toHaveBeenCalledTimes(2);
expect(readPage.mock.calls[1]?.[0]).toBe(800 * 1024);
expect(events).toEqual(["read-0", "upload-0", "read-1", "upload-1"]);
});
});
describe("frontend log capture deadlines and fallbacks", () => {
it("uses the relative budget despite wall-clock skew and stops later pages", async () => {
vi.useFakeTimers();
const performanceNow = vi.spyOn(performance, "now");
performanceNow.mockReturnValue(100);
const first = prepareLogEntry({
timestamp: new Date(1).toISOString(),
level: "info",
source: "console",
message: "first",
});
const readPage = vi.fn().mockResolvedValue({
entries: [first],
nextCursor: { timestamp_ms: 1, primary_key: 1 },
done: false,
});
captureMocks.begin.mockResolvedValue({
storageMode: "indexeddb",
flushTimeout: false,
memoryEntries: [first],
readPage,
});
captureMocks.upload.mockImplementation(async () => {
performanceNow.mockReturnValue(200);
});
try {
const capture = handleBrowserLogCapture(
{
bundle_id: "bundle",
capture_deadline: new Date(0).toISOString(),
capture_timeout_ms: 50,
max_chunk_bytes: 1024 * 1024,
max_browser_profiles: 4,
},
"identity",
);
await capture;
expect(captureMocks.upload).toHaveBeenCalledTimes(1);
expect(readPage).toHaveBeenCalledTimes(1);
} finally {
performanceNow.mockRestore();
vi.useRealTimers();
}
});
});
describe("active frontend log capture deadlines", () => {
it("aborts an active upload when the monotonic budget expires", async () => {
vi.useFakeTimers();
const performanceNow = vi.spyOn(performance, "now");
performanceNow.mockReturnValue(100);
let signal: AbortSignal | undefined;
const entry = prepareLogEntry({
timestamp: new Date(1).toISOString(),
level: "info",
source: "console",
message: "active",
});
captureMocks.begin.mockResolvedValue({
storageMode: "memory",
flushTimeout: false,
memoryEntries: [entry],
readPage: null,
});
captureMocks.upload.mockImplementation((_id, _chunk, options) => {
signal = options?.init?.signal;
return new Promise<void>((_resolve, reject) => {
signal?.addEventListener("abort", () => {
reject(new DOMException("aborted", "AbortError"));
});
});
});
try {
const capture = handleBrowserLogCapture(
{
bundle_id: "bundle",
capture_deadline: new Date(0).toISOString(),
capture_timeout_ms: 50,
max_chunk_bytes: 1024 * 1024,
max_browser_profiles: 4,
},
"identity",
);
await Promise.resolve();
await vi.advanceTimersByTimeAsync(50);
await expect(capture).resolves.toBeUndefined();
expect(signal?.aborted).toBe(true);
} finally {
performanceNow.mockRestore();
vi.useRealTimers();
}
});
});
describe("frontend log capture read fallback", () => {
it("falls back to the receipt memory snapshot when a page read fails", async () => {
const entry = prepareLogEntry({
timestamp: new Date(1).toISOString(),
level: "info",
source: "console",
message: "fallback",
});
const readPage = vi.fn().mockRejectedValue(new Error("IndexedDB unavailable"));
captureMocks.begin.mockResolvedValue({
storageMode: "indexeddb",
flushTimeout: false,
memoryEntries: [entry],
readPage,
});
await handleBrowserLogCapture(
{
bundle_id: "bundle",
capture_deadline: new Date(Date.now() + 60_000).toISOString(),
capture_timeout_ms: 15_000,
max_chunk_bytes: 1024 * 1024,
max_browser_profiles: 4,
},
"identity",
);
expect(readPage).toHaveBeenCalledTimes(1);
expect(captureMocks.upload.mock.calls[0]?.[1]).toMatchObject({
done: true,
storage_mode: "memory",
entries: [entry.entry],
});
});
it("does not switch sources after the first page has uploaded", async () => {
const entry = prepareLogEntry({
timestamp: new Date(1).toISOString(),
level: "info",
source: "console",
message: "first page",
});
const readPage = vi
.fn()
.mockResolvedValueOnce({
entries: [entry],
nextCursor: { timestamp_ms: 1, primary_key: 1 },
done: false,
})
.mockRejectedValueOnce(new Error("IndexedDB read failed"));
captureMocks.begin.mockResolvedValue({
storageMode: "indexeddb",
flushTimeout: false,
memoryEntries: [entry],
readPage,
});
await expect(
handleBrowserLogCapture(
{
bundle_id: "bundle",
capture_deadline: new Date(Date.now() + 60_000).toISOString(),
capture_timeout_ms: 15_000,
max_chunk_bytes: 1024 * 1024,
max_browser_profiles: 4,
},
"identity",
),
).rejects.toThrow("IndexedDB read failed");
expect(captureMocks.upload).toHaveBeenCalledTimes(1);
});
});
describe("frontend log capture metadata", () => {
it("marks a timed-out persistence flush in final capture metadata", async () => {
const entry = prepareLogEntry({
timestamp: new Date(1).toISOString(),
level: "info",
source: "console",
message: "memory",
});
captureMocks.begin.mockResolvedValue({
storageMode: "memory",
flushTimeout: true,
memoryEntries: [entry],
readPage: null,
});
await handleBrowserLogCapture(
{
bundle_id: "bundle",
capture_deadline: new Date(Date.now() + 60_000).toISOString(),
capture_timeout_ms: 15_000,
max_chunk_bytes: 1024 * 1024,
max_browser_profiles: 4,
},
"identity",
);
expect(captureMocks.upload.mock.calls[0]?.[1]).toMatchObject({
done: true,
storage_mode: "memory",
capture_metadata: { flush_timeout: true, storage_mode: "memory" },
});
});
});