diff --git a/.gitignore b/.gitignore index 4cc9e64..80c34c3 100644 --- a/.gitignore +++ b/.gitignore @@ -152,6 +152,7 @@ packages/cli/*.jsonl packages/shared/dist packages/web/build packages/web/.svelte-kit +packages/web/test-results # Rover .rover/tasks/ diff --git a/package.json b/package.json index cb77e72..04deb67 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "scripts": { "dev": "pnpm -C packages/web dev", "build": "pnpm -C packages/web build", + "test": "pnpm -r run test", + "test:e2e": "pnpm -r run test:e2e", "lint": "biome lint --write", "format": "biome format --write", "postinstall": "lefthook install" diff --git a/packages/cli/package.json b/packages/cli/package.json index fac1998..a8bc533 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,7 +10,9 @@ "files": ["dist", "web", "README", "LICENSE"], "scripts": { "dev": "tsx src/index.ts", - "build": "tsup" + "build": "tsup", + "test": "vitest run", + "test:e2e": "vitest run --config vitest.config.e2e.ts" }, "dependencies": { "@clack/prompts": "^0.10.0", @@ -19,8 +21,9 @@ "devDependencies": { "@endorhq/capsule-shared": "workspace:*", "@endorhq/capsule-web": "workspace:*", - "typescript": "^5.9.3", "tsup": "^8.0.0", - "tsx": "^4.0.0" + "tsx": "^4.0.0", + "typescript": "^5.9.3", + "vitest": "^4.1.0" } } diff --git a/packages/cli/tests/e2e/anonymize.test.ts b/packages/cli/tests/e2e/anonymize.test.ts new file mode 100644 index 0000000..e0fac41 --- /dev/null +++ b/packages/cli/tests/e2e/anonymize.test.ts @@ -0,0 +1,235 @@ +// ⚠️ AUTO-GENERATED — DO NOT EDIT +// Source: packages/cli/E2E_TESTS.md — Suite: Anonymization +// Generator: /fp-generate + +import { describe, expect, it } from 'vitest'; +import { + type AnonymizeOptions, + anonymize, + DEFAULT_OPTIONS, +} from '../../src/anonymize.js'; +import { readFixture } from './e2e-utils.js'; + +describe('Anonymization', () => { + // category: core + it('Claude: mask file paths replaces real paths with generic ones', async () => { + const content = await readFixture('claude-simple.jsonl'); + const options: AnonymizeOptions = { + ...DEFAULT_OPTIONS, + maskFilePaths: true, + }; + + const result = anonymize(content, 'claude', options); + + // Original paths should be replaced + expect(result).not.toContain('/home/user/projects/myapp'); + + // Masked paths should follow /project/src/file{N}{ext} pattern + expect(result).toMatch(/\/project\/src\/file\d+/); + + // Verify the cwd field is masked + const lines = result.split('\n').filter(l => l.trim()); + for (const line of lines) { + const entry = JSON.parse(line); + if (entry.cwd) { + expect(entry.cwd).toMatch(/^\/project\/src\//); + } + } + }); + + // category: core + it('Claude: remove thinking blocks strips thinking content', async () => { + const content = await readFixture('claude-with-thinking.jsonl'); + const options: AnonymizeOptions = { + ...DEFAULT_OPTIONS, + removeThinking: true, + }; + + const result = anonymize(content, 'claude', options); + + // Parse each line and check no thinking blocks remain + const lines = result.split('\n').filter(l => l.trim()); + for (const line of lines) { + const entry = JSON.parse(line); + if (entry.type === 'assistant' && entry.message?.content) { + const blocks = entry.message.content as Array<{ type: string }>; + for (const block of blocks) { + expect(block.type).not.toBe('thinking'); + } + } + } + }); + + // category: core + it('Claude: remove tool outputs strips tool_result content', async () => { + const content = await readFixture('claude-simple.jsonl'); + const options: AnonymizeOptions = { + ...DEFAULT_OPTIONS, + removeToolOutputs: true, + }; + + const result = anonymize(content, 'claude', options); + + const lines = result.split('\n').filter(l => l.trim()); + for (const line of lines) { + const entry = JSON.parse(line); + if (entry.type === 'user' && Array.isArray(entry.message?.content)) { + for (const block of entry.message.content) { + if (block.type === 'tool_result') { + expect(block.content).toBe('[removed]'); + } + } + } + // toolUseResult should be removed + if (entry.type === 'user') { + expect(entry.toolUseResult).toBeUndefined(); + } + } + }); + + // category: core + it('Codex: remove reasoning items filters reasoning entries', async () => { + const content = await readFixture('codex-simple.jsonl'); + const options: AnonymizeOptions = { + ...DEFAULT_OPTIONS, + removeThinking: true, + }; + + const result = anonymize(content, 'codex', options); + + const lines = result.split('\n').filter(l => l.trim()); + for (const line of lines) { + const entry = JSON.parse(line); + if (entry.type === 'response_item' && entry.payload) { + expect(entry.payload.type).not.toBe('reasoning'); + } + } + }); + + // category: core + it('Copilot: mask session context sanitizes cwd, branch, repository', async () => { + const content = await readFixture('copilot-simple.jsonl'); + const options: AnonymizeOptions = { + ...DEFAULT_OPTIONS, + maskFilePaths: true, + maskGitInfo: true, + }; + + const result = anonymize(content, 'copilot', options); + + const lines = result.split('\n').filter(l => l.trim()); + for (const line of lines) { + const entry = JSON.parse(line); + if (entry.type === 'session.start' && entry.data?.context) { + const ctx = entry.data.context; + // cwd should be masked + if (ctx.cwd) { + expect(ctx.cwd).not.toContain('/home/user'); + expect(ctx.cwd).toMatch(/^\/project\/src\//); + } + // branch should be masked + if (ctx.branch) { + expect(ctx.branch).toMatch(/^branch-\d+$/); + } + // repository should be masked + if (ctx.repository) { + expect(ctx.repository).toMatch( + /^https:\/\/github\.com\/user\/repo-\d+\.git$/ + ); + } + } + } + }); + + // category: core + it('Gemini: anonymization produces valid JSON', async () => { + const content = await readFixture('gemini-simple.json'); + const options: AnonymizeOptions = { + ...DEFAULT_OPTIONS, + removeToolOutputs: true, + maskFilePaths: true, + removeThinking: true, + removeTokenUsage: true, + }; + + const result = anonymize(content, 'gemini', options); + + // Must be valid JSON + expect(() => JSON.parse(result)).not.toThrow(); + + const parsed = JSON.parse(result); + expect(parsed.messages).toBeDefined(); + expect(Array.isArray(parsed.messages)).toBe(true); + + // Verify no original paths + expect(result).not.toContain('/home/user/projects/myapp'); + + // Verify no token data + for (const msg of parsed.messages) { + expect(msg.tokens).toBeUndefined(); + expect(msg.thoughts).toBeUndefined(); + } + + // Pretty-printed (2-space indent) + expect(result).toContain(' '); + }); + + // category: edge + it('path masker produces consistent mappings across occurrences', async () => { + const content = await readFixture('claude-simple.jsonl'); + const options: AnonymizeOptions = { + ...DEFAULT_OPTIONS, + maskFilePaths: true, + }; + + const result = anonymize(content, 'claude', options); + + // The path /home/user/projects/myapp appears in multiple lines + // All occurrences should map to the same masked path + const lines = result.split('\n').filter(l => l.trim()); + const maskedPaths = new Set(); + + for (const line of lines) { + const entry = JSON.parse(line); + if (entry.cwd) { + maskedPaths.add(entry.cwd); + } + } + + // All cwd entries should be the same masked value + expect(maskedPaths.size).toBe(1); + }); + + // category: edge + it('git masker handles branch names and repo URLs', async () => { + const content = await readFixture('codex-simple.jsonl'); + const options: AnonymizeOptions = { + ...DEFAULT_OPTIONS, + maskGitInfo: true, + }; + + const result = anonymize(content, 'codex', options); + + const lines = result.split('\n').filter(l => l.trim()); + for (const line of lines) { + const entry = JSON.parse(line); + if (entry.type === 'session_meta' && entry.payload?.git) { + const git = entry.payload.git; + if (git.branch) { + expect(git.branch).toMatch(/^branch-\d+$/); + } + if (git.repository_url) { + expect(git.repository_url).toMatch( + /^https:\/\/github\.com\/user\/repo-\d+\.git$/ + ); + } + } + } + + // Original values should not appear + expect(result).not.toContain('develop'); + expect(result).not.toContain('github.com/user/myapp.git'); + }); +}); + +// ⚠️ AUTO-GENERATED — DO NOT EDIT diff --git a/packages/cli/tests/e2e/discovery.test.ts b/packages/cli/tests/e2e/discovery.test.ts new file mode 100644 index 0000000..85cc597 --- /dev/null +++ b/packages/cli/tests/e2e/discovery.test.ts @@ -0,0 +1,276 @@ +// ⚠️ AUTO-GENERATED — DO NOT EDIT +// Source: packages/cli/E2E_TESTS.md — Suite: Discovery +// Generator: /fp-generate + +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createClaudeSession, + createCodexSession, + createCopilotSession, + createGeminiSession, + createMockHome, +} from './e2e-utils.js'; + +// We need to mock homedir() to point to our temp directory +// The discovery module uses homedir() from 'node:os' +vi.mock('node:os', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + homedir: () => mockHomeDir, + }; +}); + +let mockHomeDir: string; +let cleanupHome: (() => Promise) | undefined; + +// Re-import after mock is set up +async function getDiscovery() { + // Clear the module cache to pick up the new mock + const mod = await import('@endorhq/capsule-shared/discovery'); + return mod; +} + +describe('Discovery', () => { + beforeEach(async () => { + const { home, cleanup } = await createMockHome(); + mockHomeDir = home; + cleanupHome = cleanup; + }); + + afterEach(async () => { + if (cleanupHome) { + await cleanupHome(); + cleanupHome = undefined; + } + vi.restoreAllMocks(); + }); + + // category: core + it('discovers Claude sessions from ~/.claude/projects/', async () => { + const claudeContent = [ + JSON.stringify({ + sessionId: 'claude-test-001', + type: 'user', + timestamp: '2025-01-15T10:00:00Z', + cwd: '/home/user/projects/test', + message: { role: 'user', content: 'Hello Claude' }, + }), + JSON.stringify({ + sessionId: 'claude-test-001', + type: 'assistant', + timestamp: '2025-01-15T10:00:05Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Hi!' }], + }, + }), + ].join('\n'); + + await createClaudeSession( + mockHomeDir, + 'my-project', + 'session.jsonl', + claudeContent + ); + + const { discoverAllSessions } = await getDiscovery(); + const sources = await discoverAllSessions(); + + const claudeSource = sources.find(s => s.agent === 'claude'); + expect(claudeSource).toBeDefined(); + expect(claudeSource?.sessions.length).toBeGreaterThanOrEqual(1); + + const session = claudeSource?.sessions[0]; + expect(session.agent).toBe('claude'); + expect(session.title).toContain('Hello Claude'); + expect(session.date).toBeInstanceOf(Date); + expect(session.cwd).toBe('/home/user/projects/test'); + }); + + // category: core + it('discovers Codex sessions from ~/.codex/sessions/', async () => { + const codexContent = [ + JSON.stringify({ + type: 'session_meta', + timestamp: '2025-01-15T10:00:00Z', + payload: { + id: 'codex-test-001', + originator: 'codex-cli', + cwd: '/home/user/projects/test', + }, + }), + JSON.stringify({ + type: 'event_msg', + timestamp: '2025-01-15T10:00:01Z', + payload: { type: 'user_message', message: 'Fix the bug' }, + }), + ].join('\n'); + + await createCodexSession(mockHomeDir, 'session.jsonl', codexContent); + + const { discoverAllSessions } = await getDiscovery(); + const sources = await discoverAllSessions(); + + const codexSource = sources.find(s => s.agent === 'codex'); + expect(codexSource).toBeDefined(); + expect(codexSource?.sessions.length).toBeGreaterThanOrEqual(1); + + const session = codexSource?.sessions[0]; + expect(session.agent).toBe('codex'); + expect(session.title).toContain('Fix the bug'); + }); + + // category: core + it('discovers Copilot sessions from ~/.copilot/session-state/', async () => { + const copilotContent = [ + JSON.stringify({ + type: 'session.start', + timestamp: '2025-01-15T10:00:00Z', + data: { + sessionId: 'copilot-test-001', + startTime: '2025-01-15T10:00:00Z', + context: { cwd: '/home/user/projects/test' }, + }, + }), + JSON.stringify({ + type: 'user.message', + timestamp: '2025-01-15T10:00:01Z', + data: { content: 'Refactor auth' }, + }), + ].join('\n'); + + await createCopilotSession(mockHomeDir, 'copilot-test-001', copilotContent); + + const { discoverAllSessions } = await getDiscovery(); + const sources = await discoverAllSessions(); + + const copilotSource = sources.find(s => s.agent === 'copilot'); + expect(copilotSource).toBeDefined(); + expect(copilotSource?.sessions.length).toBeGreaterThanOrEqual(1); + + const session = copilotSource?.sessions[0]; + expect(session.agent).toBe('copilot'); + expect(session.sessionId).toBe('copilot-test-001'); + }); + + // category: core + it('discovers Gemini sessions from ~/.gemini/tmp/', async () => { + const geminiContent = JSON.stringify({ + startTime: '2025-01-15T10:00:00Z', + sessionId: 'gemini-test-001', + projectHash: 'test123', + messages: [ + { + type: 'user', + timestamp: '2025-01-15T10:00:00Z', + content: 'Create component', + }, + ], + }); + + await createGeminiSession(mockHomeDir, 'session-test.json', geminiContent); + + const { discoverAllSessions } = await getDiscovery(); + const sources = await discoverAllSessions(); + + const geminiSource = sources.find(s => s.agent === 'gemini'); + expect(geminiSource).toBeDefined(); + expect(geminiSource?.sessions.length).toBeGreaterThanOrEqual(1); + + const session = geminiSource?.sessions[0]; + expect(session.agent).toBe('gemini'); + expect(session.title).toContain('Create component'); + }); + + // category: edge + it('returns empty list for missing directories', async () => { + // mockHomeDir is empty — no agent directories exist + const { discoverAllSessions } = await getDiscovery(); + const sources = await discoverAllSessions(); + + expect(sources).toEqual([]); + }); + + // category: error + it('skips malformed session files', async () => { + // Create a Claude project dir with a corrupted file + const dir = join(mockHomeDir, '.claude', 'projects', 'broken'); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, 'bad.jsonl'), + 'not valid json at all\n{broken', + 'utf-8' + ); + + // Also create a valid session + const validContent = [ + JSON.stringify({ + sessionId: 'valid-001', + type: 'user', + timestamp: '2025-01-15T10:00:00Z', + cwd: '/test', + message: { role: 'user', content: 'Valid session' }, + }), + ].join('\n'); + await createClaudeSession( + mockHomeDir, + 'good-project', + 'session.jsonl', + validContent + ); + + const { discoverAllSessions } = await getDiscovery(); + const sources = await discoverAllSessions(); + + const claudeSource = sources.find(s => s.agent === 'claude'); + // Should have at least the valid session; bad one should be skipped + if (claudeSource) { + for (const session of claudeSource.sessions) { + expect(session.date).toBeInstanceOf(Date); + expect(session.title).toBeDefined(); + } + } + }); + + // category: side-effect + it('sorts sessions by date descending', async () => { + const older = [ + JSON.stringify({ + sessionId: 'older', + type: 'user', + timestamp: '2025-01-10T10:00:00Z', + cwd: '/test', + message: { role: 'user', content: 'Older session' }, + }), + ].join('\n'); + + const newer = [ + JSON.stringify({ + sessionId: 'newer', + type: 'user', + timestamp: '2025-01-20T10:00:00Z', + cwd: '/test', + message: { role: 'user', content: 'Newer session' }, + }), + ].join('\n'); + + await createClaudeSession(mockHomeDir, 'proj-old', 'old.jsonl', older); + await createClaudeSession(mockHomeDir, 'proj-new', 'new.jsonl', newer); + + const { discoverAllSessions } = await getDiscovery(); + const sources = await discoverAllSessions(); + + const claudeSource = sources.find(s => s.agent === 'claude'); + expect(claudeSource).toBeDefined(); + expect(claudeSource?.sessions.length).toBe(2); + + // First session should be newer + const [first, second] = claudeSource?.sessions ?? []; + expect(first.date.getTime()).toBeGreaterThan(second.date.getTime()); + }); +}); + +// ⚠️ AUTO-GENERATED — DO NOT EDIT diff --git a/packages/cli/tests/e2e/e2e-utils.ts b/packages/cli/tests/e2e/e2e-utils.ts new file mode 100644 index 0000000..c6b20b0 --- /dev/null +++ b/packages/cli/tests/e2e/e2e-utils.ts @@ -0,0 +1,132 @@ +// ⚠️ AUTO-GENERATED — DO NOT EDIT +// Source: packages/cli/E2E_TESTS.md +// Generator: /fp-generate + +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +export const FIXTURES_DIR = join(__dirname, 'fixtures'); + +export async function readFixture(name: string): Promise { + return readFile(join(FIXTURES_DIR, name), 'utf-8'); +} + +export async function createTempDir(): Promise { + return mkdtemp(join(tmpdir(), 'capsule-test-')); +} + +export async function safeCleanup(dir: string): Promise { + try { + await rm(dir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } +} + +/** + * Create a mock `gh` CLI script in a temp directory. + * Returns the directory to prepend to $PATH. + */ +export async function createMockGh( + behavior: 'auth-ok' | 'auth-fail' | 'gist-create' +): Promise<{ dir: string; cleanup: () => Promise }> { + const dir = await createTempDir(); + const script = join(dir, 'gh'); + + let content: string; + switch (behavior) { + case 'auth-ok': + content = + '#!/bin/sh\nif [ "$1" = "auth" ] && [ "$2" = "status" ]; then\n echo "Logged in"\n exit 0\nfi\nexit 1\n'; + break; + case 'auth-fail': + content = + '#!/bin/sh\nif [ "$1" = "auth" ] && [ "$2" = "status" ]; then\n echo "Not logged in" >&2\n exit 1\nfi\nexit 1\n'; + break; + case 'gist-create': + content = + '#!/bin/sh\nif [ "$1" = "auth" ] && [ "$2" = "status" ]; then\n echo "Logged in"\n exit 0\nfi\nif [ "$1" = "gist" ] && [ "$2" = "create" ]; then\n echo "https://gist.github.com/abc123def456"\n exit 0\nfi\nexit 1\n'; + break; + } + + await writeFile(script, content, { mode: 0o755 }); + return { dir, cleanup: () => safeCleanup(dir) }; +} + +/** + * Create a fake $HOME with agent session directories for discovery testing. + */ +export async function createMockHome(): Promise<{ + home: string; + cleanup: () => Promise; +}> { + const home = await createTempDir(); + return { home, cleanup: () => safeCleanup(home) }; +} + +/** + * Create a Claude session file in the mock home directory. + */ +export async function createClaudeSession( + home: string, + project: string, + filename: string, + content: string +): Promise { + const dir = join(home, '.claude', 'projects', project); + await mkdir(dir, { recursive: true }); + const filePath = join(dir, filename); + await writeFile(filePath, content, 'utf-8'); + return filePath; +} + +/** + * Create a Codex session file in the mock home directory. + */ +export async function createCodexSession( + home: string, + filename: string, + content: string +): Promise { + const dir = join(home, '.codex', 'sessions'); + await mkdir(dir, { recursive: true }); + const filePath = join(dir, filename); + await writeFile(filePath, content, 'utf-8'); + return filePath; +} + +/** + * Create a Copilot session file in the mock home directory. + */ +export async function createCopilotSession( + home: string, + sessionId: string, + content: string +): Promise { + const dir = join(home, '.copilot', 'session-state', sessionId); + await mkdir(dir, { recursive: true }); + const filePath = join(dir, 'events.jsonl'); + await writeFile(filePath, content, 'utf-8'); + return filePath; +} + +/** + * Create a Gemini session file in the mock home directory. + */ +export async function createGeminiSession( + home: string, + filename: string, + content: string +): Promise { + const dir = join(home, '.gemini', 'tmp'); + await mkdir(dir, { recursive: true }); + const filePath = join(dir, filename); + await writeFile(filePath, content, 'utf-8'); + return filePath; +} + +// ⚠️ AUTO-GENERATED — DO NOT EDIT diff --git a/packages/cli/tests/e2e/export.test.ts b/packages/cli/tests/e2e/export.test.ts new file mode 100644 index 0000000..e0cf621 --- /dev/null +++ b/packages/cli/tests/e2e/export.test.ts @@ -0,0 +1,166 @@ +// ⚠️ AUTO-GENERATED — DO NOT EDIT +// Source: packages/cli/E2E_TESTS.md — Suite: Export +// Generator: /fp-generate + +import { readFile, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { type AnonymizeOptions, anonymize } from '../../src/anonymize.js'; +import { saveToFile } from '../../src/publish.js'; +import { createTempDir, readFixture, safeCleanup } from './e2e-utils.js'; + +describe('Export', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await createTempDir(); + }); + + afterEach(async () => { + await safeCleanup(tempDir); + }); + + // category: core + it('exports session to specified path without anonymization', async () => { + const content = await readFixture('claude-simple.jsonl'); + const outputPath = join(tempDir, 'output.jsonl'); + + await saveToFile(content, outputPath); + + const written = await readFile(outputPath, 'utf-8'); + expect(written).toBe(content); + + const fileStat = await stat(outputPath); + expect(fileStat.isFile()).toBe(true); + }); + + // category: core + it('exports with all anonymization options producing sanitized output', async () => { + const content = await readFixture('claude-simple.jsonl'); + const options: AnonymizeOptions = { + removeToolOutputs: true, + maskFilePaths: true, + removeFileContents: true, + removeThinking: true, + removeSystemMessages: true, + removeTokenUsage: true, + maskGitInfo: true, + }; + + const anonymized = anonymize(content, 'claude', options); + const outputPath = join(tempDir, 'anonymized.jsonl'); + await saveToFile(anonymized, outputPath); + + const written = await readFile(outputPath, 'utf-8'); + + // Verify no original paths remain + expect(written).not.toContain('/home/user/projects/myapp'); + + // Verify masked paths are present + expect(written).toContain('/project/src/'); + + // Verify tool outputs are removed + expect(written).toContain('[removed]'); + + // Verify token usage is removed from messages + const lines = written.split('\n').filter(l => l.trim()); + for (const line of lines) { + const entry = JSON.parse(line); + if (entry.message?.usage) { + expect(entry.message.usage).toBeUndefined(); + } + } + }); + + // category: core + it('preserves JSONL format integrity for JSONL formats', async () => { + const formats = [ + { file: 'claude-simple.jsonl', format: 'claude' as const }, + { file: 'codex-simple.jsonl', format: 'codex' as const }, + { file: 'copilot-simple.jsonl', format: 'copilot' as const }, + ]; + + for (const { file, format } of formats) { + const content = await readFixture(file); + const anonymized = anonymize(content, format, { + removeToolOutputs: false, + maskFilePaths: true, + removeFileContents: false, + removeThinking: false, + removeSystemMessages: false, + removeTokenUsage: false, + maskGitInfo: false, + }); + + const lines = anonymized.split('\n').filter(l => l.trim()); + for (const line of lines) { + expect(() => JSON.parse(line)).not.toThrow(); + } + } + }); + + // category: core + it('preserves JSON format integrity for Gemini', async () => { + const content = await readFixture('gemini-simple.json'); + const anonymized = anonymize(content, 'gemini', { + removeToolOutputs: false, + maskFilePaths: true, + removeFileContents: false, + removeThinking: false, + removeSystemMessages: false, + removeTokenUsage: false, + maskGitInfo: false, + }); + + expect(() => JSON.parse(anonymized)).not.toThrow(); + const parsed = JSON.parse(anonymized); + expect(parsed.messages).toBeDefined(); + expect(Array.isArray(parsed.messages)).toBe(true); + }); + + // category: edge + it('exports minimal session without corruption', async () => { + const content = await readFixture('claude-minimal.jsonl'); + const outputPath = join(tempDir, 'minimal.jsonl'); + + await saveToFile(content, outputPath); + + const written = await readFile(outputPath, 'utf-8'); + expect(written.trim().length).toBeGreaterThan(0); + + const lines = written.split('\n').filter(l => l.trim()); + expect(lines.length).toBeGreaterThanOrEqual(1); + for (const line of lines) { + expect(() => JSON.parse(line)).not.toThrow(); + } + }); + + // category: error + it('fails gracefully when writing to non-existent directory', async () => { + const content = await readFixture('claude-simple.jsonl'); + const badPath = '/nonexistent/path/output.jsonl'; + + await expect(saveToFile(content, badPath)).rejects.toThrow(); + }); + + // category: idempotency + it('produces identical output for the same input', async () => { + const content = await readFixture('claude-simple.jsonl'); + const options: AnonymizeOptions = { + removeToolOutputs: true, + maskFilePaths: true, + removeFileContents: false, + removeThinking: false, + removeSystemMessages: false, + removeTokenUsage: false, + maskGitInfo: false, + }; + + const output1 = anonymize(content, 'claude', options); + const output2 = anonymize(content, 'claude', options); + + expect(output1).toBe(output2); + }); +}); + +// ⚠️ AUTO-GENERATED — DO NOT EDIT diff --git a/packages/cli/tests/e2e/fixtures/claude-minimal.jsonl b/packages/cli/tests/e2e/fixtures/claude-minimal.jsonl new file mode 100644 index 0000000..7383ec6 --- /dev/null +++ b/packages/cli/tests/e2e/fixtures/claude-minimal.jsonl @@ -0,0 +1 @@ +{"sessionId":"sess-min","type":"user","timestamp":"2025-01-15T10:00:00Z","cwd":"/home/user/projects/myapp","message":{"role":"user","content":"Hi"}} diff --git a/packages/cli/tests/e2e/fixtures/claude-simple.jsonl b/packages/cli/tests/e2e/fixtures/claude-simple.jsonl new file mode 100644 index 0000000..54e2f54 --- /dev/null +++ b/packages/cli/tests/e2e/fixtures/claude-simple.jsonl @@ -0,0 +1,6 @@ +{"sessionId":"sess-001","type":"user","timestamp":"2025-01-15T10:00:00Z","cwd":"/home/user/projects/myapp","gitBranch":"main","message":{"role":"user","content":"Hello, can you help me with my project?"}} +{"sessionId":"sess-001","type":"assistant","timestamp":"2025-01-15T10:00:05Z","cwd":"/home/user/projects/myapp","message":{"role":"assistant","content":[{"type":"text","text":"Sure! I'd be happy to help with your project at /home/user/projects/myapp. What do you need?"}],"usage":{"input_tokens":50,"output_tokens":30,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} +{"sessionId":"sess-001","type":"user","timestamp":"2025-01-15T10:01:00Z","cwd":"/home/user/projects/myapp","message":{"role":"user","content":"Please read the file /home/user/projects/myapp/src/index.ts"}} +{"sessionId":"sess-001","type":"assistant","timestamp":"2025-01-15T10:01:05Z","cwd":"/home/user/projects/myapp","message":{"role":"assistant","content":[{"type":"tool_use","id":"tool-001","name":"Read","input":{"file_path":"/home/user/projects/myapp/src/index.ts"}}],"usage":{"input_tokens":80,"output_tokens":40,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} +{"sessionId":"sess-001","type":"user","timestamp":"2025-01-15T10:01:06Z","cwd":"/home/user/projects/myapp","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-001","content":"export function main() {\n console.log('hello');\n}"}]},"toolUseResult":{"name":"Read","exitCode":0}} +{"sessionId":"sess-001","type":"assistant","timestamp":"2025-01-15T10:01:10Z","cwd":"/home/user/projects/myapp","message":{"role":"assistant","content":[{"type":"text","text":"I can see the file. It exports a main function."}],"usage":{"input_tokens":100,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} diff --git a/packages/cli/tests/e2e/fixtures/claude-system.jsonl b/packages/cli/tests/e2e/fixtures/claude-system.jsonl new file mode 100644 index 0000000..8cdea6a --- /dev/null +++ b/packages/cli/tests/e2e/fixtures/claude-system.jsonl @@ -0,0 +1,4 @@ +{"sessionId":"sess-003","type":"system","timestamp":"2025-01-15T10:00:00Z","cwd":"/home/user/projects/myapp","message":{"content":"System initialization"}} +{"sessionId":"sess-003","type":"file-history-snapshot","timestamp":"2025-01-15T10:00:01Z","cwd":"/home/user/projects/myapp","files":["/home/user/projects/myapp/src/index.ts"]} +{"sessionId":"sess-003","type":"user","timestamp":"2025-01-15T10:00:02Z","cwd":"/home/user/projects/myapp","gitBranch":"feature/new-stuff","message":{"role":"user","content":"Hello"}} +{"sessionId":"sess-003","type":"assistant","timestamp":"2025-01-15T10:00:05Z","cwd":"/home/user/projects/myapp","message":{"role":"assistant","content":[{"type":"text","text":"Hi there!"}],"usage":{"input_tokens":20,"output_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} diff --git a/packages/cli/tests/e2e/fixtures/claude-with-thinking.jsonl b/packages/cli/tests/e2e/fixtures/claude-with-thinking.jsonl new file mode 100644 index 0000000..65398c3 --- /dev/null +++ b/packages/cli/tests/e2e/fixtures/claude-with-thinking.jsonl @@ -0,0 +1,2 @@ +{"sessionId":"sess-002","type":"user","timestamp":"2025-01-15T10:00:00Z","cwd":"/home/user/projects/myapp","message":{"role":"user","content":"Explain the architecture"}} +{"sessionId":"sess-002","type":"assistant","timestamp":"2025-01-15T10:00:05Z","cwd":"/home/user/projects/myapp","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Let me think about the architecture of this project. It uses a monorepo setup with multiple packages."},{"type":"text","text":"The project uses a monorepo architecture."}],"usage":{"input_tokens":50,"output_tokens":60,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} diff --git a/packages/cli/tests/e2e/fixtures/codex-simple.jsonl b/packages/cli/tests/e2e/fixtures/codex-simple.jsonl new file mode 100644 index 0000000..118e03c --- /dev/null +++ b/packages/cli/tests/e2e/fixtures/codex-simple.jsonl @@ -0,0 +1,7 @@ +{"type":"session_meta","timestamp":"2025-01-15T10:00:00Z","payload":{"id":"codex-sess-001","originator":"codex-cli","cwd":"/home/user/projects/myapp","git":{"branch":"develop","repository_url":"https://github.com/user/myapp.git"}}} +{"type":"event_msg","timestamp":"2025-01-15T10:00:01Z","payload":{"type":"user_message","message":"Fix the bug in utils.ts"}} +{"type":"response_item","timestamp":"2025-01-15T10:00:05Z","payload":{"type":"reasoning","encrypted_content":"encrypted-data","summary":[{"text":"Analyzing the bug"}]}} +{"type":"response_item","timestamp":"2025-01-15T10:00:06Z","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"I'll look at /home/user/projects/myapp/src/utils.ts to find the bug."}]}} +{"type":"response_item","timestamp":"2025-01-15T10:00:07Z","payload":{"type":"function_call","call_id":"call-001","name":"read_file","arguments":"{\"path\":\"/home/user/projects/myapp/src/utils.ts\"}"}} +{"type":"response_item","timestamp":"2025-01-15T10:00:08Z","payload":{"type":"function_call_output","call_id":"call-001","output":"{\"content\":\"export function add(a, b) { return a - b; }\"}"}} +{"type":"event_msg","timestamp":"2025-01-15T10:00:10Z","payload":{"type":"token_count","input_tokens":100,"output_tokens":50}} diff --git a/packages/cli/tests/e2e/fixtures/copilot-simple.jsonl b/packages/cli/tests/e2e/fixtures/copilot-simple.jsonl new file mode 100644 index 0000000..0ece5d6 --- /dev/null +++ b/packages/cli/tests/e2e/fixtures/copilot-simple.jsonl @@ -0,0 +1,5 @@ +{"type":"session.start","timestamp":"2025-01-15T10:00:00Z","data":{"sessionId":"copilot-sess-001","startTime":"2025-01-15T10:00:00Z","context":{"cwd":"/home/user/projects/myapp","branch":"main","repository":"https://github.com/user/myapp.git"},"version":"1.0"}} +{"type":"user.message","timestamp":"2025-01-15T10:00:01Z","data":{"content":"Refactor the auth module at /home/user/projects/myapp/src/auth.ts"}} +{"type":"assistant.message","timestamp":"2025-01-15T10:00:05Z","data":{"content":"I'll refactor the auth module.","reasoningText":"The auth module needs restructuring for better separation of concerns.","reasoningOpaque":"encrypted-reasoning"}} +{"type":"tool.execution_start","timestamp":"2025-01-15T10:00:06Z","data":{"toolCallId":"tc-001","name":"readFile","arguments":{"path":"/home/user/projects/myapp/src/auth.ts"}}} +{"type":"tool.execution_complete","timestamp":"2025-01-15T10:00:07Z","data":{"toolCallId":"tc-001","name":"readFile","result":{"content":"export class Auth { login() {} logout() {} }","detailedContent":"Full file content here"}}} diff --git a/packages/cli/tests/e2e/fixtures/gemini-simple.json b/packages/cli/tests/e2e/fixtures/gemini-simple.json new file mode 100644 index 0000000..6384415 --- /dev/null +++ b/packages/cli/tests/e2e/fixtures/gemini-simple.json @@ -0,0 +1,59 @@ +{ + "startTime": "2025-01-15T10:00:00Z", + "lastUpdated": "2025-01-15T10:05:00Z", + "sessionId": "gemini-sess-001", + "projectHash": "abc123", + "messages": [ + { + "type": "user", + "timestamp": "2025-01-15T10:00:00Z", + "content": "Create a new component at /home/user/projects/myapp/src/Button.tsx" + }, + { + "type": "gemini", + "timestamp": "2025-01-15T10:00:05Z", + "model": "gemini-2.0-flash", + "content": "I'll create the Button component for you.", + "thoughts": [ + { + "subject": "Component Design", + "description": "I should create a reusable button component with proper props." + } + ], + "tokens": { + "input": 100, + "output": 50, + "thoughts": 20, + "cached": 0, + "total": 170 + }, + "toolCalls": [ + { + "name": "write_file", + "args": { + "path": "/home/user/projects/myapp/src/Button.tsx" + }, + "status": "success", + "timestamp": "2025-01-15T10:00:06Z", + "result": [ + { + "functionResponse": { + "response": { + "output": "File written successfully" + } + } + } + ], + "resultDisplay": { + "filePath": "/home/user/projects/myapp/src/Button.tsx", + "isNewFile": true, + "diffStat": { + "model_added_lines": 15, + "model_removed_lines": 0 + } + } + } + ] + } + ] +} diff --git a/packages/cli/tests/e2e/serve.test.ts b/packages/cli/tests/e2e/serve.test.ts new file mode 100644 index 0000000..3233f9f --- /dev/null +++ b/packages/cli/tests/e2e/serve.test.ts @@ -0,0 +1,149 @@ +// ⚠️ AUTO-GENERATED — DO NOT EDIT +// Source: packages/cli/E2E_TESTS.md — Suite: Serve +// Generator: /fp-generate + +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from 'node:http'; +import { afterEach, describe, expect, it } from 'vitest'; + +describe('Serve', () => { + let server: Server | undefined; + + afterEach(async () => { + if (server) { + await new Promise((resolve, reject) => { + server?.close(err => (err ? reject(err) : resolve())); + }); + server = undefined; + } + }); + + // category: core + it('starts HTTP server on specified port', async () => { + // Create a minimal handler to test server binding + const handler = (_req: IncomingMessage, res: ServerResponse) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('capsule'); + }; + + server = createServer(handler); + + await new Promise(resolve => { + server?.listen(0, '127.0.0.1', () => resolve()); + }); + + const addr = server.address(); + expect(addr).not.toBeNull(); + expect(typeof addr).toBe('object'); + + const port = (addr as { port: number }).port; + expect(port).toBeGreaterThan(0); + + const response = await fetch(`http://127.0.0.1:${port}/`); + expect(response.status).toBe(200); + }); + + // category: core + it('accepts custom port via server.listen', async () => { + const handler = (_req: IncomingMessage, res: ServerResponse) => { + res.writeHead(200); + res.end('ok'); + }; + + server = createServer(handler); + + // Use port 0 for random assignment + await new Promise(resolve => { + server?.listen(0, '127.0.0.1', () => resolve()); + }); + + const addr = server.address() as { port: number }; + expect(addr.port).toBeGreaterThan(0); + expect(addr.port).not.toBe(3123); // Almost certainly a random port + + const response = await fetch(`http://127.0.0.1:${addr.port}/`); + expect(response.status).toBe(200); + }); + + // category: core + it('handles graceful shutdown', async () => { + const handler = (_req: IncomingMessage, res: ServerResponse) => { + res.writeHead(200); + res.end('ok'); + }; + + server = createServer(handler); + + await new Promise(resolve => { + server?.listen(0, '127.0.0.1', () => resolve()); + }); + + const addr = server.address() as { port: number }; + const port = addr.port; + + // Close server (simulating SIGINT cleanup) + await new Promise((resolve, reject) => { + server?.close(err => (err ? reject(err) : resolve())); + }); + + // Verify the port is released - a new server should be able to bind + const server2 = createServer(handler); + await new Promise(resolve => { + server2.listen(port, '127.0.0.1', () => resolve()); + }); + await new Promise((resolve, reject) => { + server2.close(err => (err ? reject(err) : resolve())); + }); + + server = undefined; // Already closed + }); + + // category: error + it('fails with clear error if handler import fails', async () => { + // Test that a dynamic import of a non-existent module throws + await expect(import('../nonexistent-handler.js')).rejects.toThrow(); + }); + + // category: core + it('responds with correct content-type for HTML', async () => { + const handler = (_req: IncomingMessage, res: ServerResponse) => { + const url = new URL(_req.url ?? '/', `http://${_req.headers.host}`); + if (url.pathname === '/') { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(''); + } else if (url.pathname.endsWith('.js')) { + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end('console.log("ok")'); + } else if (url.pathname.endsWith('.css')) { + res.writeHead(200, { 'Content-Type': 'text/css' }); + res.end('body {}'); + } else { + res.writeHead(404); + res.end(); + } + }; + + server = createServer(handler); + await new Promise(resolve => { + server?.listen(0, '127.0.0.1', () => resolve()); + }); + + const addr = server.address() as { port: number }; + const baseUrl = `http://127.0.0.1:${addr.port}`; + + const htmlRes = await fetch(`${baseUrl}/`); + expect(htmlRes.headers.get('content-type')).toContain('text/html'); + + const jsRes = await fetch(`${baseUrl}/app.js`); + expect(jsRes.headers.get('content-type')).toContain('javascript'); + + const cssRes = await fetch(`${baseUrl}/style.css`); + expect(cssRes.headers.get('content-type')).toContain('text/css'); + }); +}); + +// ⚠️ AUTO-GENERATED — DO NOT EDIT diff --git a/packages/cli/tests/e2e/share.test.ts b/packages/cli/tests/e2e/share.test.ts new file mode 100644 index 0000000..995ce73 --- /dev/null +++ b/packages/cli/tests/e2e/share.test.ts @@ -0,0 +1,73 @@ +// ⚠️ AUTO-GENERATED — DO NOT EDIT +// Source: packages/cli/E2E_TESTS.md — Suite: Share +// Generator: /fp-generate + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { checkGhAuth } from '../../src/publish.js'; +import { createMockGh } from './e2e-utils.js'; + +describe('Share', () => { + let originalPath: string | undefined; + let mockCleanup: (() => Promise) | undefined; + + beforeEach(() => { + originalPath = process.env.PATH; + }); + + afterEach(async () => { + if (originalPath !== undefined) { + process.env.PATH = originalPath; + } + if (mockCleanup) { + await mockCleanup(); + mockCleanup = undefined; + } + }); + + // category: core + // skip: requires-real-gist + it.skip('publishes session and returns viewer URL', () => { + // Skip reason: requires-real-gist + // This test would run `capsule share ` with a mocked `gh` CLI, + // verify it calls `gh gist create`, captures the gist ID, and prints + // a viewer URL matching https://capsule.endor.dev?gist=. + }); + + // category: core + it('checks gh auth before proceeding and fails on auth error', async () => { + const mock = await createMockGh('auth-fail'); + mockCleanup = mock.cleanup; + + process.env.PATH = `${mock.dir}:${process.env.PATH}`; + + const result = await checkGhAuth(); + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error).toContain('auth'); + }); + + // category: error + it('fails when gh is not installed', async () => { + // Set PATH to empty directory so gh cannot be found + const emptyMock = await createMockGh('auth-fail'); + mockCleanup = emptyMock.cleanup; + + // Point PATH to a directory with no gh binary + process.env.PATH = '/nonexistent/bin'; + + const result = await checkGhAuth(); + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + }); + + // category: core + // skip: requires-gh-auth + it.skip('applies anonymization transforms before publishing', () => { + // Skip reason: requires-gh-auth + // This test would run share with path masking enabled, capture the + // temp file written for gist creation, and verify file paths are + // masked in the content. + }); +}); + +// ⚠️ AUTO-GENERATED — DO NOT EDIT diff --git a/packages/cli/vitest.config.e2e.ts b/packages/cli/vitest.config.e2e.ts new file mode 100644 index 0000000..9255b8c --- /dev/null +++ b/packages/cli/vitest.config.e2e.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/e2e/**/*.test.ts'], + testTimeout: 30_000, + sequence: { + concurrent: false, + }, + }, +}); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 0000000..3059bbd --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/unit/**/*.test.ts'], + passWithNoTests: true, + }, +}); diff --git a/packages/web/package.json b/packages/web/package.json index c78380a..1eb5519 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -11,7 +11,9 @@ "build": "PUBLIC_DISTRIBUTION=local vite build", "build:cloudflare": "ADAPTER=cloudflare vite build", "preview": "vite preview", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run", + "test:e2e": "playwright test" }, "dependencies": { "@endorhq/capsule-shared": "workspace:*", @@ -21,6 +23,7 @@ }, "devDependencies": { "@iconify-json/dinkie-icons": "^1.2.0", + "@playwright/test": "^1.58.2", "@sveltejs/adapter-cloudflare": "^7.2.6", "@sveltejs/adapter-node": "^5.5.2", "@sveltejs/kit": "^2.50.1", @@ -32,6 +35,7 @@ "tailwindcss": "^4.1.18", "typescript": "^5.9.3", "unplugin-icons": "^23.0.1", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.1.0" } } diff --git a/packages/web/playwright.config.ts b/packages/web/playwright.config.ts new file mode 100644 index 0000000..0edba66 --- /dev/null +++ b/packages/web/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + testMatch: '**/*.spec.ts', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: 'list', + timeout: 30_000, + use: { + baseURL: 'http://localhost:4173', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: + 'PUBLIC_DISTRIBUTION=public vite build && pnpm preview --port 4173', + port: 4173, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/packages/web/test-results/.last-run.json b/packages/web/test-results/.last-run.json new file mode 100644 index 0000000..f740f7c --- /dev/null +++ b/packages/web/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} diff --git a/packages/web/tests/e2e/e2e-utils.ts b/packages/web/tests/e2e/e2e-utils.ts new file mode 100644 index 0000000..a0f38d8 --- /dev/null +++ b/packages/web/tests/e2e/e2e-utils.ts @@ -0,0 +1,72 @@ +// ⚠️ AUTO-GENERATED — DO NOT EDIT +// Source: packages/web/E2E_TESTS.md +// Generator: /fp-generate + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Page } from '@playwright/test'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +export const FIXTURES_DIR = join(__dirname, 'fixtures'); + +export function fixturePath(name: string): string { + return join(FIXTURES_DIR, name); +} + +export async function readFixture(name: string): Promise { + return readFile(fixturePath(name), 'utf-8'); +} + +/** + * Upload a fixture file via the file input on the page. + * Looks for the file input element and sets the file. + */ +export async function uploadFixture( + page: Page, + fixtureName: string +): Promise { + const filePath = fixturePath(fixtureName); + + // Try main content area first (UploadZone), fall back to sidebar input + const mainInput = page.locator('main input[type="file"]'); + const sidebarInput = page.locator('aside input[type="file"]'); + + if ((await mainInput.count()) > 0) { + await mainInput.setInputFiles(filePath); + } else { + await sidebarInput.setInputFiles(filePath); + } +} + +/** + * Wait for a session to appear in the sidebar session list after upload. + * Looks for the session count indicator changing from [0] to [1+]. + */ +export async function waitForSessionInSidebar(page: Page): Promise { + // Wait for at least one session to appear in the sidebar list + // The sidebar shows a count like [1] next to "// sessions" + await page + .locator('aside') + .getByRole('button') + .filter({ hasNotText: /load|clear|open|where|\+|logs/ }) + .first() + .waitFor({ + state: 'visible', + timeout: 10_000, + }); +} + +/** + * Wait for the timeline to render entries. + * Looks for the "// end of session" marker which appears after all entries render. + */ +export async function waitForTimeline(page: Page): Promise { + await page.locator('main').getByText('// end of session').waitFor({ + state: 'visible', + timeout: 10_000, + }); +} + +// ⚠️ AUTO-GENERATED — DO NOT EDIT diff --git a/packages/web/tests/e2e/fixtures/claude-simple.jsonl b/packages/web/tests/e2e/fixtures/claude-simple.jsonl new file mode 100644 index 0000000..8806d2c --- /dev/null +++ b/packages/web/tests/e2e/fixtures/claude-simple.jsonl @@ -0,0 +1,4 @@ +{"sessionId":"sess-001","type":"user","timestamp":"2025-01-15T10:00:00Z","cwd":"/home/user/projects/myapp","message":{"role":"user","content":"Hello, can you help me with my project?"}} +{"sessionId":"sess-001","type":"assistant","timestamp":"2025-01-15T10:00:05Z","cwd":"/home/user/projects/myapp","message":{"role":"assistant","content":[{"type":"text","text":"Sure! I'd be happy to help with your project. What do you need?"}],"usage":{"input_tokens":50,"output_tokens":30}}} +{"sessionId":"sess-001","type":"user","timestamp":"2025-01-15T10:01:00Z","cwd":"/home/user/projects/myapp","message":{"role":"user","content":"Please review the code"}} +{"sessionId":"sess-001","type":"assistant","timestamp":"2025-01-15T10:01:05Z","cwd":"/home/user/projects/myapp","message":{"role":"assistant","content":[{"type":"text","text":"I'll review the code for you."}],"usage":{"input_tokens":80,"output_tokens":20}}} diff --git a/packages/web/tests/e2e/fixtures/claude-with-thinking.jsonl b/packages/web/tests/e2e/fixtures/claude-with-thinking.jsonl new file mode 100644 index 0000000..edff0af --- /dev/null +++ b/packages/web/tests/e2e/fixtures/claude-with-thinking.jsonl @@ -0,0 +1,2 @@ +{"sessionId":"sess-think","type":"user","timestamp":"2025-01-15T10:00:00Z","cwd":"/home/user/projects/myapp","message":{"role":"user","content":"Explain the architecture"}} +{"sessionId":"sess-think","type":"assistant","timestamp":"2025-01-15T10:00:05Z","cwd":"/home/user/projects/myapp","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Let me think about the architecture of this project."},{"type":"text","text":"The project uses a monorepo architecture with multiple packages."}],"usage":{"input_tokens":50,"output_tokens":60}}} diff --git a/packages/web/tests/e2e/fixtures/claude-with-tools.jsonl b/packages/web/tests/e2e/fixtures/claude-with-tools.jsonl new file mode 100644 index 0000000..718a709 --- /dev/null +++ b/packages/web/tests/e2e/fixtures/claude-with-tools.jsonl @@ -0,0 +1,4 @@ +{"sessionId":"sess-tools","type":"user","timestamp":"2025-01-15T10:00:00Z","cwd":"/home/user/projects/myapp","message":{"role":"user","content":"Read the index file"}} +{"sessionId":"sess-tools","type":"assistant","timestamp":"2025-01-15T10:00:05Z","cwd":"/home/user/projects/myapp","message":{"role":"assistant","content":[{"type":"tool_use","id":"tool-001","name":"Read","input":{"file_path":"/home/user/projects/myapp/src/index.ts"}}],"usage":{"input_tokens":50,"output_tokens":30}}} +{"sessionId":"sess-tools","type":"user","timestamp":"2025-01-15T10:00:06Z","cwd":"/home/user/projects/myapp","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-001","content":"export function main() { console.log('hello'); }"}]},"toolUseResult":{"name":"Read","exitCode":0}} +{"sessionId":"sess-tools","type":"assistant","timestamp":"2025-01-15T10:00:10Z","cwd":"/home/user/projects/myapp","message":{"role":"assistant","content":[{"type":"text","text":"I can see the main function in the index file."}],"usage":{"input_tokens":100,"output_tokens":20}}} diff --git a/packages/web/tests/e2e/fixtures/codex-simple.jsonl b/packages/web/tests/e2e/fixtures/codex-simple.jsonl new file mode 100644 index 0000000..ea0d74c --- /dev/null +++ b/packages/web/tests/e2e/fixtures/codex-simple.jsonl @@ -0,0 +1,3 @@ +{"type":"session_meta","timestamp":"2025-01-15T10:00:00Z","payload":{"id":"codex-sess-001","originator":"codex-cli","cwd":"/home/user/projects/myapp"}} +{"type":"event_msg","timestamp":"2025-01-15T10:00:01Z","payload":{"type":"user_message","message":"Fix the bug"}} +{"type":"response_item","timestamp":"2025-01-15T10:00:05Z","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"I'll fix the bug."}]}} diff --git a/packages/web/tests/e2e/fixtures/copilot-simple.jsonl b/packages/web/tests/e2e/fixtures/copilot-simple.jsonl new file mode 100644 index 0000000..22cad1f --- /dev/null +++ b/packages/web/tests/e2e/fixtures/copilot-simple.jsonl @@ -0,0 +1,3 @@ +{"type":"session.start","timestamp":"2025-01-15T10:00:00Z","data":{"sessionId":"copilot-sess-001","startTime":"2025-01-15T10:00:00Z","context":{"cwd":"/home/user/projects/myapp"},"version":"1.0"}} +{"type":"user.message","timestamp":"2025-01-15T10:00:01Z","data":{"content":"Refactor the auth module"}} +{"type":"assistant.message","timestamp":"2025-01-15T10:00:05Z","data":{"content":"I'll refactor the auth module for you."}} diff --git a/packages/web/tests/e2e/fixtures/gemini-simple.json b/packages/web/tests/e2e/fixtures/gemini-simple.json new file mode 100644 index 0000000..f960b76 --- /dev/null +++ b/packages/web/tests/e2e/fixtures/gemini-simple.json @@ -0,0 +1,24 @@ +{ + "startTime": "2025-01-15T10:00:00Z", + "lastUpdated": "2025-01-15T10:05:00Z", + "sessionId": "gemini-sess-001", + "projectHash": "abc123", + "messages": [ + { + "type": "user", + "timestamp": "2025-01-15T10:00:00Z", + "content": "Create a new component" + }, + { + "type": "gemini", + "timestamp": "2025-01-15T10:00:05Z", + "model": "gemini-2.0-flash", + "content": "I'll create the component for you.", + "tokens": { + "input": 100, + "output": 50, + "total": 150 + } + } + ] +} diff --git a/packages/web/tests/e2e/fixtures/invalid-file.txt b/packages/web/tests/e2e/fixtures/invalid-file.txt new file mode 100644 index 0000000..fb5682e --- /dev/null +++ b/packages/web/tests/e2e/fixtures/invalid-file.txt @@ -0,0 +1,4 @@ +This is not a valid session file. +It contains random text that cannot be parsed +as any known agent format. +Lorem ipsum dolor sit amet. diff --git a/packages/web/tests/e2e/gist-loading.spec.ts b/packages/web/tests/e2e/gist-loading.spec.ts new file mode 100644 index 0000000..fc139f5 --- /dev/null +++ b/packages/web/tests/e2e/gist-loading.spec.ts @@ -0,0 +1,84 @@ +// ⚠️ AUTO-GENERATED — DO NOT EDIT +// Source: packages/web/E2E_TESTS.md — Suite: Gist Loading +// Generator: /fp-generate + +import { expect, test } from '@playwright/test'; +import { readFixture } from './e2e-utils.js'; + +test.describe('Gist Loading', () => { + // category: core + test('load session from gist ID via URL parameter', async ({ page }) => { + const claudeContent = await readFixture('claude-simple.jsonl'); + + // Mock the GitHub Gist API + await page.route('**/api.github.com/gists/*', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 'mock-gist-123', + html_url: 'https://gist.github.com/user/mock-gist-123', + owner: { login: 'testuser' }, + description: 'Test gist', + files: { + 'claude-session.jsonl': { + filename: 'claude-session.jsonl', + size: claudeContent.length, + truncated: false, + raw_url: 'https://gist.githubusercontent.com/raw/mock', + content: claudeContent, + }, + }, + }), + }); + }); + + // Navigate with gist parameter + await page.goto('/?gist=mock-gist-123'); + await page.waitForLoadState('networkidle'); + + // Wait for the gist to load and session to appear + // The app should fetch, parse, and display the gist content + await page.waitForTimeout(3000); + + // Verify the session was loaded (content should be visible) + const pageText = await page.textContent('body'); + expect(pageText?.toLowerCase()).toContain('claude'); + }); + + // category: error + test('gist loading shows error for invalid gist ID', async ({ page }) => { + // Mock the GitHub API to return 404 + await page.route('**/api.github.com/gists/*', async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }); + }); + + await page.goto('/?gist=nonexistent'); + await page.waitForLoadState('networkidle'); + + // Wait for error to display + await page.waitForTimeout(3000); + + // Verify an error message is displayed + const pageText = await page.textContent('body'); + const hasError = + pageText?.toLowerCase().includes('error') || + pageText?.toLowerCase().includes('not found') || + pageText?.toLowerCase().includes('failed'); + expect(hasError).toBe(true); + }); + + // category: core + // skip: requires-real-gist + test.skip('gist loading with real GitHub API', async ({ page: _page }) => { + // Skip reason: requires-real-gist + // This test would load a known public gist and verify + // content renders correctly. Requires network access. + }); +}); + +// ⚠️ AUTO-GENERATED — DO NOT EDIT diff --git a/packages/web/tests/e2e/session-viewer.spec.ts b/packages/web/tests/e2e/session-viewer.spec.ts new file mode 100644 index 0000000..cf5d950 --- /dev/null +++ b/packages/web/tests/e2e/session-viewer.spec.ts @@ -0,0 +1,222 @@ +// ⚠️ AUTO-GENERATED — DO NOT EDIT +// Source: packages/web/E2E_TESTS.md — Suite: Session Viewer +// Generator: /fp-generate + +import { expect, test } from '@playwright/test'; +import { + uploadFixture, + waitForSessionInSidebar, + waitForTimeline, +} from './e2e-utils.js'; + +test.describe('Session Viewer', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + // category: core + test('filter bar filters timeline entries by type', async ({ page }) => { + // Upload a session with tool calls + await uploadFixture(page, 'claude-with-tools.jsonl'); + await waitForSessionInSidebar(page); + + const sessionItem = page.locator('aside').locator('button, a').first(); + await sessionItem.click(); + + await waitForTimeline(page); + + // Get initial content with tool calls visible + const initialText = await page.locator('main').textContent(); + expect(initialText).toBeTruthy(); + + // Look for a search/filter input + const filterInput = page.locator( + 'input[type="search"], input[type="text"][placeholder*="filter" i], input[type="text"][placeholder*="search" i]' + ); + if ((await filterInput.count()) > 0) { + // Type a filter term that should exclude tool calls + await filterInput.first().fill('help'); + await page.waitForTimeout(1000); + + const filteredText = await page.locator('main').textContent(); + // Content should change after filtering + expect(filteredText).not.toBe(initialText); + } + }); + + // category: core + test('session metadata panel shows format, duration, and token counts', async ({ + page, + }) => { + await uploadFixture(page, 'claude-simple.jsonl'); + await waitForSessionInSidebar(page); + + const sessionItem = page.locator('aside').locator('button, a').first(); + await sessionItem.click(); + + await waitForTimeline(page); + + // Verify metadata panel content + const pageText = await page.textContent('body'); + + // Should show the agent format + expect(pageText?.toLowerCase()).toContain('claude'); + + // Should show token info (look for numbers or "tokens" text) + const hasTokenInfo = + pageText?.toLowerCase().includes('token') || + pageText?.match(/\d+\s*(input|output|total)/i); + expect(hasTokenInfo).toBeTruthy(); + }); + + // category: edge + test('subagent entries render nested timelines', async ({ page }) => { + // Create a fixture with subagent entries inline + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const os = await import('node:os'); + + const subagentFixture = [ + JSON.stringify({ + sessionId: 'sess-sub', + type: 'user', + timestamp: '2025-01-15T10:00:00Z', + cwd: '/test', + message: { role: 'user', content: 'Run a task' }, + }), + JSON.stringify({ + sessionId: 'sess-sub', + type: 'assistant', + timestamp: '2025-01-15T10:00:05Z', + cwd: '/test', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'task-001', + name: 'Agent', + input: { prompt: 'Do subtask' }, + }, + ], + usage: { input_tokens: 50, output_tokens: 30 }, + }, + }), + JSON.stringify({ + sessionId: 'sess-sub', + type: 'progress', + timestamp: '2025-01-15T10:00:06Z', + agentId: 'agent-001', + parentToolUseId: 'task-001', + data: { + type: 'start', + description: 'Subtask agent', + subagentType: 'general-purpose', + }, + }), + JSON.stringify({ + sessionId: 'sess-sub', + type: 'progress', + timestamp: '2025-01-15T10:00:07Z', + agentId: 'agent-001', + data: { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Working on subtask' }], + }, + }, + }), + JSON.stringify({ + sessionId: 'sess-sub', + type: 'user', + timestamp: '2025-01-15T10:00:10Z', + cwd: '/test', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'task-001', + content: 'Task completed', + }, + ], + }, + }), + JSON.stringify({ + sessionId: 'sess-sub', + type: 'assistant', + timestamp: '2025-01-15T10:00:15Z', + cwd: '/test', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'The subtask is done.' }], + usage: { input_tokens: 100, output_tokens: 20 }, + }, + }), + ].join('\n'); + + const tempFile = path.join(os.tmpdir(), 'capsule-subagent-test.jsonl'); + await fs.writeFile(tempFile, subagentFixture, 'utf-8'); + + try { + const fileInput = page.locator('main input[type="file"]'); + await fileInput.setInputFiles(tempFile); + + await waitForSessionInSidebar(page); + + const sessionItem = page.locator('aside').locator('button, a').first(); + await sessionItem.click(); + + await waitForTimeline(page); + + // Verify subagent content is rendered + const pageText = await page.textContent('body'); + expect(pageText).toContain('Agent'); + } finally { + await fs.unlink(tempFile).catch(() => {}); + } + }); + + // category: error + test('empty session shows appropriate empty state', async ({ page }) => { + // Create a fixture with only system entries (no user/assistant) + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const os = await import('node:os'); + + const emptyFixture = JSON.stringify({ + sessionId: 'sess-empty', + type: 'system', + timestamp: '2025-01-15T10:00:00Z', + cwd: '/test', + message: { content: 'Session started' }, + }); + + const tempFile = path.join(os.tmpdir(), 'capsule-empty-test.jsonl'); + await fs.writeFile(tempFile, emptyFixture, 'utf-8'); + + try { + const fileInput = page.locator('main input[type="file"]'); + await fileInput.setInputFiles(tempFile); + + await page.waitForTimeout(3000); + + // Either an error is shown, session doesn't appear, or an empty state message is displayed + const pageText = await page.textContent('body'); + const hasEmptyState = + pageText?.toLowerCase().includes('empty') || + pageText?.toLowerCase().includes('no messages') || + pageText?.toLowerCase().includes('no entries') || + pageText?.toLowerCase().includes('error') || + pageText?.toLowerCase().includes('upload'); + + expect(hasEmptyState).toBe(true); + } finally { + await fs.unlink(tempFile).catch(() => {}); + } + }); +}); + +// ⚠️ AUTO-GENERATED — DO NOT EDIT diff --git a/packages/web/tests/e2e/storage.spec.ts b/packages/web/tests/e2e/storage.spec.ts new file mode 100644 index 0000000..5a35e00 --- /dev/null +++ b/packages/web/tests/e2e/storage.spec.ts @@ -0,0 +1,180 @@ +// ⚠️ AUTO-GENERATED — DO NOT EDIT +// Source: packages/web/E2E_TESTS.md — Suite: Storage +// Generator: /fp-generate + +import { expect, test } from '@playwright/test'; +import { uploadFixture, waitForSessionInSidebar } from './e2e-utils.js'; + +test.describe('Storage', () => { + test.beforeEach(async ({ page }) => { + // Clear storage before each test for clean state + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + // Clear OPFS and IndexedDB + await page.evaluate(async () => { + // Clear IndexedDB + const dbs = await indexedDB.databases(); + for (const db of dbs) { + if (db.name) indexedDB.deleteDatabase(db.name); + } + // Clear OPFS + try { + const root = await navigator.storage.getDirectory(); + // @ts-expect-error - non-standard but works in Chromium + for await (const [name] of root.entries()) { + await root.removeEntry(name, { recursive: true }); + } + } catch { + // OPFS may not be available + } + // Clear localStorage + localStorage.clear(); + }); + + // Reload after clearing storage + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + // category: core + test('uploaded session persists across page reloads', async ({ page }) => { + await uploadFixture(page, 'claude-simple.jsonl'); + await waitForSessionInSidebar(page); + + // Get session count before reload + const itemsBefore = await page + .locator('aside') + .locator('button, a') + .count(); + expect(itemsBefore).toBeGreaterThan(0); + + // Reload the page + await page.reload(); + await page.waitForLoadState('networkidle'); + + // Wait for sessions to load from storage + await page.waitForTimeout(2000); + + // Verify session still appears + const itemsAfter = await page.locator('aside').locator('button, a').count(); + expect(itemsAfter).toBeGreaterThanOrEqual(itemsBefore); + }); + + // category: core + test('multiple sessions can be stored and switched between', async ({ + page, + }) => { + // Upload first session + await uploadFixture(page, 'claude-simple.jsonl'); + await waitForSessionInSidebar(page); + + // Upload second session + await uploadFixture(page, 'codex-simple.jsonl'); + await page.waitForTimeout(1000); + + // Verify multiple sessions in sidebar (session items contain "steps") + const sessionItems = page + .locator('aside button') + .filter({ hasText: 'steps' }); + await expect(sessionItems).toHaveCount(2, { timeout: 5_000 }); + + // Click first session + await sessionItems.first().click(); + await page.waitForTimeout(1000); + const firstText = await page.locator('main').textContent(); + + // Click second session + await sessionItems.nth(1).click(); + await page.waitForTimeout(1000); + const secondText = await page.locator('main').textContent(); + + // Content should differ between sessions + expect(firstText).not.toBe(secondText); + }); + + // category: side-effect + test('clear all sessions removes everything', async ({ page }) => { + // Upload sessions + await uploadFixture(page, 'claude-simple.jsonl'); + await waitForSessionInSidebar(page); + + await uploadFixture(page, 'codex-simple.jsonl'); + await page.waitForTimeout(1000); + + // Find and click the clear/delete all button + const clearButton = page + .locator('button') + .filter({ hasText: /clear|delete all|remove all/i }); + if ((await clearButton.count()) > 0) { + await clearButton.first().click(); + + // Confirm if there's a confirmation dialog + const confirmButton = page + .locator('button') + .filter({ hasText: /confirm|yes|ok/i }); + if ((await confirmButton.count()) > 0) { + await confirmButton.first().click(); + } + + await page.waitForTimeout(1000); + + // Sidebar should be empty + const items = page + .locator('aside') + .locator('[data-testid="session-item"], .session-item'); + const count = await items.count(); + expect(count).toBe(0); + } + }); + + // category: edge + test('storage fallback works when OPFS is unavailable', async ({ page }) => { + // Disable OPFS by overriding the API + await page.addInitScript(() => { + // Override navigator.storage.getDirectory to simulate OPFS unavailability + if (navigator.storage) { + navigator.storage.getDirectory = () => + Promise.reject(new Error('OPFS not available')); + } + }); + + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + // Upload should still work via IndexedDB or memory fallback + await uploadFixture(page, 'claude-simple.jsonl'); + await waitForSessionInSidebar(page); + + // Verify session is accessible + const sessionItem = page.locator('aside').locator('button, a').first(); + await sessionItem.click(); + + await page.waitForTimeout(2000); + const pageText = await page.locator('main').textContent(); + expect(pageText).toBeTruthy(); + }); + + // category: idempotency + test('re-uploading same file handles correctly', async ({ page }) => { + await uploadFixture(page, 'claude-simple.jsonl'); + await waitForSessionInSidebar(page); + + const countBefore = await page + .locator('aside') + .locator('button, a') + .count(); + + // Upload the same file again + await uploadFixture(page, 'claude-simple.jsonl'); + await page.waitForTimeout(1000); + + const countAfter = await page.locator('aside').locator('button, a').count(); + + // Either the same count (deduplication) or one more (duplicates allowed) + // Both are valid behaviors — just verify no crash + expect(countAfter).toBeGreaterThanOrEqual(countBefore); + }); +}); + +// ⚠️ AUTO-GENERATED — DO NOT EDIT diff --git a/packages/web/tests/e2e/upload-parse.spec.ts b/packages/web/tests/e2e/upload-parse.spec.ts new file mode 100644 index 0000000..689f2e8 --- /dev/null +++ b/packages/web/tests/e2e/upload-parse.spec.ts @@ -0,0 +1,214 @@ +// ⚠️ AUTO-GENERATED — DO NOT EDIT +// Source: packages/web/E2E_TESTS.md — Suite: Upload & Parse +// Generator: /fp-generate + +import { expect, test } from '@playwright/test'; +import { + uploadFixture, + waitForSessionInSidebar, + waitForTimeline, +} from './e2e-utils.js'; + +test.describe('Upload & Parse', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + // category: core + test('upload Claude JSONL file and render timeline', async ({ page }) => { + await uploadFixture(page, 'claude-simple.jsonl'); + await waitForSessionInSidebar(page); + + // Click the session in the sidebar to select it + const sessionItem = page.locator('aside').locator('button, a').first(); + await sessionItem.click(); + + await waitForTimeline(page); + + // Verify timeline has content (user and assistant messages) + const timelineContent = await page.locator('main').textContent(); + expect(timelineContent).toBeTruthy(); + + // Verify format indicator shows Claude + const pageText = await page.textContent('body'); + expect(pageText?.toLowerCase()).toContain('claude'); + }); + + // category: core + test('upload Codex JSONL file and render timeline', async ({ page }) => { + await uploadFixture(page, 'codex-simple.jsonl'); + await waitForSessionInSidebar(page); + + const sessionItem = page.locator('aside').locator('button, a').first(); + await sessionItem.click(); + + await waitForTimeline(page); + + const pageText = await page.textContent('body'); + expect(pageText?.toLowerCase()).toContain('codex'); + }); + + // category: core + test('upload Copilot JSONL file and render timeline', async ({ page }) => { + await uploadFixture(page, 'copilot-simple.jsonl'); + await waitForSessionInSidebar(page); + + const sessionItem = page.locator('aside').locator('button, a').first(); + await sessionItem.click(); + + await waitForTimeline(page); + + const pageText = await page.textContent('body'); + expect(pageText?.toLowerCase()).toContain('copilot'); + }); + + // category: core + test('upload Gemini JSON file and render timeline', async ({ page }) => { + await uploadFixture(page, 'gemini-simple.json'); + await waitForSessionInSidebar(page); + + const sessionItem = page.locator('aside').locator('button, a').first(); + await sessionItem.click(); + + await waitForTimeline(page); + + const pageText = await page.textContent('body'); + expect(pageText?.toLowerCase()).toContain('gemini'); + }); + + // category: core + test('format auto-detection identifies correct agent', async ({ page }) => { + // Upload each format and verify detection + const fixtures = [ + { file: 'claude-simple.jsonl', format: 'claude' }, + { file: 'codex-simple.jsonl', format: 'codex' }, + { file: 'copilot-simple.jsonl', format: 'copilot' }, + { file: 'gemini-simple.json', format: 'gemini' }, + ]; + + for (const { file, format } of fixtures) { + // Reload page for clean state + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + await uploadFixture(page, file); + await waitForSessionInSidebar(page); + + // Select the session + const sessionItem = page.locator('aside').locator('button, a').first(); + await sessionItem.click(); + + await waitForTimeline(page); + + // Verify the format is displayed somewhere on the page + const pageText = await page.textContent('body'); + expect(pageText?.toLowerCase()).toContain(format); + } + }); + + // category: core + test('upload file with tool calls renders nested tool blocks', async ({ + page, + }) => { + await uploadFixture(page, 'claude-with-tools.jsonl'); + await waitForSessionInSidebar(page); + + const sessionItem = page.locator('aside').locator('button, a').first(); + await sessionItem.click(); + + await waitForTimeline(page); + + // Verify tool call is rendered (look for tool name "Read") + const pageText = await page.textContent('body'); + expect(pageText).toContain('Read'); + }); + + // category: core + test('upload file with thinking blocks renders thinking sections', async ({ + page, + }) => { + await uploadFixture(page, 'claude-with-thinking.jsonl'); + await waitForSessionInSidebar(page); + + const sessionItem = page.locator('aside').locator('button, a').first(); + await sessionItem.click(); + + await waitForTimeline(page); + + // Verify thinking content or indicator is present + const pageText = await page.textContent('body'); + // The thinking block should be rendered (either expanded or collapsed) + expect(pageText?.toLowerCase()).toMatch(/think|reason/); + }); + + // category: error + test('upload invalid file shows error', async ({ page }) => { + await uploadFixture(page, 'invalid-file.txt'); + + // Wait a moment for error to display + await page.waitForTimeout(2000); + + // The session should NOT appear in the sidebar, or an error should be shown + // Check for error indication + const pageText = await page.textContent('body'); + // Either an error message is shown, or the sidebar remains empty + const hasError = + pageText?.toLowerCase().includes('error') || + pageText?.toLowerCase().includes('invalid') || + pageText?.toLowerCase().includes('failed'); + const sidebarEmpty = + (await page.locator('aside').locator('button, a').count()) === 0; + + expect(hasError || sidebarEmpty).toBe(true); + }); + + // category: edge + test('upload very large file does not hang', async ({ page }) => { + // Generate a large JSONL fixture inline + const lines: string[] = []; + for (let i = 0; i < 1000; i++) { + lines.push( + JSON.stringify({ + sessionId: 'sess-large', + type: i % 2 === 0 ? 'user' : 'assistant', + timestamp: new Date(Date.now() + i * 1000).toISOString(), + cwd: '/home/user/projects/myapp', + message: + i % 2 === 0 + ? { role: 'user', content: `Message ${i}` } + : { + role: 'assistant', + content: [{ type: 'text', text: `Response ${i}` }], + usage: { input_tokens: 10, output_tokens: 10 }, + }, + }) + ); + } + const largeContent = lines.join('\n'); + + // Write the large file to a temp location and upload + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const os = await import('node:os'); + const tempFile = path.join(os.tmpdir(), 'capsule-large-test.jsonl'); + await fs.writeFile(tempFile, largeContent, 'utf-8'); + + try { + const fileInput = page.locator('main input[type="file"]'); + await fileInput.setInputFiles(tempFile); + + // Should complete within the test timeout (30s) + await waitForSessionInSidebar(page); + + const sessionItem = page.locator('aside').locator('button, a').first(); + await sessionItem.click(); + + await waitForTimeline(page); + } finally { + await fs.unlink(tempFile).catch(() => {}); + } + }); +}); + +// ⚠️ AUTO-GENERATED — DO NOT EDIT diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts new file mode 100644 index 0000000..5df67ec --- /dev/null +++ b/packages/web/vitest.config.ts @@ -0,0 +1,10 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [svelte({ hot: false })], + test: { + include: ['tests/unit/**/*.test.ts'], + passWithNoTests: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cac8125..e46d8a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.2)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) packages/shared: devDependencies: @@ -64,6 +67,9 @@ importers: '@iconify-json/dinkie-icons': specifier: ^1.2.0 version: 1.2.0 + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 '@sveltejs/adapter-cloudflare': specifier: ^7.2.6 version: 7.2.6(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.2)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.49.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(wrangler@4.63.0(@cloudflare/workers-types@4.20260207.0)) @@ -100,6 +106,9 @@ importers: vite: specifier: ^7.3.1 version: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vitest: + specifier: ^4.1.0 + version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.2)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) packages: @@ -767,6 +776,11 @@ packages: resolution: {integrity: sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==} engines: {node: '>=14'} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1123,9 +1137,15 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1138,6 +1158,35 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@vitest/expect@4.1.0': + resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} + + '@vitest/mocker@4.1.0': + resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.0': + resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} + + '@vitest/runner@4.1.0': + resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} + + '@vitest/snapshot@4.1.0': + resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==} + + '@vitest/spy@4.1.0': + resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==} + + '@vitest/utils@4.1.0': + resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} + acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} @@ -1150,6 +1199,10 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -1167,6 +1220,10 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -1192,6 +1249,9 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -1237,6 +1297,9 @@ packages: error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + esbuild@0.27.0: resolution: {integrity: sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==} engines: {node: '>=18'} @@ -1256,6 +1319,13 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -1274,6 +1344,11 @@ packages: fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1540,6 +1615,16 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} @@ -1627,6 +1712,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sirv@3.0.2: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} @@ -1642,6 +1730,12 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -1681,6 +1775,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -1692,6 +1789,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -1817,6 +1918,41 @@ packages: vite: optional: true + vitest@4.1.0: + resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.0 + '@vitest/browser-preview': 4.1.0 + '@vitest/browser-webdriverio': 4.1.0 + '@vitest/ui': 4.1.0 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + web-vitals@5.1.0: resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==} @@ -1828,6 +1964,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + workerd@1.20260205.0: resolution: {integrity: sha512-CcMH5clHwrH8VlY7yWS9C/G/C8g9czIz1yU3akMSP9Z3CkEMFSoC3GGdj5G7Alw/PHEeez1+1IrlYger4pwu+w==} engines: {node: '>=16'} @@ -2319,6 +2460,10 @@ snapshots: '@opentelemetry/semantic-conventions@1.39.0': {} + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@polka/url@1.0.0-next.29': {} '@poppinss/colors@4.1.6': @@ -2605,8 +2750,15 @@ snapshots: tailwindcss: 4.1.18 vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/cookie@0.6.0': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/node@25.2.2': @@ -2618,12 +2770,55 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@vitest/expect@4.1.0': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.0(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.1.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + + '@vitest/pretty-format@4.1.0': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.0': + dependencies: + '@vitest/utils': 4.1.0 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.0': + dependencies: + '@vitest/pretty-format': 4.1.0 + '@vitest/utils': 4.1.0 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.0': {} + + '@vitest/utils@4.1.0': + dependencies: + '@vitest/pretty-format': 4.1.0 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn@8.15.0: {} any-promise@1.3.0: {} aria-query@5.3.2: {} + assertion-error@2.0.1: {} + axobject-query@4.1.0: {} blake3-wasm@2.1.5: {} @@ -2635,6 +2830,8 @@ snapshots: cac@6.7.14: {} + chai@6.2.2: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -2651,6 +2848,8 @@ snapshots: consola@3.4.2: {} + convert-source-map@2.0.0: {} + cookie@0.6.0: {} cookie@1.1.1: {} @@ -2684,6 +2883,8 @@ snapshots: error-stack-parser-es@1.0.5: {} + es-module-lexer@2.0.0: {} + esbuild@0.27.0: optionalDependencies: '@esbuild/aix-ppc64': 0.27.0 @@ -2750,6 +2951,12 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + exsolve@1.0.8: {} fdir@6.5.0(picomatch@4.0.3): @@ -2764,6 +2971,9 @@ snapshots: mlly: 1.8.0 rollup: 4.57.1 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2980,6 +3190,14 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0): dependencies: lilconfig: 3.1.3 @@ -3121,6 +3339,8 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.29 @@ -3133,6 +3353,10 @@ snapshots: source-map@0.7.6: {} + stackback@0.0.2: {} + + std-env@4.0.0: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -3189,6 +3413,8 @@ snapshots: dependencies: any-promise: 1.3.0 + tinybench@2.9.0: {} + tinyexec@0.3.2: {} tinyexec@1.0.2: {} @@ -3198,6 +3424,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.0: {} + totalist@3.0.1: {} tree-kill@1.2.2: {} @@ -3290,6 +3518,34 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.2)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): + dependencies: + '@vitest/expect': 4.1.0 + '@vitest/mocker': 4.1.0(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@vitest/pretty-format': 4.1.0 + '@vitest/runner': 4.1.0 + '@vitest/snapshot': 4.1.0 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 25.2.2 + transitivePeerDependencies: + - msw + web-vitals@5.1.0: {} webpack-virtual-modules@0.6.2: {} @@ -3298,6 +3554,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + workerd@1.20260205.0: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20260205.0