SPB Git

Repository Public

claude.test.ts 11.0 KB 198 lines typescript

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
Files main
Menu

Theme