Repository Public
1import { afterAll, beforeAll, describe, expect, it } from 'vitest';2import { existsSync, mkdirSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs';3import path from 'node:path';4import type { ClaudeConversation } from '@cr/shared';5import { buildApp, call, errCode, makeConfig, makeTmp, rmTmp } from './fsx-testkit.js';6import { createClaudePlugin, encodeProjectDir, parseAuthStatus, readTranscriptMeta, type ClaudeInfo } from './claude.js';7import { defaultClaudeEnv, isolatedClaudeEnv, resolveClaudeBinary, tildify } from './fsx-claude-env.js';89let tmp: string;10let ws: string;11let proj: string;12let home: string;13let projectsDir: string;1415const line = (o: unknown): string => JSON.stringify(o);16const user = (content: unknown, extra: Record<string, unknown> = {}): string =>17 line({ type: 'user', isSidechain: false, message: { role: 'user', content }, uuid: 'u', ...extra });18const assistant = (text: string): string => line({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text }] } });1920function transcript(dir: string, id: string, lines: string[], mtime: Date): string {21 mkdirSync(dir, { recursive: true });22 const f = path.join(dir, `${id}.jsonl`);23 writeFileSync(f, lines.join('\n') + '\n');24 utimesSync(f, mtime, mtime);25 return f;26}2728beforeAll(() => {29 tmp = makeTmp('cr-claude-');30 ws = path.join(tmp, 'ws');31 proj = path.join(ws, 'my_proj.v2');32 home = path.join(tmp, 'home');33 mkdirSync(proj, { recursive: true });34 mkdirSync(home, { recursive: true });35 projectsDir = path.join(home, '.claude', 'projects');36});37afterAll(() => rmTmp(tmp));3839describe('encodeProjectDir', () => {40 it('replaces every non-alphanumeric character by a dash', () => {41 expect(encodeProjectDir('/private/tmp/akos-fresh-project')).toBe('-private-tmp-akos-fresh-project');42 expect(encodeProjectDir('/Users/x y/My_Proj.v2/é')).toBe('-Users-x-y-My-Proj-v2--');43 });44 it('matches a directory that exists for this very checkout (read-only check, skipped when absent)', () => {45 const real = defaultClaudeEnv();46 const here = path.resolve(process.cwd(), '..', '..');47 const candidates = [here, path.dirname(here), path.dirname(path.dirname(here)), real.home];48 const hit = candidates.find((c) => existsSync(path.join(real.configDir, 'projects', encodeProjectDir(c))));49 if (!existsSync(path.join(real.configDir, 'projects'))) return;50 expect(hit).toBeDefined();51 });52});5354describe('parseAuthStatus', () => {55 it('maps the JSON form and drops identifying fields', () => {56 const out = JSON.stringify({ loggedIn: true, authMethod: 'api_key', apiProvider: 'firstParty', email: 'someone@example.invalid', orgId: 'org-1', orgName: 'Org', subscriptionType: 'max' });57 expect(parseAuthStatus(out)).toEqual({ authenticated: true, method: 'api_key', subscription: 'max' });58 expect(parseAuthStatus('{"loggedIn": false, "authMethod": "none"}')).toEqual({ authenticated: false, method: 'none', subscription: null });59 expect(parseAuthStatus('noise before\n{"loggedIn": true, "authMethod": "claude.ai"}\n')).toMatchObject({ authenticated: true, method: 'claude.ai' });60 });61 it('refuses to guess, and never passes through odd labels', () => {62 expect(parseAuthStatus('')).toEqual({ authenticated: null, method: null, subscription: null });63 expect(parseAuthStatus('{"loggedIn": "yes"}').authenticated).toBeNull();64 expect(parseAuthStatus('{broken').authenticated).toBeNull();65 expect(parseAuthStatus('{"loggedIn": true, "authMethod": "someone@example.invalid"}').method).toBeNull();66 expect(parseAuthStatus('Not logged in. Run claude auth login.').authenticated).toBe(false);67 expect(parseAuthStatus('Logged in as …').authenticated).toBe(true);68 });69});7071describe('/claude/info', () => {72 function fakeClaude(name: string, authJson: string, exit = 0): string {73 const p = path.join(tmp, name);74 writeFileSync(75 p,76 `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "9.8.7 (Claude Code)"; exit 0; fi\nif [ "$1" = "auth" ]; then cat <<'EOF'\n${authJson}\nEOF\nexit ${exit}; fi\nexit 2\n`,77 { mode: 0o755 },78 );79 return p;80 }81 it('reports path, version and a boolean authentication state without account data', async () => {82 const env = { ...isolatedClaudeEnv(home), claudeBin: fakeClaude('claude-ok', '{"loggedIn": true, "authMethod": "api_key", "email": "someone@example.invalid", "orgName": "Secret Org"}') };83 const app = await buildApp(makeConfig([ws]), createClaudePlugin({ env }));84 const r = await call<ClaudeInfo>(app, 'GET', '/claude/info');85 expect(r.body).toEqual({ path: env.claudeBin, version: '9.8.7', authenticated: true, method: 'api_key', subscription: null, configDir: path.join(home, '.claude') });86 expect(JSON.stringify(r.body)).not.toMatch(/example\.invalid|Secret Org/);87 await app.close();88 });89 it('returns authenticated:null when the status cannot be determined', async () => {90 const env = { ...isolatedClaudeEnv(home), claudeBin: fakeClaude('claude-odd', 'something unexpected', 1) };91 const app = await buildApp(makeConfig([ws]), createClaudePlugin({ env }));92 expect((await call<ClaudeInfo>(app, 'GET', '/claude/info')).body).toMatchObject({ version: '9.8.7', authenticated: null, method: null });93 await app.close();94 });95 it('reports a missing binary as null values (never invents a version)', async () => {96 const config = makeConfig([ws], { claudeBin: 'cr-definitely-not-installed-xyz' });97 const app = await buildApp(config, createClaudePlugin({ env: isolatedClaudeEnv(home) }));98 expect((await call<ClaudeInfo>(app, 'GET', '/claude/info')).body).toMatchObject({ path: null, version: null, authenticated: null });99 await app.close();100 });101 it('real CLI in an isolated HOME: version parsed, logged out (skipped when claude is not installed)', async () => {102 const config = makeConfig([ws]);103 const bin = await resolveClaudeBinary(config, defaultClaudeEnv());104 if (!bin.path) return;105 const iso = isolatedClaudeEnv(path.join(tmp, 'home-live'));106 mkdirSync(iso.configDir, { recursive: true });107 delete iso.childEnv.ANTHROPIC_API_KEY;108 delete iso.childEnv.ANTHROPIC_AUTH_TOKEN;109 delete iso.childEnv.CLAUDE_CODE_OAUTH_TOKEN;110 const app = await buildApp(config, createClaudePlugin({ env: iso }));111 const r = await call<ClaudeInfo>(app, 'GET', '/claude/info');112 expect(r.body.path).toBe(bin.path);113 expect(r.body.version).toMatch(/^\d+\.\d+\.\d+/);114 expect(r.body.authenticated).toBe(false);115 expect(r.body.configDir).toBe(iso.configDir);116 await app.close();117 }, 60_000);118 it('tildify', () => {119 expect(tildify('/Users/a/.claude/skills/x', '/Users/a')).toBe('~/.claude/skills/x');120 expect(tildify('/Users/ab/x', '/Users/a')).toBe('/Users/ab/x');121 });122});123124describe('/claude/conversations', () => {125 it('lists transcripts newest first with the first real user prompt and a line count', async () => {126 const dir = path.join(projectsDir, encodeProjectDir(proj));127 transcript(dir, 'aaaa-old', [user('First question about the build'), assistant('answer'), user('follow-up')], new Date('2026-01-01T10:00:00Z'));128 transcript(129 dir,130 'bbbb-new',131 [132 line({ type: 'queue-operation', operation: 'enqueue' }),133 line({ type: 'attachment', attachment: {} }),134 user('<command-name>/clear</command-name>'),135 user('<local-command-stdout>ok</local-command-stdout>'),136 user('meta text', { isMeta: true }),137 user('sidechain prompt', { isSidechain: true }),138 user([{ type: 'tool_result', content: 'x' }]),139 user([{ type: 'text', text: '<system-reminder>injected</system-reminder>' }, { type: 'text', text: ' Refactor the\nparser ' + 'x'.repeat(300) }]),140 assistant('ok'),141 '{not json',142 '',143 ],144 new Date('2026-02-01T10:00:00Z'),145 );146 transcript(dir, 'cccc-empty', [line({ type: 'summary', summary: 'x' })], new Date('2025-12-01T10:00:00Z'));147 writeFileSync(path.join(dir, 'notes.txt'), 'ignored');148 mkdirSync(path.join(dir, 'memory'), { recursive: true });149 symlinkSync(path.join(dir, 'aaaa-old.jsonl'), path.join(dir, 'link.jsonl'));150151 const app = await buildApp(makeConfig([ws]), createClaudePlugin({ env: isolatedClaudeEnv(home) }));152 const r = await call<ClaudeConversation[]>(app, 'POST', '/claude/conversations', { cwd: proj });153 expect(r.status).toBe(200);154 expect(r.body.map((c) => c.id)).toEqual(['bbbb-new', 'aaaa-old', 'cccc-empty']);155 expect(r.body[0]?.summary.startsWith('Refactor the parser xxx')).toBe(true);156 expect(r.body[0]?.summary.length).toBe(160);157 expect(r.body[0]?.messageCount).toBe(10);158 expect(r.body[0]?.updatedAt).toBe('2026-02-01T10:00:00.000Z');159 expect(r.body[1]).toEqual({ id: 'aaaa-old', summary: 'First question about the build', updatedAt: '2026-01-01T10:00:00.000Z', messageCount: 3 });160 expect(r.body[2]).toMatchObject({ summary: '', messageCount: 1 });161 await app.close();162 });163 it('strips a leading system-reminder inside a string prompt', async () => {164 const dir = path.join(tmp, 'meta');165 const f = transcript(dir, 'x', [user('<system-reminder>a\nb</system-reminder>\nReal prompt')], new Date());166 expect((await readTranscriptMeta(f)).summary).toBe('Real prompt');167 });168 it('stops at the read cap and counts only the complete lines actually read', async () => {169 const dir = path.join(tmp, 'meta');170 const lines = Array.from({ length: 100 }, (_, i) => assistant(`message ${String(i).padStart(3, '0')} ${'p'.repeat(70)}`));171 const f = transcript(dir, 'big', lines, new Date());172 const lineLen = Buffer.byteLength(lines[0] as string) + 1;173 expect((await readTranscriptMeta(f)).messageCount).toBe(100);174 expect((await readTranscriptMeta(f, lineLen * 10 + 5)).messageCount).toBe(10);175 expect((await readTranscriptMeta(f, lineLen * 10)).messageCount).toBe(10);176 });177 it('keeps the newest 50', async () => {178 const many = path.join(ws, 'many');179 mkdirSync(many, { recursive: true });180 const dir = path.join(projectsDir, encodeProjectDir(many));181 for (let i = 0; i < 55; i++) transcript(dir, `c${String(i).padStart(2, '0')}`, [user(`prompt ${i}`)], new Date(Date.UTC(2026, 0, 1, 0, i)));182 const app = await buildApp(makeConfig([ws]), createClaudePlugin({ env: isolatedClaudeEnv(home) }));183 const r = await call<ClaudeConversation[]>(app, 'POST', '/claude/conversations', { cwd: many });184 expect(r.body.length).toBe(50);185 expect(r.body[0]?.id).toBe('c54');186 expect(r.body[49]?.id).toBe('c05');187 await app.close();188 });189 it('requires an absolute cwd inside a workspace root; unknown projects give []', async () => {190 const app = await buildApp(makeConfig([ws]), createClaudePlugin({ env: isolatedClaudeEnv(home) }));191 expect(errCode(await call(app, 'POST', '/claude/conversations', { cwd: home }))).toBe('root_not_allowed');192 expect(errCode(await call(app, 'POST', '/claude/conversations', { cwd: 'relative/dir' }))).toBe('bad_request');193 expect(errCode(await call(app, 'POST', '/claude/conversations', {}))).toBe('bad_request');194 expect((await call(app, 'POST', '/claude/conversations', { cwd: ws })).body).toEqual([]);195 await app.close();196 });197});198