Skip to content

Commit 6acc113

Browse files
committed
test: push coverage to 58% — 708 tests across 35 files
New test files (5): - HumanMouse.test.ts (24 tests) — bezier paths, Fitts law, biomechanical movement - ArtifactBuilder.test.ts (35 tests) — session trace recording, export, redaction - practical-tools.test.ts (14 tests) — background tabs, API capture, markdown export - ObserveSession.test.ts (21 tests) — forensic observe lifecycle, events, reports - PageStateCollector.test.ts (23 tests) — DOM/AX state collection, edge cases Extended files (1): - TaloxController.test.ts — additional controller tests Coverage: 48% -> 58% statements (+10%) Tests: 544 -> 708 (+30%) Files: 30 -> 35
1 parent 51f3d21 commit 6acc113

6 files changed

Lines changed: 2114 additions & 5 deletions

File tree

tests/unit/ArtifactBuilder.test.ts

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
/**
2+
* Unit tests for ArtifactBuilder — session action trace recording.
3+
*/
4+
import { describe, it, expect, vi } from 'vitest';
5+
import { ArtifactBuilder } from '../../src/core/ArtifactBuilder.js';
6+
import type { ActionFrame, VisualContext, ExportOptions } from '../../src/core/ArtifactBuilder.js';
7+
8+
// ─── Helpers ──────────────────────────────────────────────────────────────────
9+
10+
/** Seed the builder with a few actions. */
11+
function seedActions(builder: ArtifactBuilder, count: number = 3): void {
12+
for (let i = 0; i < count; i++) {
13+
builder.addAction('CLICK', { selector: `#btn-${i}` }, 50 + i * 10);
14+
}
15+
}
16+
17+
// ─── Tests ────────────────────────────────────────────────────────────────────
18+
19+
describe('ArtifactBuilder', () => {
20+
// ── Construction & addAction ─────────────────────────────────────────────
21+
22+
it('starts with an empty trace', () => {
23+
const builder = new ArtifactBuilder();
24+
const trace = builder.getTrace();
25+
expect(trace.actions).toHaveLength(0);
26+
});
27+
28+
it('addAction records an action', () => {
29+
const builder = new ArtifactBuilder();
30+
builder.addAction('CLICK', { selector: '#submit' });
31+
const trace = builder.getTrace();
32+
expect(trace.actions).toHaveLength(1);
33+
expect(trace.actions[0]!.type).toBe('CLICK');
34+
expect(trace.actions[0]!.payload).toEqual({ selector: '#submit' });
35+
});
36+
37+
it('addAction includes ISO timestamp', () => {
38+
const builder = new ArtifactBuilder();
39+
builder.addAction('NAVIGATE', { url: 'https://example.com' });
40+
const action = builder.getTrace().actions[0]!;
41+
expect(action.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
42+
});
43+
44+
it('addAction stores optional durationMs', () => {
45+
const builder = new ArtifactBuilder();
46+
builder.addAction('CLICK', { selector: '#btn' }, 123);
47+
const action = builder.getTrace().actions[0]!;
48+
expect(action.durationMs).toBe(123);
49+
});
50+
51+
it('addAction stores optional visualContext', () => {
52+
const builder = new ArtifactBuilder();
53+
const vc: VisualContext = { mouseX: 100, mouseY: 200 };
54+
builder.addAction('CLICK', { selector: '#btn' }, undefined, vc);
55+
const action = builder.getTrace().actions[0]!;
56+
expect(action.visualContext).toEqual({ mouseX: 100, mouseY: 200 });
57+
});
58+
59+
it('records multiple actions in order', () => {
60+
const builder = new ArtifactBuilder();
61+
builder.addAction('CLICK', { idx: 0 });
62+
builder.addAction('INPUT', { idx: 1 });
63+
builder.addAction('NAVIGATE', { idx: 2 });
64+
const actions = builder.getTrace().actions;
65+
expect(actions).toHaveLength(3);
66+
expect(actions[0]!.type).toBe('CLICK');
67+
expect(actions[1]!.type).toBe('INPUT');
68+
expect(actions[2]!.type).toBe('NAVIGATE');
69+
});
70+
71+
// ── getTrace ─────────────────────────────────────────────────────────────
72+
73+
it('getTrace returns a copy (mutations do not affect builder)', () => {
74+
const builder = new ArtifactBuilder();
75+
builder.addAction('CLICK', { a: 1 });
76+
const trace = builder.getTrace();
77+
trace.actions.push({ type: 'FAKE', payload: {}, timestamp: '' } as any);
78+
expect(builder.getTrace().actions).toHaveLength(1);
79+
});
80+
81+
it('getTrace id starts with "trace-"', () => {
82+
const builder = new ArtifactBuilder();
83+
const trace = builder.getTrace();
84+
expect(trace.id).toMatch(/^trace-\d+$/);
85+
});
86+
87+
// ── addMousePosition / addScrollPosition ──────────────────────────────────
88+
89+
it('addMousePosition enriches the last action visual context', () => {
90+
const builder = new ArtifactBuilder();
91+
builder.addAction('CLICK', {});
92+
builder.addMousePosition(50, 75);
93+
const action = builder.getTrace().actions[0]!;
94+
expect(action.visualContext?.mouseX).toBe(50);
95+
expect(action.visualContext?.mouseY).toBe(75);
96+
});
97+
98+
it('addMousePosition includes viewport dimensions', () => {
99+
const builder = new ArtifactBuilder();
100+
builder.addAction('CLICK', {});
101+
builder.addMousePosition(10, 20, 1920, 1080);
102+
const vc = builder.getTrace().actions[0]!.visualContext!;
103+
expect(vc.viewportWidth).toBe(1920);
104+
expect(vc.viewportHeight).toBe(1080);
105+
});
106+
107+
it('addMousePosition does nothing when no actions exist', () => {
108+
const builder = new ArtifactBuilder();
109+
// Should not throw
110+
builder.addMousePosition(1, 2);
111+
expect(builder.getTrace().actions).toHaveLength(0);
112+
});
113+
114+
it('addScrollPosition enriches the last action visual context', () => {
115+
const builder = new ArtifactBuilder();
116+
builder.addAction('SCROLL', {});
117+
builder.addScrollPosition(350);
118+
const action = builder.getTrace().actions[0]!;
119+
expect(action.visualContext?.scrollPosition).toBe(350);
120+
});
121+
122+
it('addMousePosition merges with existing visualContext', () => {
123+
const builder = new ArtifactBuilder();
124+
builder.addAction('CLICK', {}, undefined, { scrollPosition: 100 });
125+
builder.addMousePosition(42, 84);
126+
const vc = builder.getTrace().actions[0]!.visualContext!;
127+
expect(vc.scrollPosition).toBe(100);
128+
expect(vc.mouseX).toBe(42);
129+
expect(vc.mouseY).toBe(84);
130+
});
131+
132+
// ── toActionFrames ───────────────────────────────────────────────────────
133+
134+
it('toActionFrames returns frames with correct indices', () => {
135+
const builder = new ArtifactBuilder();
136+
seedActions(builder, 3);
137+
const frames = builder.toActionFrames();
138+
expect(frames).toHaveLength(3);
139+
expect(frames[0]!.frameIndex).toBe(0);
140+
expect(frames[1]!.frameIndex).toBe(1);
141+
expect(frames[2]!.frameIndex).toBe(2);
142+
});
143+
144+
it('toActionFrames includes relativeTimeMs', () => {
145+
const builder = new ArtifactBuilder();
146+
builder.addAction('CLICK', {});
147+
const frames = builder.toActionFrames();
148+
expect(frames[0]!.relativeTimeMs).toBeGreaterThanOrEqual(0);
149+
});
150+
151+
it('toActionFrames includes durationMs when present', () => {
152+
const builder = new ArtifactBuilder();
153+
builder.addAction('CLICK', {}, 75);
154+
const frames = builder.toActionFrames();
155+
expect(frames[0]!.durationMs).toBe(75);
156+
});
157+
158+
// ── formatActionType (via toActionFrames) ─────────────────────────────────
159+
160+
it('formats known action types', () => {
161+
const builder = new ArtifactBuilder();
162+
builder.addAction('CLICK', {});
163+
builder.addAction('INPUT', {});
164+
builder.addAction('NAVIGATE', {});
165+
const frames = builder.toActionFrames();
166+
expect(frames[0]!.action).toBe('Click Action');
167+
expect(frames[1]!.action).toBe('Input Action');
168+
expect(frames[2]!.action).toBe('Navigation Action');
169+
});
170+
171+
it('passes through unknown action types unchanged', () => {
172+
const builder = new ArtifactBuilder();
173+
builder.addAction('CUSTOM_THING', {});
174+
const frames = builder.toActionFrames();
175+
expect(frames[0]!.action).toBe('CUSTOM_THING');
176+
});
177+
178+
// ── sanitizePayload (via toActionFrames) ─────────────────────────────────
179+
180+
it('redacts sensitive fields in payloads', () => {
181+
const builder = new ArtifactBuilder();
182+
builder.addAction('INPUT', {
183+
username: 'alice',
184+
password: 'secret123',
185+
token: 'abc',
186+
});
187+
const frame = builder.toActionFrames()[0]!;
188+
expect(frame.details.username).toBe('alice');
189+
expect(frame.details.password).toBe('[REDACTED]');
190+
expect(frame.details.token).toBe('[REDACTED]');
191+
});
192+
193+
it('handles null/undefined payload gracefully', () => {
194+
const builder = new ArtifactBuilder();
195+
builder.addAction('CLICK', null as any);
196+
const frame = builder.toActionFrames()[0]!;
197+
expect(frame.details).toEqual({});
198+
});
199+
200+
// ── exportAsJSON ─────────────────────────────────────────────────────────
201+
202+
it('exportAsJSON returns valid JSON string', () => {
203+
const builder = new ArtifactBuilder();
204+
seedActions(builder);
205+
const json = builder.exportAsJSON();
206+
const parsed = JSON.parse(json);
207+
expect(parsed).toHaveProperty('sessionId');
208+
expect(parsed).toHaveProperty('frames');
209+
expect(parsed.frames).toHaveLength(3);
210+
});
211+
212+
it('exportAsJSON with prettyPrint=false is single-line', () => {
213+
const builder = new ArtifactBuilder();
214+
builder.addAction('CLICK', {});
215+
const compact = builder.exportAsJSON({ prettyPrint: false });
216+
expect(compact).not.toMatch(/\n/);
217+
});
218+
219+
it('exportAsJSON without payloads omits details', () => {
220+
const builder = new ArtifactBuilder();
221+
builder.addAction('CLICK', { secret: 'val' });
222+
const json = builder.exportAsJSON({ includePayloads: false });
223+
const parsed = JSON.parse(json);
224+
expect(parsed.frames[0]).not.toHaveProperty('details');
225+
});
226+
227+
it('exportAsJSON without visualContext omits it', () => {
228+
const builder = new ArtifactBuilder();
229+
builder.addAction('CLICK', {}, undefined, { mouseX: 1, mouseY: 2 });
230+
const json = builder.exportAsJSON({ includeVisualContext: false });
231+
const parsed = JSON.parse(json);
232+
expect(parsed.frames[0]).not.toHaveProperty('visualContext');
233+
});
234+
235+
// ── exportAsText ─────────────────────────────────────────────────────────
236+
237+
it('exportAsText returns a readable text log', () => {
238+
const builder = new ArtifactBuilder();
239+
builder.addAction('CLICK', { selector: '#btn' });
240+
const text = builder.exportAsText();
241+
expect(text).toContain('GHOST REPLAY SESSION LOG');
242+
expect(text).toContain('Frame 0');
243+
expect(text).toContain('Click Action');
244+
});
245+
246+
it('exportAsText includes visual context when present', () => {
247+
const builder = new ArtifactBuilder();
248+
builder.addAction('CLICK', {}, undefined, { mouseX: 100, mouseY: 200 });
249+
const text = builder.exportAsText({ includeVisualContext: true });
250+
expect(text).toContain('Mouse: (100, 200)');
251+
});
252+
253+
it('exportAsText excludes details when includePayloads is false', () => {
254+
const builder = new ArtifactBuilder();
255+
builder.addAction('CLICK', { secret: 'val' });
256+
const text = builder.exportAsText({ includePayloads: false });
257+
expect(text).not.toContain('secret');
258+
expect(text).not.toContain('Details:');
259+
});
260+
261+
// ── exportAsActionFrames ─────────────────────────────────────────────────
262+
263+
it('exportAsActionFrames returns JSON array of frames', () => {
264+
const builder = new ArtifactBuilder();
265+
seedActions(builder, 2);
266+
const json = builder.exportAsActionFrames();
267+
const parsed = JSON.parse(json);
268+
expect(Array.isArray(parsed)).toBe(true);
269+
expect(parsed).toHaveLength(2);
270+
});
271+
272+
// ── getSessionSummary ────────────────────────────────────────────────────
273+
274+
it('getSessionSummary returns action type counts', () => {
275+
const builder = new ArtifactBuilder();
276+
builder.addAction('CLICK', {});
277+
builder.addAction('CLICK', {});
278+
builder.addAction('INPUT', {});
279+
const summary = builder.getSessionSummary();
280+
expect(summary.totalActions).toBe(3);
281+
expect(summary.actionTypes).toEqual({ CLICK: 2, INPUT: 1 });
282+
});
283+
284+
it('getSessionSummary detects visual context presence', () => {
285+
const builder = new ArtifactBuilder();
286+
builder.addAction('CLICK', {}, undefined, { mouseX: 1, mouseY: 2 });
287+
const summary = builder.getSessionSummary();
288+
expect(summary.hasVisualContext).toBe(true);
289+
});
290+
291+
it('getSessionSummary reports hasVisualContext false when none', () => {
292+
const builder = new ArtifactBuilder();
293+
builder.addAction('CLICK', {});
294+
const summary = builder.getSessionSummary();
295+
expect(summary.hasVisualContext).toBe(false);
296+
});
297+
298+
it('getSessionSummary includes sessionId and timing', () => {
299+
const builder = new ArtifactBuilder();
300+
const summary = builder.getSessionSummary();
301+
expect(summary.sessionId).toMatch(/^session-\d+$/);
302+
expect(typeof summary.totalDurationMs).toBe('number');
303+
expect(summary.startTime).toMatch(/^\d{4}-/);
304+
});
305+
306+
// ── clear ────────────────────────────────────────────────────────────────
307+
308+
it('clear resets the trace', () => {
309+
const builder = new ArtifactBuilder();
310+
seedActions(builder, 5);
311+
expect(builder.getTrace().actions).toHaveLength(5);
312+
builder.clear();
313+
expect(builder.getTrace().actions).toHaveLength(0);
314+
});
315+
316+
it('clear resets startTime — sessionId changes after clear', async () => {
317+
// Use real timers briefly so Date.now() advances between calls
318+
vi.useRealTimers();
319+
const builder = new ArtifactBuilder();
320+
const id1 = builder.getSessionSummary().sessionId;
321+
// Ensure time passes
322+
await new Promise(r => setTimeout(r, 2));
323+
builder.clear();
324+
const id2 = builder.getSessionSummary().sessionId;
325+
expect(id2).not.toBe(id1);
326+
});
327+
328+
it('builder is usable after clear', () => {
329+
const builder = new ArtifactBuilder();
330+
builder.addAction('OLD', {});
331+
builder.clear();
332+
builder.addAction('NEW', { fresh: true });
333+
const actions = builder.getTrace().actions;
334+
expect(actions).toHaveLength(1);
335+
expect(actions[0]!.type).toBe('NEW');
336+
});
337+
});

0 commit comments

Comments
 (0)