mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-22 03:10:43 +08:00
177 lines
7.8 KiB
TypeScript
177 lines
7.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { BrowserRecordingEvent } from '@/types/models';
|
|
import {
|
|
buildRecordingLinks,
|
|
buildRecordingTraces,
|
|
mergeRecordingEvents,
|
|
nextRecordingSequence,
|
|
} from './timeline';
|
|
|
|
function event(
|
|
id: string,
|
|
sequence: number,
|
|
traceId: string,
|
|
overrides: Partial<BrowserRecordingEvent> = {},
|
|
): BrowserRecordingEvent {
|
|
return {
|
|
id,
|
|
sequence,
|
|
timestamp: 1_000 + sequence * 10,
|
|
recordingId: 'session-1',
|
|
traceId,
|
|
kind: 'interaction',
|
|
operation: 'click',
|
|
inputs: [],
|
|
outputs: [],
|
|
sensitiveCaptured: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('browser recording timeline', () => {
|
|
it('keeps traces and their steps in top-to-bottom chronological order', () => {
|
|
const events = [
|
|
event('request', 3, 'trace-login', { kind: 'fetch', operation: 'request', method: 'POST', url: 'https://example.test/login' }),
|
|
event('second-click', 5, 'trace-search', { label: '查询' }),
|
|
event('first-click', 1, 'trace-login', { label: '登录' }),
|
|
event('crypto', 2, 'trace-login', { kind: 'crypto', operation: 'AES.encrypt', crypto: { adapterId: 'cryptojs', providerKind: 'library', family: 'symmetric', operation: 'AES.encrypt' } }),
|
|
event('navigation', 4, 'trace-login', {
|
|
kind: 'navigation',
|
|
operation: 'navigation.document',
|
|
navigation: { phase: 'completed', kind: 'document', toUrl: 'https://example.test/success', sameDocument: false },
|
|
}),
|
|
];
|
|
const traces = buildRecordingTraces(events, []);
|
|
|
|
expect(traces.map((trace) => trace.label)).toEqual(['登录', '查询']);
|
|
expect(traces[0].eventIds).toEqual(['first-click', 'crypto', 'request', 'navigation']);
|
|
expect(traces[0]).toMatchObject({ requestCount: 1, cryptoCount: 1, navigationCount: 1 });
|
|
});
|
|
|
|
it('updates one navigation boundary instead of duplicating its lifecycle phases', () => {
|
|
const started = event('navigation', 2, 'trace-login', {
|
|
kind: 'navigation',
|
|
operation: 'navigation.document',
|
|
navigation: { phase: 'started', kind: 'document', fromUrl: 'https://example.test/', toUrl: 'https://example.test/success', sameDocument: false },
|
|
});
|
|
const completed = {
|
|
...started,
|
|
durationMs: 84,
|
|
navigation: { ...started.navigation!, phase: 'completed' as const, documentId: 'document-2' },
|
|
};
|
|
const merged = mergeRecordingEvents([[started], [completed]]);
|
|
|
|
expect(merged).toHaveLength(1);
|
|
expect(merged[0].navigation?.phase).toBe('completed');
|
|
expect(merged[0].durationMs).toBe(84);
|
|
expect(nextRecordingSequence(merged)).toBe(3);
|
|
expect(buildRecordingTraces(merged, [])[0]).toMatchObject({ startedAt: 1_020, endedAt: 1_104 });
|
|
});
|
|
|
|
it('preserves sensitive previews and the most advanced navigation phase across status snapshots', () => {
|
|
const sensitive = event('crypto', 1, 'trace-login', {
|
|
kind: 'crypto',
|
|
operation: 'AES.encrypt',
|
|
crypto: { adapterId: 'cryptojs', providerKind: 'library', family: 'symmetric', operation: 'AES.encrypt' },
|
|
sensitiveCaptured: true,
|
|
inputPreview: 'plain-value',
|
|
inputs: [{ path: '$input', fingerprint: 'plain', encoding: 'text', byteLength: 11, preview: 'plain-value' }],
|
|
});
|
|
const metadataOnly = {
|
|
...sensitive,
|
|
sensitiveCaptured: false,
|
|
inputPreview: undefined,
|
|
inputs: sensitive.inputs.map(({ preview: _preview, ...item }) => item),
|
|
};
|
|
const completedNavigation = event('navigation', 2, 'trace-login', {
|
|
kind: 'navigation',
|
|
operation: 'navigation.document',
|
|
durationMs: 48,
|
|
navigation: { phase: 'completed', kind: 'document', toUrl: 'https://example.test/success', sameDocument: false },
|
|
});
|
|
const pageNavigation = {
|
|
...completedNavigation,
|
|
durationMs: undefined,
|
|
navigation: { ...completedNavigation.navigation!, phase: 'started' as const },
|
|
};
|
|
|
|
const merged = mergeRecordingEvents([[sensitive, completedNavigation], [metadataOnly, pageNavigation]]);
|
|
expect(merged[0]).toMatchObject({ sensitiveCaptured: true, inputPreview: 'plain-value' });
|
|
expect(merged[0].inputs[0].preview).toBe('plain-value');
|
|
expect(merged[1].navigation?.phase).toBe('completed');
|
|
expect(merged[1].durationMs).toBe(48);
|
|
});
|
|
|
|
it('keeps exact value links separate from chronological navigation edges', () => {
|
|
const crypto = event('crypto', 1, 'trace-login', {
|
|
kind: 'crypto',
|
|
operation: 'AES.encrypt',
|
|
crypto: { adapterId: 'cryptojs', providerKind: 'library', family: 'symmetric', operation: 'AES.encrypt' },
|
|
outputs: [{ path: '$output', fingerprint: 'cipher', encoding: 'text', byteLength: 32 }],
|
|
});
|
|
const request = event('request', 2, 'trace-login', {
|
|
kind: 'fetch',
|
|
operation: 'request',
|
|
inputs: [{ path: '$body.encryptedData', fingerprint: 'cipher', encoding: 'text', byteLength: 32 }],
|
|
});
|
|
const navigation = event('navigation', 3, 'trace-login', {
|
|
kind: 'navigation',
|
|
operation: 'navigation.document',
|
|
navigation: { phase: 'completed', kind: 'document', toUrl: 'https://example.test/success', sameDocument: false },
|
|
});
|
|
|
|
const links = buildRecordingLinks([crypto, request, navigation]);
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0]).toMatchObject({ fromEventId: 'crypto', toEventId: 'request' });
|
|
});
|
|
|
|
it('correlates delayed Worker replies with the originating channel without claiming value equality', () => {
|
|
const send = event('send', 1, 'trace-worker', {
|
|
kind: 'worker', operation: 'worker.postMessage', direction: 'send', channelId: 'worker-1',
|
|
});
|
|
const receive = event('receive', 2, 'trace-worker', {
|
|
kind: 'worker', operation: 'worker.message', direction: 'receive', channelId: 'worker-1',
|
|
});
|
|
const links = buildRecordingLinks([send, receive]);
|
|
|
|
expect(links).toEqual([expect.objectContaining({
|
|
kind: 'channel', confidence: 'correlated', fromEventId: 'send', toEventId: 'receive',
|
|
})]);
|
|
expect(buildRecordingTraces([send, receive], links)[0]).toMatchObject({ messageCount: 2, linkedValueCount: 0 });
|
|
});
|
|
|
|
it('links ordered constructor and session stages without claiming value equality', () => {
|
|
const state = (phase: 'create' | 'update' | 'final') => ({
|
|
adapterId: 'jsrsasign',
|
|
providerKind: 'library' as const,
|
|
family: 'signature' as const,
|
|
operation: phase === 'final' ? 'sign' : phase,
|
|
state: { model: 'session' as const, correlationId: 'signature-session-1', phase },
|
|
});
|
|
const create = event('create', 1, 'trace-sign', {
|
|
kind: 'crypto', operation: 'Signature.create', crypto: state('create'),
|
|
});
|
|
const update = event('update', 2, 'trace-sign', {
|
|
kind: 'crypto', operation: 'Signature.updateString', crypto: state('update'),
|
|
});
|
|
const final = event('final', 3, 'trace-sign', {
|
|
kind: 'crypto', operation: 'Signature.sign', crypto: state('final'),
|
|
outputs: [{ path: '$output', fingerprint: 'signature', encoding: 'hex', byteLength: 64 }],
|
|
});
|
|
const request = event('request', 4, 'trace-sign', {
|
|
kind: 'fetch', operation: 'request',
|
|
inputs: [{ path: '$headers.x-signature', fingerprint: 'signature', encoding: 'hex', byteLength: 64 }],
|
|
});
|
|
|
|
const links = buildRecordingLinks([create, update, final, request]);
|
|
expect(links.filter((link) => link.kind === 'state')).toEqual([
|
|
expect.objectContaining({ fromEventId: 'create', toEventId: 'update', confidence: 'correlated' }),
|
|
expect.objectContaining({ fromEventId: 'update', toEventId: 'final', confidence: 'correlated' }),
|
|
]);
|
|
expect(links.filter((link) => link.kind === 'value')).toEqual([
|
|
expect.objectContaining({ fromEventId: 'final', toEventId: 'request', confidence: 'exact' }),
|
|
]);
|
|
expect(buildRecordingTraces([create, update, final, request], links)[0].linkedValueCount).toBe(1);
|
|
});
|
|
});
|