Skip to content

Commit d5bc4bc

Browse files
test: add 16 unit tests for session-parser module
Covers: findSessionFiles, parseSession, parseSessionAsync, parseAllSessions Tests include: prompt/correction detection, tool_call + sub_agent_spawn extraction, error handling, compaction events, malformed JSON resilience, epoch timestamp normalization, since-date filtering, and sync/async parity.
1 parent d5f0d01 commit d5bc4bc

1 file changed

Lines changed: 287 additions & 0 deletions

File tree

tests/lib/session-parser.test.ts

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
3+
// We test the internal pure helpers by importing the module and exercising
4+
// the public API with controlled JSONL data written to temp files.
5+
import { writeFileSync, mkdirSync, rmSync } from "fs";
6+
import { join } from "path";
7+
import { tmpdir } from "os";
8+
import {
9+
findSessionFiles,
10+
parseSession,
11+
parseSessionAsync,
12+
parseAllSessions,
13+
} from "../../src/lib/session-parser.js";
14+
15+
// ── Fixtures ───────────────────────────────────────────────────────────────
16+
17+
function tmpDir(): string {
18+
const dir = join(tmpdir(), `preflight-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
19+
mkdirSync(dir, { recursive: true });
20+
return dir;
21+
}
22+
23+
function jsonl(...records: any[]): string {
24+
return records.map((r) => JSON.stringify(r)).join("\n") + "\n";
25+
}
26+
27+
const summaryRecord = {
28+
type: "summary",
29+
sessionId: "sess-123",
30+
gitBranch: "main",
31+
};
32+
33+
const userPrompt = {
34+
type: "user",
35+
timestamp: "2025-06-01T10:00:00Z",
36+
message: { content: "refactor the auth module" },
37+
};
38+
39+
const assistantReply = {
40+
type: "assistant",
41+
timestamp: "2025-06-01T10:00:05Z",
42+
model: "claude-sonnet-4-20250514",
43+
message: {
44+
content: [
45+
{ type: "text", text: "Sure, I'll refactor the auth module." },
46+
{ type: "tool_use", name: "Edit", input: { file: "auth.ts" } },
47+
],
48+
},
49+
};
50+
51+
const correctionPrompt = {
52+
type: "user",
53+
timestamp: "2025-06-01T10:00:10Z",
54+
message: { content: "no, I meant the login flow" },
55+
};
56+
57+
const toolResultError = {
58+
type: "tool_result",
59+
timestamp: "2025-06-01T10:00:12Z",
60+
is_error: true,
61+
content: "stderr: file not found",
62+
tool_use_id: "tu-1",
63+
};
64+
65+
const compactionRecord = {
66+
type: "system",
67+
timestamp: "2025-06-01T10:01:00Z",
68+
subtype: "compaction",
69+
message: { content: "context compacted" },
70+
};
71+
72+
const subAgentCall = {
73+
type: "assistant",
74+
timestamp: "2025-06-01T10:00:20Z",
75+
message: {
76+
content: [
77+
{ type: "tool_use", name: "Task", input: { task: "run tests" } },
78+
],
79+
},
80+
};
81+
82+
// ── Tests ──────────────────────────────────────────────────────────────────
83+
84+
describe("findSessionFiles", () => {
85+
it("returns empty for non-existent dir", () => {
86+
expect(findSessionFiles("/tmp/does-not-exist-xyz")).toEqual([]);
87+
});
88+
89+
it("discovers .jsonl files and subagent files", () => {
90+
const dir = tmpDir();
91+
writeFileSync(join(dir, "session-a.jsonl"), "{}");
92+
// Create subagent dir
93+
const subDir = join(dir, "session-a", "subagents");
94+
mkdirSync(subDir, { recursive: true });
95+
writeFileSync(join(subDir, "sub-1.jsonl"), "{}");
96+
97+
const files = findSessionFiles(dir);
98+
expect(files.length).toBe(2);
99+
expect(files.map((f) => f.sessionId).sort()).toEqual(["session-a", "sub-1"]);
100+
101+
rmSync(dir, { recursive: true, force: true });
102+
});
103+
});
104+
105+
describe("parseSession", () => {
106+
it("parses user prompts into prompt events", () => {
107+
const dir = tmpDir();
108+
const file = join(dir, "s1.jsonl");
109+
writeFileSync(file, jsonl(summaryRecord, userPrompt));
110+
111+
const events = parseSession(file, "/test", "test");
112+
expect(events.length).toBe(1);
113+
expect(events[0].type).toBe("prompt");
114+
expect(events[0].content).toBe("refactor the auth module");
115+
expect(events[0].session_id).toBe("sess-123");
116+
expect(events[0].branch).toBe("main");
117+
118+
rmSync(dir, { recursive: true, force: true });
119+
});
120+
121+
it("parses assistant text + tool_use into multiple events", () => {
122+
const dir = tmpDir();
123+
const file = join(dir, "s2.jsonl");
124+
writeFileSync(file, jsonl(summaryRecord, userPrompt, assistantReply));
125+
126+
const events = parseSession(file, "/test", "test");
127+
// prompt + assistant text + tool_call
128+
expect(events.length).toBe(3);
129+
expect(events[1].type).toBe("assistant");
130+
expect(events[2].type).toBe("tool_call");
131+
expect(events[2].content).toContain("Edit");
132+
133+
rmSync(dir, { recursive: true, force: true });
134+
});
135+
136+
it("detects corrections after assistant replies", () => {
137+
const dir = tmpDir();
138+
const file = join(dir, "s3.jsonl");
139+
writeFileSync(file, jsonl(summaryRecord, userPrompt, assistantReply, correctionPrompt));
140+
141+
const events = parseSession(file, "/test", "test");
142+
const correction = events.find((e) => e.type === "correction");
143+
expect(correction).toBeDefined();
144+
expect(correction!.content).toContain("login flow");
145+
146+
rmSync(dir, { recursive: true, force: true });
147+
});
148+
149+
it("parses tool_result errors", () => {
150+
const dir = tmpDir();
151+
const file = join(dir, "s4.jsonl");
152+
writeFileSync(file, jsonl(summaryRecord, toolResultError));
153+
154+
const events = parseSession(file, "/test", "test");
155+
expect(events.length).toBe(1);
156+
expect(events[0].type).toBe("error");
157+
expect(events[0].content).toContain("file not found");
158+
159+
rmSync(dir, { recursive: true, force: true });
160+
});
161+
162+
it("parses compaction events", () => {
163+
const dir = tmpDir();
164+
const file = join(dir, "s5.jsonl");
165+
writeFileSync(file, jsonl(summaryRecord, compactionRecord));
166+
167+
const events = parseSession(file, "/test", "test");
168+
expect(events.length).toBe(1);
169+
expect(events[0].type).toBe("compaction");
170+
171+
rmSync(dir, { recursive: true, force: true });
172+
});
173+
174+
it("detects sub_agent_spawn for Task tool", () => {
175+
const dir = tmpDir();
176+
const file = join(dir, "s6.jsonl");
177+
writeFileSync(file, jsonl(summaryRecord, subAgentCall));
178+
179+
const events = parseSession(file, "/test", "test");
180+
expect(events.length).toBe(1);
181+
expect(events[0].type).toBe("sub_agent_spawn");
182+
183+
rmSync(dir, { recursive: true, force: true });
184+
});
185+
186+
it("handles malformed JSON lines gracefully", () => {
187+
const dir = tmpDir();
188+
const file = join(dir, "s7.jsonl");
189+
writeFileSync(file, "not json\n" + JSON.stringify(userPrompt) + "\n");
190+
191+
// Should not throw, should skip bad line
192+
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
193+
const events = parseSession(file, "/test", "test");
194+
expect(events.length).toBe(1);
195+
expect(stderrSpy).toHaveBeenCalled();
196+
stderrSpy.mockRestore();
197+
198+
rmSync(dir, { recursive: true, force: true });
199+
});
200+
201+
it("handles empty content gracefully", () => {
202+
const dir = tmpDir();
203+
const file = join(dir, "s8.jsonl");
204+
writeFileSync(file, jsonl({ type: "user", message: { content: "" } }));
205+
206+
const events = parseSession(file, "/test", "test");
207+
expect(events.length).toBe(0);
208+
209+
rmSync(dir, { recursive: true, force: true });
210+
});
211+
212+
it("normalizes epoch timestamps", () => {
213+
const dir = tmpDir();
214+
const file = join(dir, "s9.jsonl");
215+
const record = { type: "user", timestamp: 1717236000, message: { content: "hello" } };
216+
writeFileSync(file, jsonl(record));
217+
218+
const events = parseSession(file, "/test", "test");
219+
expect(events[0].timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
220+
221+
rmSync(dir, { recursive: true, force: true });
222+
});
223+
224+
it("generates unique IDs and content_preview", () => {
225+
const dir = tmpDir();
226+
const file = join(dir, "s10.jsonl");
227+
writeFileSync(file, jsonl(userPrompt, { ...userPrompt, timestamp: "2025-06-01T10:01:00Z" }));
228+
229+
const events = parseSession(file, "/test", "test");
230+
expect(events.length).toBe(2);
231+
expect(events[0].id).not.toBe(events[1].id);
232+
expect(events[0].content_preview).toBeTruthy();
233+
234+
rmSync(dir, { recursive: true, force: true });
235+
});
236+
});
237+
238+
describe("parseSessionAsync", () => {
239+
it("produces same events as sync parser", async () => {
240+
const dir = tmpDir();
241+
const file = join(dir, "async.jsonl");
242+
writeFileSync(file, jsonl(summaryRecord, userPrompt, assistantReply, correctionPrompt));
243+
244+
const syncEvents = parseSession(file, "/test", "test");
245+
const asyncEvents = await parseSessionAsync(file, "/test", "test");
246+
247+
// Same count and types (IDs differ since they're random UUIDs)
248+
expect(asyncEvents.length).toBe(syncEvents.length);
249+
expect(asyncEvents.map((e) => e.type)).toEqual(syncEvents.map((e) => e.type));
250+
251+
rmSync(dir, { recursive: true, force: true });
252+
});
253+
});
254+
255+
describe("parseAllSessions", () => {
256+
it("parses all .jsonl files in a directory", () => {
257+
const dir = tmpDir();
258+
writeFileSync(join(dir, "a.jsonl"), jsonl(userPrompt));
259+
writeFileSync(join(dir, "b.jsonl"), jsonl(userPrompt));
260+
261+
const events = parseAllSessions(dir);
262+
expect(events.length).toBe(2);
263+
264+
rmSync(dir, { recursive: true, force: true });
265+
});
266+
267+
it("filters by since date", () => {
268+
const dir = tmpDir();
269+
writeFileSync(join(dir, "old.jsonl"), jsonl(userPrompt));
270+
271+
// Filter with a future date → should skip
272+
const events = parseAllSessions(dir, { since: new Date("2099-01-01") });
273+
expect(events.length).toBe(0);
274+
275+
rmSync(dir, { recursive: true, force: true });
276+
});
277+
278+
it("returns empty for non-existent dir", () => {
279+
// inferProject + findSessionFiles handle missing dirs
280+
const dir = tmpDir();
281+
rmSync(dir, { recursive: true, force: true });
282+
// parseAllSessions calls findSessionFiles which returns [] for missing dir
283+
// But it also calls inferProject on the dir basename — should not throw
284+
const events = parseAllSessions(dir);
285+
expect(events.length).toBe(0);
286+
});
287+
});

0 commit comments

Comments
 (0)