Add test suite: unit tests for parser, discovery, redactor, exporter, and filters
Comprehensive test coverage for all server services and shared modules:
tests/unit/session-parser.test.ts (16 tests):
- Parses every message type: user (string + array content), assistant
(text, thinking, tool_use blocks), progress/hook events (from data
field), file-history-snapshot, summary (from summary field)
- Verifies system metadata (turn_duration) and queue-operation lines
are silently skipped
- Detects <system-reminder> tags and reclassifies as system_message
- Resilience: skips malformed JSONL lines, returns empty for empty
files, preserves UUIDs from source
- Integration: parses full sample-session.jsonl fixture verifying all
9 categories are represented, handles edge-cases.jsonl with corrupt
lines
tests/unit/session-discovery.test.ts (6 tests):
- Discovers sessions from {version, entries} index format
- Handles legacy raw array format
- Gracefully returns empty for missing directories and corrupt JSON
- Aggregates across multiple project directories
- Uses fullPath from index entries when available
tests/unit/sensitive-redactor.test.ts (40+ tests):
- Tier 1 secrets: AWS access keys (AKIA/ASIA), Bedrock keys, GitHub
PATs (ghp_, github_pat_, ghu_, ghs_), GitLab (glpat-, glrt-),
OpenAI (sk-proj-, legacy sk-), Anthropic (api03, admin01),
HuggingFace, Perplexity, Stripe (sk_live/test/prod, rk_*), Slack
(bot token, webhook URL), SendGrid, GCP, Heroku, npm, PyPI, Sentry,
JWT, PEM private keys (RSA + generic), generic API key assignments
- Tier 2 PII: home directory paths (Linux/macOS/Windows), connection
strings (PostgreSQL/MongoDB/Redis), URLs with embedded credentials,
email addresses, IPv4 addresses, Bearer tokens, env var secrets
- False positive resistance: normal code, markdown, short strings,
non-home file paths, version numbers
- Allowlists: example.com/test.com emails, noreply@anthropic.com,
127.0.0.1, 0.0.0.0, RFC 5737 documentation IPs
- Edge cases: empty strings, multiple secrets, category tracking
- redactMessage: preserves uuid/category/timestamp, redacts content
and toolInput, leaves toolName unchanged, doesn't mutate original
tests/unit/html-exporter.test.ts (8 tests):
- Valid DOCTYPE HTML output with no external URL references
- Includes visible messages, excludes filtered and redacted ones
- Inserts redacted dividers at correct positions
- Renders markdown to HTML with syntax highlighting CSS
- Includes session metadata header
tests/unit/filters.test.ts (12 tests):
- Category filtering: include/exclude by enabled set
- Default state: thinking and hook_progress hidden
- All-on/all-off edge cases
- Redacted UUID exclusion
- Search match counting with empty query edge case
- Preset validation (conversation, debug)
- Category count computation with and without redactions
src/client/components/SessionList.test.tsx (7 tests):
- Two-phase navigation: project list → session list → back
- Auto-select project when selectedId matches
- Loading and empty states
- onSelect callback on session click
tests/fixtures/:
- sample-session.jsonl: representative session with all message types
- edge-cases.jsonl: corrupt lines interspersed with valid messages
- sessions-index.json: sample index file for discovery tests
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
4
tests/fixtures/edge-cases.jsonl
vendored
Normal file
4
tests/fixtures/edge-cases.jsonl
vendored
Normal file
@@ -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"}
|
||||
15
tests/fixtures/sample-session.jsonl
vendored
Normal file
15
tests/fixtures/sample-session.jsonl
vendored
Normal file
@@ -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":"<system-reminder>Remember to check environment variables</system-reminder>"},"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"}
|
||||
23
tests/fixtures/sessions-index.json
vendored
Normal file
23
tests/fixtures/sessions-index.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
142
tests/unit/filters.test.ts
Normal file
142
tests/unit/filters.test.ts
Normal file
@@ -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<MessageCategory>,
|
||||
redactedUuids: Set<string> = 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<MessageCategory>(["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<MessageCategory>(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<string, number> = {};
|
||||
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<string, number> = {};
|
||||
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);
|
||||
});
|
||||
});
|
||||
119
tests/unit/html-exporter.test.ts
Normal file
119
tests/unit/html-exporter.test.ts
Normal file
@@ -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<ParsedMessage> & { 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(/^<!DOCTYPE html>/);
|
||||
expect(html).toContain("</html>");
|
||||
});
|
||||
|
||||
it("contains no external URL references", async () => {
|
||||
const msg = makeMessage({ uuid: "msg-1", content: "Hello" });
|
||||
const html = await generateExportHtml(makeExportRequest([msg]));
|
||||
// No script src or link href pointing to external URLs
|
||||
expect(html).not.toMatch(/<script\s+src\s*=/i);
|
||||
expect(html).not.toMatch(/<link\s+.*href\s*=\s*"https?:\/\//i);
|
||||
});
|
||||
|
||||
it("includes visible messages as HTML", async () => {
|
||||
const msg = makeMessage({
|
||||
uuid: "msg-1",
|
||||
content: "Hello **world**",
|
||||
category: "assistant_text",
|
||||
});
|
||||
const html = await generateExportHtml(makeExportRequest([msg]));
|
||||
expect(html).toContain("<strong>world</strong>");
|
||||
});
|
||||
|
||||
it("excludes filtered messages", async () => {
|
||||
const msg1 = makeMessage({ uuid: "msg-1", content: "Visible" });
|
||||
const msg2 = makeMessage({ uuid: "msg-2", content: "Invisible" });
|
||||
const html = await generateExportHtml(
|
||||
makeExportRequest([msg1, msg2], ["msg-1"], [])
|
||||
);
|
||||
expect(html).toContain("Visible");
|
||||
expect(html).not.toContain("Invisible");
|
||||
});
|
||||
|
||||
it("excludes redacted messages", async () => {
|
||||
const msg1 = makeMessage({ uuid: "msg-1", content: "Kept" });
|
||||
const msg2 = makeMessage({ uuid: "msg-2", content: "Redacted" });
|
||||
const html = await generateExportHtml(
|
||||
makeExportRequest([msg1, msg2], ["msg-1", "msg-2"], ["msg-2"])
|
||||
);
|
||||
expect(html).toContain("Kept");
|
||||
expect(html).not.toContain("Redacted");
|
||||
});
|
||||
|
||||
it("includes redacted divider between non-redacted messages where redaction occurred", async () => {
|
||||
const msg1 = makeMessage({ uuid: "msg-1", content: "Before" });
|
||||
const msg2 = makeMessage({ uuid: "msg-2", content: "Middle removed" });
|
||||
const msg3 = makeMessage({ uuid: "msg-3", content: "After" });
|
||||
const html = await generateExportHtml(
|
||||
makeExportRequest(
|
||||
[msg1, msg2, msg3],
|
||||
["msg-1", "msg-2", "msg-3"],
|
||||
["msg-2"]
|
||||
)
|
||||
);
|
||||
expect(html).toContain("content redacted");
|
||||
expect(html).toContain("Before");
|
||||
expect(html).toContain("After");
|
||||
expect(html).not.toContain("Middle removed");
|
||||
});
|
||||
|
||||
it("renders markdown to HTML", async () => {
|
||||
const msg = makeMessage({
|
||||
uuid: "msg-1",
|
||||
content: "# Heading\n\n- item 1\n- item 2\n\n```js\nconst x = 1;\n```",
|
||||
});
|
||||
const html = await generateExportHtml(makeExportRequest([msg]));
|
||||
expect(html).toContain("<h1");
|
||||
expect(html).toContain("<li>");
|
||||
expect(html).toContain("<code");
|
||||
});
|
||||
|
||||
it("includes syntax highlighting CSS", async () => {
|
||||
const msg = makeMessage({ uuid: "msg-1", content: "```js\nconst x = 1;\n```" });
|
||||
const html = await generateExportHtml(makeExportRequest([msg]));
|
||||
expect(html).toContain(".hljs");
|
||||
});
|
||||
|
||||
it("includes session metadata header", async () => {
|
||||
const msg = makeMessage({ uuid: "msg-1", content: "Hello" });
|
||||
const html = await generateExportHtml(makeExportRequest([msg]));
|
||||
expect(html).toContain("test-project");
|
||||
expect(html).toContain("Session Export");
|
||||
expect(html).toContain("1 messages");
|
||||
});
|
||||
});
|
||||
552
tests/unit/sensitive-redactor.test.ts
Normal file
552
tests/unit/sensitive-redactor.test.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
redactSensitiveContent,
|
||||
redactMessage,
|
||||
redactString,
|
||||
} from "../../src/shared/sensitive-redactor.js";
|
||||
import type { ParsedMessage } from "../../src/shared/types.js";
|
||||
|
||||
describe("sensitive-redactor", () => {
|
||||
describe("redactSensitiveContent", () => {
|
||||
// --- Tier 1: Known Secret Formats ---
|
||||
|
||||
describe("AWS credentials", () => {
|
||||
it("redacts AWS access key IDs", () => {
|
||||
const input = "Use key AKIAIOSFODNN7EXAMPLE to authenticate";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[AWS_KEY]");
|
||||
expect(result.sanitized).not.toContain("AKIAIOSFODNN7EXAMPLE");
|
||||
expect(result.redactionCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("redacts ASIA temporary credentials", () => {
|
||||
const input = "Temporary creds: ASIA2EXAMPLEXEG4567Q";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[AWS_KEY]");
|
||||
});
|
||||
|
||||
it("redacts AWS Bedrock API keys", () => {
|
||||
const key = "ABSK" + "A".repeat(110);
|
||||
const input = "key: " + key;
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[AWS_BEDROCK_KEY]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GitHub tokens", () => {
|
||||
it("redacts GitHub PATs", () => {
|
||||
const token = "ghp_" + "a".repeat(36);
|
||||
const input = "token: " + token;
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[GITHUB_TOKEN]");
|
||||
expect(result.sanitized).not.toContain("ghp_");
|
||||
});
|
||||
|
||||
it("redacts GitHub fine-grained PATs", () => {
|
||||
const token = "github_pat_" + "a".repeat(82);
|
||||
const result = redactSensitiveContent(token);
|
||||
expect(result.sanitized).toContain("[GITHUB_TOKEN]");
|
||||
});
|
||||
|
||||
it("redacts GitHub app tokens (ghu_ and ghs_)", () => {
|
||||
const input = "ghu_" + "a".repeat(36) + " and ghs_" + "b".repeat(36);
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).not.toContain("ghu_");
|
||||
expect(result.sanitized).not.toContain("ghs_");
|
||||
expect(result.redactionCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GitLab tokens", () => {
|
||||
it("redacts GitLab PATs", () => {
|
||||
const input = "glpat-a1b2c3d4e5f6g7h8i9j0";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[GITLAB_TOKEN]");
|
||||
});
|
||||
|
||||
it("redacts GitLab runner tokens", () => {
|
||||
const input = "glrt-a1b2c3d4e5f6g7h8i9j0";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[GITLAB_TOKEN]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenAI keys", () => {
|
||||
it("redacts OpenAI project keys", () => {
|
||||
const input =
|
||||
"sk-proj-" + "a".repeat(58) + "T3BlbkFJ" + "b".repeat(58);
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[OPENAI_KEY]");
|
||||
expect(result.sanitized).not.toContain("sk-proj-");
|
||||
});
|
||||
|
||||
it("redacts legacy OpenAI keys", () => {
|
||||
const input = "sk-" + "a".repeat(20) + "T3BlbkFJ" + "b".repeat(20);
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[OPENAI_KEY]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Anthropic keys", () => {
|
||||
it("redacts Anthropic API keys", () => {
|
||||
const input = "sk-ant-api03-" + "a".repeat(93) + "AA";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[ANTHROPIC_KEY]");
|
||||
});
|
||||
|
||||
it("redacts Anthropic admin keys", () => {
|
||||
const input = "sk-ant-admin01-" + "a".repeat(93) + "AA";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[ANTHROPIC_KEY]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AI service tokens", () => {
|
||||
it("redacts HuggingFace tokens", () => {
|
||||
const input = "hf_" + "a".repeat(34);
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[HF_TOKEN]");
|
||||
});
|
||||
|
||||
it("redacts Perplexity keys", () => {
|
||||
const input = "pplx-" + "a".repeat(48);
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[PERPLEXITY_KEY]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Stripe keys", () => {
|
||||
it("redacts live Stripe secret keys", () => {
|
||||
const input = "sk_live_abcdefghijklmnopqrstuvwxyz1234";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[STRIPE_KEY]");
|
||||
expect(result.sanitized).not.toContain("sk_live_");
|
||||
});
|
||||
|
||||
it("redacts test Stripe keys", () => {
|
||||
const input = "sk_test_abcdefghijklmnop";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[STRIPE_KEY]");
|
||||
});
|
||||
|
||||
it("redacts restricted Stripe keys", () => {
|
||||
const input = "rk_live_abcdefghijklmnop";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[STRIPE_KEY]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Slack tokens", () => {
|
||||
it("redacts Slack bot tokens", () => {
|
||||
const input = "xoxb-1234567890123-1234567890123-abcdefghij";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[SLACK_TOKEN]");
|
||||
});
|
||||
|
||||
it("redacts Slack webhook URLs", () => {
|
||||
const input =
|
||||
"https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[SLACK_WEBHOOK]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SendGrid tokens", () => {
|
||||
it("redacts SendGrid API tokens", () => {
|
||||
const sgKey = "SG." + "a".repeat(22) + "." + "b".repeat(43);
|
||||
const result = redactSensitiveContent(sgKey);
|
||||
expect(result.sanitized).toContain("[SENDGRID_TOKEN]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GCP API keys", () => {
|
||||
it("redacts GCP API keys", () => {
|
||||
const input = "AIza" + "a".repeat(35);
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[GCP_KEY]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Heroku keys", () => {
|
||||
it("redacts Heroku API keys", () => {
|
||||
const input = "HRKU-AA" + "a".repeat(58);
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[HEROKU_KEY]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("npm tokens", () => {
|
||||
it("redacts npm access tokens", () => {
|
||||
const input = "npm_" + "a".repeat(36);
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[NPM_TOKEN]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PyPI tokens", () => {
|
||||
it("redacts PyPI upload tokens", () => {
|
||||
const input = "pypi-AgEIcHlwaS5vcmc" + "a".repeat(50);
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[PYPI_TOKEN]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sentry tokens", () => {
|
||||
it("redacts Sentry user tokens", () => {
|
||||
const input = "sntryu_" + "a".repeat(64);
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[SENTRY_TOKEN]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("JWT tokens", () => {
|
||||
it("redacts JWT tokens", () => {
|
||||
const header = Buffer.from('{"alg":"HS256"}').toString("base64url");
|
||||
const payload = Buffer.from('{"sub":"1234567890"}').toString(
|
||||
"base64url"
|
||||
);
|
||||
const sig = "abcdefghij";
|
||||
const jwt = header + "." + payload + "." + sig;
|
||||
const result = redactSensitiveContent("Bearer " + jwt);
|
||||
expect(result.sanitized).toContain("[JWT]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Private keys (PEM)", () => {
|
||||
it("redacts RSA private keys", () => {
|
||||
const input =
|
||||
"-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn" +
|
||||
"a".repeat(100) +
|
||||
"\n-----END RSA PRIVATE KEY-----";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[PRIVATE_KEY]");
|
||||
expect(result.sanitized).not.toContain("MIIEpAIBAAKCAQEA");
|
||||
});
|
||||
|
||||
it("redacts generic private keys", () => {
|
||||
const input =
|
||||
"-----BEGIN PRIVATE KEY-----\n" +
|
||||
"a".repeat(100) +
|
||||
"\n-----END PRIVATE KEY-----";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[PRIVATE_KEY]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Generic API key pattern", () => {
|
||||
it("redacts KEY=value assignments with secret-like variable names", () => {
|
||||
const input = 'SECRET_KEY="verySecretValue1234567"';
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.redactionCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("redacts api_key assignments", () => {
|
||||
const input = "api_key: abcdefghijklmnopqrst";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.redactionCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("redacts password assignments", () => {
|
||||
const input = 'password = "mySuperSecretPassword123"';
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.redactionCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Tier 2: PII/System Info ---
|
||||
|
||||
describe("Home directory paths", () => {
|
||||
it("redacts /home/username paths", () => {
|
||||
const input = "Reading file /home/taylor/.ssh/id_rsa";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[HOME_PATH]");
|
||||
expect(result.sanitized).not.toContain("/home/taylor");
|
||||
});
|
||||
|
||||
it("redacts /Users/username paths (macOS)", () => {
|
||||
const input = "Found at /Users/taylor/projects/app/src/index.ts";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[HOME_PATH]");
|
||||
expect(result.sanitized).not.toContain("/Users/taylor");
|
||||
});
|
||||
|
||||
it("redacts C:\\Users\\username paths (Windows)", () => {
|
||||
const input = "Path: C:\\Users\\Taylor\\Documents\\project";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[HOME_PATH]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Connection strings", () => {
|
||||
it("redacts PostgreSQL connection strings", () => {
|
||||
const input =
|
||||
"DATABASE_URL=postgresql://admin:secret@db.example.com:5432/mydb";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[CONNECTION_STRING]");
|
||||
expect(result.sanitized).not.toContain("admin:secret");
|
||||
});
|
||||
|
||||
it("redacts MongoDB connection strings", () => {
|
||||
const input = "mongodb+srv://user:pass@cluster.mongodb.net/db";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[CONNECTION_STRING]");
|
||||
});
|
||||
|
||||
it("redacts Redis connection strings", () => {
|
||||
const input = "redis://default:password@redis.example.com:6379";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[CONNECTION_STRING]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URLs with credentials", () => {
|
||||
it("redacts URLs with user:pass embedded", () => {
|
||||
const input = "https://admin:s3cret@api.example.com/v1/data";
|
||||
const result = redactSensitiveContent(input);
|
||||
// Could match URL_WITH_CREDS or CONNECTION_STRING
|
||||
expect(result.redactionCount).toBeGreaterThan(0);
|
||||
expect(result.sanitized).not.toContain("admin:s3cret");
|
||||
});
|
||||
|
||||
it("does NOT redact normal URLs without credentials", () => {
|
||||
const input = "https://api.example.com/v1/data";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Email addresses", () => {
|
||||
it("redacts email addresses", () => {
|
||||
const input = "Contact taylor@company.com for support";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[EMAIL]");
|
||||
expect(result.sanitized).not.toContain("taylor@company.com");
|
||||
});
|
||||
|
||||
it("does NOT redact example.com emails", () => {
|
||||
const input = "user@example.com is a placeholder";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toBe(input);
|
||||
});
|
||||
|
||||
it("does NOT redact test.com emails", () => {
|
||||
const input = "test@test.com for testing";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toBe(input);
|
||||
});
|
||||
|
||||
it("does NOT redact noreply@anthropic.com", () => {
|
||||
const input = "Co-Authored-By: Claude <noreply@anthropic.com>";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IPv4 addresses", () => {
|
||||
it("redacts non-localhost IPv4 addresses", () => {
|
||||
const input = "Server running at 10.0.1.55:8080";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[IP_ADDR]");
|
||||
expect(result.sanitized).not.toContain("10.0.1.55");
|
||||
});
|
||||
|
||||
it("does NOT redact 127.0.0.1", () => {
|
||||
const input = "Listening on 127.0.0.1:3000";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).not.toContain("[IP_ADDR]");
|
||||
expect(result.sanitized).toContain("127.0.0.1");
|
||||
});
|
||||
|
||||
it("does NOT redact 0.0.0.0", () => {
|
||||
const input = "Bind to 0.0.0.0:8080";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).not.toContain("[IP_ADDR]");
|
||||
});
|
||||
|
||||
it("does NOT redact RFC 5737 documentation IPs", () => {
|
||||
const input =
|
||||
"Example: 192.0.2.1 and 198.51.100.5 and 203.0.113.10";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).not.toContain("[IP_ADDR]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bearer tokens", () => {
|
||||
it("redacts Bearer tokens in auth headers", () => {
|
||||
const input =
|
||||
"Authorization: Bearer some_long_token_value_here.with.parts";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(
|
||||
result.sanitized.includes("[BEARER_TOKEN]") ||
|
||||
result.sanitized.includes("[JWT]")
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Environment variable secrets", () => {
|
||||
it("redacts SECRET_KEY assignments", () => {
|
||||
const input = "SECRET_KEY=abcdefghij1234567890";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[ENV_SECRET]");
|
||||
});
|
||||
|
||||
it("redacts DATABASE_PASSWORD assignments", () => {
|
||||
const input = 'DATABASE_PASSWORD="myDbPassw0rd"';
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.redactionCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- False Positive Resistance ---
|
||||
|
||||
describe("false positive resistance", () => {
|
||||
it("does NOT redact normal code content", () => {
|
||||
const input =
|
||||
"function add(a: number, b: number): number { return a + b; }";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toBe(input);
|
||||
expect(result.redactionCount).toBe(0);
|
||||
});
|
||||
|
||||
it("does NOT redact normal markdown text", () => {
|
||||
const input =
|
||||
"# Getting Started\n\nTo install, run `npm install express`.\n\n## Features\n- Fast routing";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toBe(input);
|
||||
});
|
||||
|
||||
it("does NOT redact short strings that could look like keys", () => {
|
||||
const input = "The sk value is 42";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toBe(input);
|
||||
});
|
||||
|
||||
it("does NOT redact normal file paths outside home dirs", () => {
|
||||
const input = "Edit /etc/nginx/nginx.conf or /var/log/syslog";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toBe(input);
|
||||
});
|
||||
|
||||
it("does NOT redact version numbers that look like IPs", () => {
|
||||
const input = "Using version 1.2.3 of the library";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toBe(input);
|
||||
});
|
||||
|
||||
it("preserves surrounding text when redacting", () => {
|
||||
const token = "ghp_" + "a".repeat(36);
|
||||
const input = "Before " + token + " after";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toMatch(
|
||||
/^Before \[GITHUB_TOKEN\] after$/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Edge Cases ---
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles empty string", () => {
|
||||
const result = redactSensitiveContent("");
|
||||
expect(result.sanitized).toBe("");
|
||||
expect(result.redactionCount).toBe(0);
|
||||
expect(result.categories).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles string with no sensitive content", () => {
|
||||
const input =
|
||||
"This is a perfectly normal message with no secrets.";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toBe(input);
|
||||
expect(result.redactionCount).toBe(0);
|
||||
});
|
||||
|
||||
it("handles multiple different secrets in one string", () => {
|
||||
const ghToken = "ghp_" + "a".repeat(36);
|
||||
const input = "Key: " + ghToken + " and also AKIAIOSFODNN7EXAMPLE";
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.sanitized).toContain("[GITHUB_TOKEN]");
|
||||
expect(result.sanitized).toContain("[AWS_KEY]");
|
||||
expect(result.redactionCount).toBe(2);
|
||||
});
|
||||
|
||||
it("tracks matched categories", () => {
|
||||
const input = "ghp_" + "a".repeat(36);
|
||||
const result = redactSensitiveContent(input);
|
||||
expect(result.categories.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactString", () => {
|
||||
it("returns just the sanitized string", () => {
|
||||
const input = "ghp_" + "a".repeat(36);
|
||||
const result = redactString(input);
|
||||
expect(result).toContain("[GITHUB_TOKEN]");
|
||||
expect(typeof result).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactMessage", () => {
|
||||
const baseMessage: ParsedMessage = {
|
||||
uuid: "msg-1",
|
||||
category: "assistant_text",
|
||||
content: "Check ghp_" + "a".repeat(36) + " for access",
|
||||
toolName: undefined,
|
||||
toolInput: undefined,
|
||||
timestamp: "2025-10-15T10:00:00Z",
|
||||
rawIndex: 0,
|
||||
};
|
||||
|
||||
it("redacts the content field", () => {
|
||||
const redacted = redactMessage(baseMessage);
|
||||
expect(redacted.content).toContain("[GITHUB_TOKEN]");
|
||||
expect(redacted.content).not.toContain("ghp_");
|
||||
});
|
||||
|
||||
it("returns a new object without mutating the original", () => {
|
||||
const originalContent = baseMessage.content;
|
||||
const redacted = redactMessage(baseMessage);
|
||||
expect(redacted).not.toBe(baseMessage);
|
||||
expect(baseMessage.content).toBe(originalContent);
|
||||
});
|
||||
|
||||
it("preserves uuid, category, timestamp, rawIndex", () => {
|
||||
const redacted = redactMessage(baseMessage);
|
||||
expect(redacted.uuid).toBe(baseMessage.uuid);
|
||||
expect(redacted.category).toBe(baseMessage.category);
|
||||
expect(redacted.timestamp).toBe(baseMessage.timestamp);
|
||||
expect(redacted.rawIndex).toBe(baseMessage.rawIndex);
|
||||
});
|
||||
|
||||
it("redacts toolInput field", () => {
|
||||
const msg: ParsedMessage = {
|
||||
...baseMessage,
|
||||
category: "tool_call",
|
||||
toolName: "Bash",
|
||||
toolInput: JSON.stringify({
|
||||
command: "export SECRET_KEY=abcdefghijklmnopqrstuvwxyz",
|
||||
}),
|
||||
};
|
||||
const redacted = redactMessage(msg);
|
||||
expect(redacted.toolInput).not.toBe(msg.toolInput);
|
||||
});
|
||||
|
||||
it("leaves toolName unchanged for standard tool names", () => {
|
||||
const msg: ParsedMessage = {
|
||||
...baseMessage,
|
||||
category: "tool_call",
|
||||
toolName: "Read",
|
||||
toolInput: '{"file_path": "/etc/config"}',
|
||||
};
|
||||
const redacted = redactMessage(msg);
|
||||
expect(redacted.toolName).toBe("Read");
|
||||
});
|
||||
|
||||
it("handles undefined toolInput and toolName", () => {
|
||||
const redacted = redactMessage(baseMessage);
|
||||
expect(redacted.toolInput).toBeUndefined();
|
||||
expect(redacted.toolName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
138
tests/unit/session-discovery.test.ts
Normal file
138
tests/unit/session-discovery.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { discoverSessions } from "../../src/server/services/session-discovery.js";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import os from "os";
|
||||
|
||||
/** Helper to write a sessions-index.json in the real { version, entries } format */
|
||||
function makeIndex(entries: Record<string, unknown>[]) {
|
||||
return JSON.stringify({ version: 1, entries });
|
||||
}
|
||||
|
||||
describe("session-discovery", () => {
|
||||
it("discovers sessions from { version, entries } format", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `sv-test-${Date.now()}`);
|
||||
const projectDir = path.join(tmpDir, "test-project");
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, "sessions-index.json"),
|
||||
makeIndex([
|
||||
{
|
||||
sessionId: "sess-001",
|
||||
fullPath: "/tmp/sess-001.jsonl",
|
||||
summary: "Test session",
|
||||
firstPrompt: "Hello",
|
||||
created: "2025-10-15T10:00:00Z",
|
||||
modified: "2025-10-15T11:00:00Z",
|
||||
messageCount: 5,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const sessions = await discoverSessions(tmpDir);
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].id).toBe("sess-001");
|
||||
expect(sessions[0].summary).toBe("Test session");
|
||||
expect(sessions[0].project).toBe("test-project");
|
||||
expect(sessions[0].messageCount).toBe(5);
|
||||
expect(sessions[0].path).toBe("/tmp/sess-001.jsonl");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("also handles legacy raw array format", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `sv-test-legacy-${Date.now()}`);
|
||||
const projectDir = path.join(tmpDir, "legacy-project");
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
|
||||
// Raw array (not wrapped in { version, entries })
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, "sessions-index.json"),
|
||||
JSON.stringify([
|
||||
{
|
||||
sessionId: "legacy-001",
|
||||
summary: "Legacy format",
|
||||
created: "2025-10-15T10:00:00Z",
|
||||
modified: "2025-10-15T11:00:00Z",
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const sessions = await discoverSessions(tmpDir);
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].id).toBe("legacy-001");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("handles missing projects directory gracefully", async () => {
|
||||
const sessions = await discoverSessions("/nonexistent/path");
|
||||
expect(sessions).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles corrupt index files gracefully", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `sv-test-corrupt-${Date.now()}`);
|
||||
const projectDir = path.join(tmpDir, "corrupt-project");
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, "sessions-index.json"),
|
||||
"not valid json {"
|
||||
);
|
||||
|
||||
const sessions = await discoverSessions(tmpDir);
|
||||
expect(sessions).toEqual([]);
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("aggregates across multiple project directories", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `sv-test-multi-${Date.now()}`);
|
||||
const proj1 = path.join(tmpDir, "project-a");
|
||||
const proj2 = path.join(tmpDir, "project-b");
|
||||
await fs.mkdir(proj1, { recursive: true });
|
||||
await fs.mkdir(proj2, { recursive: true });
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(proj1, "sessions-index.json"),
|
||||
makeIndex([{ sessionId: "a-001", created: "2025-01-01T00:00:00Z", modified: "2025-01-01T00:00:00Z" }])
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(proj2, "sessions-index.json"),
|
||||
makeIndex([{ sessionId: "b-001", created: "2025-01-02T00:00:00Z", modified: "2025-01-02T00:00:00Z" }])
|
||||
);
|
||||
|
||||
const sessions = await discoverSessions(tmpDir);
|
||||
expect(sessions).toHaveLength(2);
|
||||
const ids = sessions.map((s) => s.id);
|
||||
expect(ids).toContain("a-001");
|
||||
expect(ids).toContain("b-001");
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it("uses fullPath from index entry", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `sv-test-fp-${Date.now()}`);
|
||||
const projectDir = path.join(tmpDir, "fp-project");
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, "sessions-index.json"),
|
||||
makeIndex([
|
||||
{
|
||||
sessionId: "fp-001",
|
||||
fullPath: "/home/ubuntu/.claude/projects/xyz/fp-001.jsonl",
|
||||
created: "2025-10-15T10:00:00Z",
|
||||
modified: "2025-10-15T11:00:00Z",
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const sessions = await discoverSessions(tmpDir);
|
||||
expect(sessions[0].path).toBe(
|
||||
"/home/ubuntu/.claude/projects/xyz/fp-001.jsonl"
|
||||
);
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true });
|
||||
});
|
||||
});
|
||||
219
tests/unit/session-parser.test.ts
Normal file
219
tests/unit/session-parser.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseSessionContent } from "../../src/server/services/session-parser.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
describe("session-parser", () => {
|
||||
it("parses user messages with string content", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "user",
|
||||
message: { role: "user", content: "Hello world" },
|
||||
uuid: "u-1",
|
||||
timestamp: "2025-10-15T10:00:00Z",
|
||||
});
|
||||
const msgs = parseSessionContent(line);
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0].category).toBe("user_message");
|
||||
expect(msgs[0].content).toBe("Hello world");
|
||||
expect(msgs[0].uuid).toBe("u-1");
|
||||
expect(msgs[0].timestamp).toBe("2025-10-15T10:00:00Z");
|
||||
});
|
||||
|
||||
it("parses user messages with tool_result array content", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "toolu_01", content: "File contents here" },
|
||||
],
|
||||
},
|
||||
uuid: "u-2",
|
||||
});
|
||||
const msgs = parseSessionContent(line);
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0].category).toBe("tool_result");
|
||||
expect(msgs[0].content).toBe("File contents here");
|
||||
});
|
||||
|
||||
it("parses assistant text blocks", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Here is my response" }],
|
||||
},
|
||||
uuid: "a-1",
|
||||
});
|
||||
const msgs = parseSessionContent(line);
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0].category).toBe("assistant_text");
|
||||
expect(msgs[0].content).toBe("Here is my response");
|
||||
});
|
||||
|
||||
it("parses thinking blocks", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "Let me think about this..." }],
|
||||
},
|
||||
uuid: "a-2",
|
||||
});
|
||||
const msgs = parseSessionContent(line);
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0].category).toBe("thinking");
|
||||
expect(msgs[0].content).toBe("Let me think about this...");
|
||||
});
|
||||
|
||||
it("parses tool_use blocks", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", name: "Read", input: { file_path: "/src/index.ts" } },
|
||||
],
|
||||
},
|
||||
uuid: "a-3",
|
||||
});
|
||||
const msgs = parseSessionContent(line);
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0].category).toBe("tool_call");
|
||||
expect(msgs[0].toolName).toBe("Read");
|
||||
expect(msgs[0].toolInput).toContain("/src/index.ts");
|
||||
});
|
||||
|
||||
it("parses progress messages from data field", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "progress",
|
||||
data: { type: "hook", hookEvent: "PreToolUse", hookName: "security_check" },
|
||||
uuid: "p-1",
|
||||
timestamp: "2025-10-15T10:00:00Z",
|
||||
});
|
||||
const msgs = parseSessionContent(line);
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0].category).toBe("hook_progress");
|
||||
expect(msgs[0].content).toContain("PreToolUse");
|
||||
expect(msgs[0].content).toContain("security_check");
|
||||
});
|
||||
|
||||
it("parses file-history-snapshot messages", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "file-history-snapshot",
|
||||
messageId: "snap-1",
|
||||
snapshot: { messageId: "snap-1", trackedFileBackups: [], timestamp: "2025-10-15T10:00:00Z" },
|
||||
});
|
||||
const msgs = parseSessionContent(line);
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0].category).toBe("file_snapshot");
|
||||
});
|
||||
|
||||
it("parses summary messages from summary field", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "summary",
|
||||
summary: "Session summary here",
|
||||
leafUuid: "msg-10",
|
||||
});
|
||||
const msgs = parseSessionContent(line);
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0].category).toBe("summary");
|
||||
expect(msgs[0].content).toBe("Session summary here");
|
||||
});
|
||||
|
||||
it("skips system metadata lines (turn_duration)", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "system",
|
||||
subtype: "turn_duration",
|
||||
durationMs: 50000,
|
||||
uuid: "sys-1",
|
||||
});
|
||||
const msgs = parseSessionContent(line);
|
||||
expect(msgs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips queue-operation lines", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "queue-operation",
|
||||
uuid: "qo-1",
|
||||
});
|
||||
const msgs = parseSessionContent(line);
|
||||
expect(msgs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("detects system-reminder content in user messages", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: "<system-reminder>Some reminder</system-reminder>",
|
||||
},
|
||||
uuid: "u-sr",
|
||||
});
|
||||
const msgs = parseSessionContent(line);
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0].category).toBe("system_message");
|
||||
});
|
||||
|
||||
it("skips malformed JSONL lines without crashing", () => {
|
||||
const content = [
|
||||
"not valid json",
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
message: { role: "user", content: "Valid message" },
|
||||
uuid: "u-valid",
|
||||
}),
|
||||
"{broken}",
|
||||
].join("\n");
|
||||
const msgs = parseSessionContent(content);
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0].content).toBe("Valid message");
|
||||
});
|
||||
|
||||
it("returns empty array for empty files", () => {
|
||||
const msgs = parseSessionContent("");
|
||||
expect(msgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses uuid from the JSONL line, not random", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "user",
|
||||
message: { role: "user", content: "Test" },
|
||||
uuid: "my-specific-uuid-123",
|
||||
});
|
||||
const msgs = parseSessionContent(line);
|
||||
expect(msgs[0].uuid).toBe("my-specific-uuid-123");
|
||||
});
|
||||
|
||||
it("parses the full sample session fixture", async () => {
|
||||
const fixturePath = path.join(
|
||||
__dirname,
|
||||
"../fixtures/sample-session.jsonl"
|
||||
);
|
||||
const content = await fs.readFile(fixturePath, "utf-8");
|
||||
const msgs = parseSessionContent(content);
|
||||
|
||||
const categories = new Set(msgs.map((m) => m.category));
|
||||
expect(categories.has("user_message")).toBe(true);
|
||||
expect(categories.has("assistant_text")).toBe(true);
|
||||
expect(categories.has("thinking")).toBe(true);
|
||||
expect(categories.has("tool_call")).toBe(true);
|
||||
expect(categories.has("tool_result")).toBe(true);
|
||||
expect(categories.has("system_message")).toBe(true);
|
||||
expect(categories.has("hook_progress")).toBe(true);
|
||||
expect(categories.has("summary")).toBe(true);
|
||||
expect(categories.has("file_snapshot")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles edge-cases fixture (corrupt lines)", async () => {
|
||||
const fixturePath = path.join(
|
||||
__dirname,
|
||||
"../fixtures/edge-cases.jsonl"
|
||||
);
|
||||
const content = await fs.readFile(fixturePath, "utf-8");
|
||||
const msgs = parseSessionContent(content);
|
||||
expect(msgs).toHaveLength(2);
|
||||
expect(msgs[0].category).toBe("user_message");
|
||||
expect(msgs[1].category).toBe("assistant_text");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user