mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-22 03:10:43 +08:00
258 lines
9.6 KiB
TypeScript
258 lines
9.6 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
type Listener = (details: Record<string, any>) => unknown;
|
|
|
|
interface RawSnapshot {
|
|
version: 9;
|
|
active: boolean;
|
|
recordingId?: string;
|
|
startedAt?: number;
|
|
count: number;
|
|
droppedCount: number;
|
|
retainedCallCount: number;
|
|
retainedCallBytes: number;
|
|
retainedCallDroppedCount: number;
|
|
options?: { captureValues: boolean; maxEntries: number; maxValueBytes: number };
|
|
events: Array<Record<string, unknown>>;
|
|
callables: unknown[];
|
|
}
|
|
|
|
const fixture = vi.hoisted(() => ({
|
|
storage: new Map<string, unknown>(),
|
|
storageFailure: undefined as Error | undefined,
|
|
pages: new Map<number, RawSnapshot>(),
|
|
listeners: {} as Record<string, Listener>,
|
|
storageSet: vi.fn(),
|
|
}));
|
|
|
|
function clone<T>(value: T): T {
|
|
return structuredClone(value);
|
|
}
|
|
|
|
vi.mock('wxt/browser', () => {
|
|
const event = (name: string) => ({
|
|
addListener: vi.fn((listener: Listener) => { fixture.listeners[name] = listener; }),
|
|
});
|
|
fixture.storageSet.mockImplementation(async (items: Record<string, unknown>) => {
|
|
if (fixture.storageFailure) throw fixture.storageFailure;
|
|
for (const [key, value] of Object.entries(clone(items))) fixture.storage.set(key, value);
|
|
});
|
|
return {
|
|
browser: {
|
|
storage: {
|
|
session: {
|
|
get: vi.fn(async (key: string) => ({ [key]: clone(fixture.storage.get(key)) })),
|
|
set: fixture.storageSet,
|
|
},
|
|
},
|
|
runtime: { sendMessage: vi.fn(async () => undefined) },
|
|
scripting: {
|
|
executeScript: vi.fn(async (details: Record<string, any>) => {
|
|
if (details.files) return [{ frameId: 0 }];
|
|
const tabId = details.target.tabId as number;
|
|
const command = details.args?.[2] as string;
|
|
const input = (details.args?.[3] || {}) as Record<string, unknown>;
|
|
const current = fixture.pages.get(tabId) || rawSnapshot(tabId);
|
|
if (command === 'start') {
|
|
current.active = true;
|
|
current.recordingId = typeof input.recordingId === 'string' ? input.recordingId : `recording-${tabId}`;
|
|
current.startedAt = typeof input.startedAt === 'number' ? input.startedAt : Date.now() + tabId;
|
|
current.options = {
|
|
captureValues: input.captureValues === true,
|
|
maxEntries: Number(input.maxEntries) || 500,
|
|
maxValueBytes: Number(input.maxValueBytes) || 8_192,
|
|
};
|
|
} else if (command === 'stop') {
|
|
current.active = false;
|
|
} else if (command === 'clear') {
|
|
Object.assign(current, rawSnapshot(tabId));
|
|
}
|
|
fixture.pages.set(tabId, current);
|
|
return [{ frameId: 0, result: clone(current) }];
|
|
}),
|
|
},
|
|
tabs: {
|
|
get: vi.fn(async (tabId: number) => ({
|
|
id: tabId,
|
|
windowId: 1,
|
|
title: `Tab ${tabId}`,
|
|
url: `https://site-${tabId}.example.test/page`,
|
|
incognito: false,
|
|
cookieStoreId: 'store-default',
|
|
})),
|
|
onRemoved: event('removed'),
|
|
onCreated: event('created'),
|
|
},
|
|
cookies: {
|
|
getAllCookieStores: vi.fn(async () => [{ id: 'store-default', tabIds: [...fixture.pages.keys()] }]),
|
|
},
|
|
webNavigation: {
|
|
getFrame: vi.fn(async ({ tabId }: { tabId: number }) => ({
|
|
url: `https://site-${tabId}.example.test/page`,
|
|
documentId: `document-${tabId}`,
|
|
})),
|
|
onBeforeNavigate: event('beforeNavigate'),
|
|
onCommitted: event('committed'),
|
|
onDOMContentLoaded: event('domContentLoaded'),
|
|
onCompleted: event('completed'),
|
|
onHistoryStateUpdated: event('history'),
|
|
onReferenceFragmentUpdated: event('fragment'),
|
|
onErrorOccurred: event('navigationError'),
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
function rawSnapshot(tabId: number, events: Array<Record<string, unknown>> = []): RawSnapshot {
|
|
return {
|
|
version: 9,
|
|
active: false,
|
|
recordingId: `recording-${tabId}`,
|
|
startedAt: 1_000 + tabId,
|
|
count: events.length,
|
|
droppedCount: 0,
|
|
retainedCallCount: 2,
|
|
retainedCallBytes: 4_096,
|
|
retainedCallDroppedCount: 0,
|
|
options: { captureValues: true, maxEntries: 500, maxValueBytes: 8_192 },
|
|
events,
|
|
callables: [],
|
|
};
|
|
}
|
|
|
|
function recordingEvent(index: number, input: { large?: boolean; preview?: boolean } = {}): Record<string, unknown> {
|
|
const preview = input.preview ? `${index}:`.padEnd(8_192, '密') : undefined;
|
|
return {
|
|
id: `event-${index}`,
|
|
sequence: index + 1,
|
|
timestamp: 10_000 + index,
|
|
recordingId: 'recording',
|
|
traceId: `trace-${Math.floor(index / 4)}`,
|
|
kind: 'fetch',
|
|
operation: 'request',
|
|
method: 'POST',
|
|
url: input.large
|
|
? `https://example.test/${'route'.repeat(1_600)}-${index}`
|
|
: `https://example.test/${index}`,
|
|
stack: input.large ? 'frame\n'.repeat(680) : undefined,
|
|
inputs: preview === undefined ? [] : [{
|
|
path: '$body',
|
|
fingerprint: `fingerprint-${index}`,
|
|
encoding: 'text',
|
|
byteLength: preview.length,
|
|
preview,
|
|
}],
|
|
outputs: [],
|
|
sensitiveCaptured: preview !== undefined,
|
|
inputPreview: preview,
|
|
};
|
|
}
|
|
|
|
async function freshService() {
|
|
vi.resetModules();
|
|
return import('./service');
|
|
}
|
|
|
|
describe('browser recording storage, snapshot and retained-value budgets', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(4_102_444_800_000);
|
|
fixture.storage.clear();
|
|
fixture.pages.clear();
|
|
fixture.storageFailure = undefined;
|
|
fixture.storageSet.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('reports persistence failure and recovers on the next changed snapshot', async () => {
|
|
fixture.pages.set(1, rawSnapshot(1));
|
|
const service = await freshService();
|
|
const snapshot = await service.startBrowserRecording({ tabId: 1, frameId: 0 });
|
|
expect(snapshot.status.persistence).toBe('pending');
|
|
|
|
fixture.storageFailure = new Error('session quota exceeded');
|
|
await vi.advanceTimersByTimeAsync(501);
|
|
expect(snapshot.status).toMatchObject({
|
|
persistence: 'degraded',
|
|
persistenceError: 'session quota exceeded',
|
|
});
|
|
|
|
fixture.storageFailure = undefined;
|
|
fixture.pages.get(1)!.events.push(recordingEvent(1));
|
|
fixture.pages.get(1)!.count = 1;
|
|
await service.getBrowserRecording({ tabId: 1, frameId: 0 }, 500, true);
|
|
await vi.advanceTimersByTimeAsync(501);
|
|
expect((await service.browserRecordingStatus({ tabId: 1, frameId: 0 })).persistence).toBe('persisted');
|
|
});
|
|
|
|
it('makes corrupted restored sessions visible and repairs storage on the next write', async () => {
|
|
fixture.storage.set('session.browser-recording-sessions.v4', {
|
|
version: 4,
|
|
sessions: { broken: { snapshot: null } },
|
|
});
|
|
fixture.pages.set(3, rawSnapshot(3));
|
|
const service = await freshService();
|
|
const snapshot = await service.startBrowserRecording({ tabId: 3, frameId: 0 });
|
|
|
|
expect(snapshot.status).toMatchObject({
|
|
persistence: 'degraded',
|
|
persistenceError: '已忽略 1 个损坏的录制会话',
|
|
});
|
|
await vi.advanceTimersByTimeAsync(501);
|
|
expect(snapshot.status.persistence).toBe('persisted');
|
|
});
|
|
|
|
it('bounds plaintext previews before discarding event metadata', async () => {
|
|
const events = Array.from({ length: 120 }, (_, index) => recordingEvent(index, { preview: true }));
|
|
fixture.pages.set(2, rawSnapshot(2, events));
|
|
const service = await freshService();
|
|
const snapshot = await service.startBrowserRecording({ tabId: 2, frameId: 0 }, { captureValues: true });
|
|
|
|
expect(snapshot.events).toHaveLength(120);
|
|
expect(snapshot.status.retainedPreviewBytes).toBeLessThanOrEqual(512 * 1024);
|
|
expect(snapshot.status.previewDroppedCount).toBeGreaterThan(0);
|
|
expect(snapshot.events.some((event) => event.inputs[0]?.fingerprint)).toBe(true);
|
|
expect(snapshot.status.retainedCallBytes).toBe(4_096);
|
|
});
|
|
|
|
it('drops the globally oldest events to keep each snapshot and all sessions bounded', async () => {
|
|
const service = await freshService();
|
|
for (let tabId = 10; tabId < 15; tabId += 1) {
|
|
fixture.pages.set(tabId, rawSnapshot(
|
|
tabId,
|
|
Array.from({ length: 190 }, (_, index) => recordingEvent(tabId * 1_000 + index, { large: true })),
|
|
));
|
|
const snapshot = await service.startBrowserRecording({ tabId, frameId: 0 });
|
|
expect(snapshot.status.retainedBytes).toBeLessThanOrEqual(2 * 1024 * 1024);
|
|
}
|
|
|
|
const snapshots = await Promise.all(Array.from({ length: 5 }, (_, index) => (
|
|
service.getBrowserRecording({ tabId: 10 + index, frameId: 0 }, 500, false)
|
|
)));
|
|
expect(snapshots.at(-1)?.status.globalRetainedBytes).toBeLessThanOrEqual(8 * 1024 * 1024);
|
|
expect(snapshots.reduce((total, item) => total + (item.status.budgetDroppedCount || 0), 0)).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('restores a bounded persisted session after a service-worker restart', async () => {
|
|
fixture.pages.set(20, rawSnapshot(20, [recordingEvent(1)]));
|
|
let service = await freshService();
|
|
await service.startBrowserRecording({ tabId: 20, frameId: 0 });
|
|
await vi.advanceTimersByTimeAsync(501);
|
|
expect(fixture.storageSet).toHaveBeenCalled();
|
|
|
|
service = await freshService();
|
|
const restored = await service.getBrowserRecording({ tabId: 20, frameId: 0 }, 500, false);
|
|
expect(restored.events).toHaveLength(1);
|
|
expect(restored.status).toMatchObject({ persistence: 'pending', globalSessionCount: 1 });
|
|
await vi.advanceTimersByTimeAsync(501);
|
|
expect((await service.browserRecordingStatus({ tabId: 20, frameId: 0 }))).toMatchObject({
|
|
persistence: 'persisted',
|
|
globalSessionCount: 1,
|
|
});
|
|
});
|
|
});
|