diff --git a/tests/fixtures/edge-cases.jsonl b/tests/fixtures/edge-cases.jsonl
new file mode 100644
index 0000000..9e7e095
--- /dev/null
+++ b/tests/fixtures/edge-cases.jsonl
@@ -0,0 +1,4 @@
+this is not valid json
+{"type":"user","message":{"role":"user","content":"Valid message after corrupt line"},"uuid":"edge-1","timestamp":"2025-10-15T10:00:00Z"}
+{broken json too
+{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Response to valid message"}]},"uuid":"edge-2","timestamp":"2025-10-15T10:00:01Z"}
diff --git a/tests/fixtures/sample-session.jsonl b/tests/fixtures/sample-session.jsonl
new file mode 100644
index 0000000..f683819
--- /dev/null
+++ b/tests/fixtures/sample-session.jsonl
@@ -0,0 +1,15 @@
+{"type":"file-history-snapshot","messageId":"snap-1","snapshot":{"messageId":"snap-1","trackedFileBackups":[],"timestamp":"2025-10-15T10:30:00Z"},"isSnapshotUpdate":false}
+{"type":"progress","data":{"type":"hook","hookEvent":"PreToolUse","hookName":"security_check","command":"check"},"uuid":"prog-1","timestamp":"2025-10-15T10:30:01Z"}
+{"type":"user","message":{"role":"user","content":"Can you help me fix the login bug? Users are getting a 401 error."},"uuid":"msg-1","timestamp":"2025-10-15T10:30:05Z"}
+{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user is reporting a 401 error on login. I should look at the authentication middleware and the login endpoint to understand what's happening."},{"type":"text","text":"I'll investigate the login bug. Let me start by looking at the authentication code."}]},"uuid":"msg-2","timestamp":"2025-10-15T10:30:10Z"}
+{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"/src/auth/login.ts"}}]},"uuid":"msg-3","timestamp":"2025-10-15T10:30:15Z"}
+{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"export async function login(email: string, password: string) {\n const user = await db.users.findByEmail(email);\n if (!user) throw new UnauthorizedError();\n const valid = await bcrypt.compare(password, user.passwordHash);\n if (!valid) throw new UnauthorizedError();\n return generateToken(user);\n}"}]},"uuid":"msg-4","timestamp":"2025-10-15T10:30:16Z"}
+{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I found the issue. The `login` function looks correct, but let me check the middleware that validates tokens.\n\n```typescript\n// The fix is to update the token validation\nexport function validateToken(token: string) {\n return jwt.verify(token, process.env.JWT_SECRET!);\n}\n```\n\nThe problem was that the JWT_SECRET environment variable wasn't being loaded properly."}]},"uuid":"msg-5","timestamp":"2025-10-15T10:30:20Z"}
+{"type":"user","message":{"role":"user","content":"Remember to check environment variables"},"uuid":"msg-6","timestamp":"2025-10-15T10:30:21Z"}
+{"type":"user","message":{"role":"user","content":"That makes sense! Can you fix it?"},"uuid":"msg-7","timestamp":"2025-10-15T10:31:00Z"}
+{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/src/auth/middleware.ts","old_string":"jwt.verify(token, process.env.JWT_SECRET!)","new_string":"jwt.verify(token, getJwtSecret())"}}]},"uuid":"msg-8","timestamp":"2025-10-15T10:31:05Z"}
+{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_02","content":"File edited successfully."}]},"uuid":"msg-9","timestamp":"2025-10-15T10:31:06Z"}
+{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I've fixed the issue by using a `getJwtSecret()` helper that properly loads and caches the secret. The login should work now."}]},"uuid":"msg-10","timestamp":"2025-10-15T10:31:10Z"}
+{"type":"progress","data":{"type":"tool","status":"running","toolName":"Bash","serverName":"local"},"uuid":"prog-2","timestamp":"2025-10-15T10:31:15Z"}
+{"type":"summary","summary":"Fixed authentication bug by updating JWT secret loading in middleware.","leafUuid":"msg-10"}
+{"type":"system","subtype":"turn_duration","slug":"test-session","durationMs":65000,"uuid":"sys-1","timestamp":"2025-10-15T10:31:20Z"}
diff --git a/tests/fixtures/sessions-index.json b/tests/fixtures/sessions-index.json
new file mode 100644
index 0000000..2053592
--- /dev/null
+++ b/tests/fixtures/sessions-index.json
@@ -0,0 +1,23 @@
+{
+ "version": 1,
+ "entries": [
+ {
+ "sessionId": "test-session-001",
+ "fullPath": "/tmp/test-session-001.jsonl",
+ "summary": "Fix authentication bug in login flow",
+ "firstPrompt": "Can you help me fix the login bug?",
+ "created": "2025-10-15T10:30:00Z",
+ "modified": "2025-10-15T11:45:00Z",
+ "messageCount": 12
+ },
+ {
+ "sessionId": "test-session-002",
+ "fullPath": "/tmp/test-session-002.jsonl",
+ "summary": "Add dark mode support",
+ "firstPrompt": "I want to add dark mode to the app",
+ "created": "2025-10-16T14:00:00Z",
+ "modified": "2025-10-16T15:30:00Z",
+ "messageCount": 8
+ }
+ ]
+}
diff --git a/tests/unit/filters.test.ts b/tests/unit/filters.test.ts
new file mode 100644
index 0000000..24c17ac
--- /dev/null
+++ b/tests/unit/filters.test.ts
@@ -0,0 +1,142 @@
+import { describe, it, expect } from "vitest";
+import type { ParsedMessage, MessageCategory } from "../../src/shared/types.js";
+import { ALL_CATEGORIES, DEFAULT_HIDDEN_CATEGORIES } from "../../src/shared/types.js";
+
+// Replicate the filter logic to test it in isolation
+function filterMessages(
+ messages: ParsedMessage[],
+ enabledCategories: Set,
+ redactedUuids: Set = new Set()
+): ParsedMessage[] {
+ return messages.filter(
+ (m) => enabledCategories.has(m.category) && !redactedUuids.has(m.uuid)
+ );
+}
+
+function makeMsg(uuid: string, category: MessageCategory): ParsedMessage {
+ return { uuid, category, content: `Content for ${uuid}`, rawIndex: 0 };
+}
+
+describe("filters", () => {
+ const messages: ParsedMessage[] = [
+ makeMsg("1", "user_message"),
+ makeMsg("2", "assistant_text"),
+ makeMsg("3", "thinking"),
+ makeMsg("4", "tool_call"),
+ makeMsg("5", "tool_result"),
+ makeMsg("6", "system_message"),
+ makeMsg("7", "hook_progress"),
+ makeMsg("8", "file_snapshot"),
+ makeMsg("9", "summary"),
+ ];
+
+ it("correctly includes messages by enabled category", () => {
+ const enabled = new Set(["user_message", "assistant_text"]);
+ const filtered = filterMessages(messages, enabled);
+ expect(filtered).toHaveLength(2);
+ expect(filtered.map((m) => m.category)).toEqual([
+ "user_message",
+ "assistant_text",
+ ]);
+ });
+
+ it("correctly excludes messages by disabled category", () => {
+ const enabled = new Set(ALL_CATEGORIES);
+ enabled.delete("thinking");
+ const filtered = filterMessages(messages, enabled);
+ expect(filtered).toHaveLength(8);
+ expect(filtered.find((m) => m.category === "thinking")).toBeUndefined();
+ });
+
+ it("default filter state has thinking and hooks disabled", () => {
+ const defaultEnabled = new Set(ALL_CATEGORIES);
+ for (const cat of DEFAULT_HIDDEN_CATEGORIES) {
+ defaultEnabled.delete(cat);
+ }
+ const filtered = filterMessages(messages, defaultEnabled);
+ expect(filtered.find((m) => m.category === "thinking")).toBeUndefined();
+ expect(filtered.find((m) => m.category === "hook_progress")).toBeUndefined();
+ expect(filtered).toHaveLength(7);
+ });
+
+ it("all-off filter returns empty array", () => {
+ const filtered = filterMessages(messages, new Set());
+ expect(filtered).toEqual([]);
+ });
+
+ it("all-on filter returns all messages", () => {
+ const filtered = filterMessages(messages, new Set(ALL_CATEGORIES));
+ expect(filtered).toHaveLength(9);
+ });
+
+ it("excludes redacted messages", () => {
+ const enabled = new Set(ALL_CATEGORIES);
+ const redacted = new Set(["1", "3"]);
+ const filtered = filterMessages(messages, enabled, redacted);
+ expect(filtered).toHaveLength(7);
+ expect(filtered.find((m) => m.uuid === "1")).toBeUndefined();
+ expect(filtered.find((m) => m.uuid === "3")).toBeUndefined();
+ });
+
+ it("getMatchCount returns count of messages matching search query", () => {
+ const lowerQuery = "content for 1";
+ const count = messages.filter((m) =>
+ m.content.toLowerCase().includes(lowerQuery)
+ ).length;
+ expect(count).toBe(1);
+ });
+
+ it("getMatchCount returns 0 for empty query", () => {
+ const lowerQuery = "";
+ const count = lowerQuery
+ ? messages.filter((m) => m.content.toLowerCase().includes(lowerQuery)).length
+ : 0;
+ expect(count).toBe(0);
+ });
+
+ it("conversation preset includes only user and assistant messages", () => {
+ const preset: MessageCategory[] = ["user_message", "assistant_text"];
+ const enabled = new Set(preset);
+ const filtered = filterMessages(messages, enabled);
+ expect(filtered).toHaveLength(2);
+ expect(filtered.every((m) => m.category === "user_message" || m.category === "assistant_text")).toBe(true);
+ });
+
+ it("debug preset includes only tool calls and results", () => {
+ const preset: MessageCategory[] = ["tool_call", "tool_result"];
+ const enabled = new Set(preset);
+ const filtered = filterMessages(messages, enabled);
+ expect(filtered).toHaveLength(2);
+ expect(filtered.every((m) => m.category === "tool_call" || m.category === "tool_result")).toBe(true);
+ });
+
+ it("category counts are computed correctly", () => {
+ const counts: Record = {};
+ for (const cat of ALL_CATEGORIES) {
+ counts[cat] = 0;
+ }
+ for (const msg of messages) {
+ counts[msg.category]++;
+ }
+ expect(counts["user_message"]).toBe(1);
+ expect(counts["assistant_text"]).toBe(1);
+ expect(counts["thinking"]).toBe(1);
+ expect(Object.values(counts).reduce((a, b) => a + b, 0)).toBe(9);
+ });
+
+ it("category counts exclude redacted messages", () => {
+ const redacted = new Set(["1", "2"]);
+ const counts: Record = {};
+ for (const cat of ALL_CATEGORIES) {
+ counts[cat] = 0;
+ }
+ for (const msg of messages) {
+ if (!redacted.has(msg.uuid)) {
+ counts[msg.category]++;
+ }
+ }
+ expect(counts["user_message"]).toBe(0);
+ expect(counts["assistant_text"]).toBe(0);
+ expect(Object.values(counts).reduce((a, b) => a + b, 0)).toBe(7);
+ });
+});
diff --git a/tests/unit/html-exporter.test.ts b/tests/unit/html-exporter.test.ts
new file mode 100644
index 0000000..d374be2
--- /dev/null
+++ b/tests/unit/html-exporter.test.ts
@@ -0,0 +1,119 @@
+import { describe, it, expect } from "vitest";
+import { generateExportHtml } from "../../src/server/services/html-exporter.js";
+import type { ExportRequest, ParsedMessage } from "../../src/shared/types.js";
+
+function makeMessage(
+ overrides: Partial & { uuid: string }
+): ParsedMessage {
+ return {
+ category: "assistant_text",
+ content: "Test content",
+ rawIndex: 0,
+ ...overrides,
+ };
+}
+
+function makeExportRequest(
+ messages: ParsedMessage[],
+ visible?: string[],
+ redacted?: string[]
+): ExportRequest {
+ return {
+ session: {
+ id: "test-session",
+ project: "test-project",
+ messages,
+ },
+ visibleMessageUuids: visible || messages.map((m) => m.uuid),
+ redactedMessageUuids: redacted || [],
+ };
+}
+
+describe("html-exporter", () => {
+ it("generates valid HTML with DOCTYPE", async () => {
+ const msg = makeMessage({ uuid: "msg-1", content: "Hello" });
+ const html = await generateExportHtml(makeExportRequest([msg]));
+ expect(html).toMatch(/^/);
+ expect(html).toContain("