Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/__tests__/unit/chat-route-model-persistence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';

describe('chat route model persistence contract', () => {
it('persists the resolved SDK model when a status event includes statusData.model', () => {
const routePath = path.join(process.cwd(), 'src', 'app', 'api', 'chat', 'route.ts');
const source = fs.readFileSync(routePath, 'utf8');

assert.match(
source,
/if\s*\(statusData\.model\)\s*\{\s*updateSessionModel\(sessionId,\s*statusData\.model\);?\s*\}/,
);
});
});
44 changes: 44 additions & 0 deletions src/__tests__/unit/claude-client-launch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';

import { normalizeClaudeCodeModel, resolveClaudeLaunchConfig } from '../../lib/claude-client';

describe('claude client launch', () => {
it('resolves npm cmd wrappers to cli.js plus sibling node.exe on Windows', () => {
if (process.platform !== 'win32') return;

const wrapperPath = 'C:\\Program Files\\nodejs\\claude.cmd';
const config = resolveClaudeLaunchConfig(wrapperPath);

assert.equal(config.pathToClaudeCodeExecutable, 'C:\\Program Files\\nodejs\\node_modules\\@anthropic-ai\\claude-code\\cli.js');
assert.equal(config.executable, 'node');
});

it('preserves non-npm Windows cmd shims instead of rewriting them to a node_modules path', () => {
if (process.platform !== 'win32') return;

const scoopShimPath = 'C:\\Users\\zy\\scoop\\shims\\claude.cmd';
const config = resolveClaudeLaunchConfig(scoopShimPath);

assert.equal(config.pathToClaudeCodeExecutable, scoopShimPath);
assert.equal(config.executable, undefined);
});

it('normalizes upstream Anthropic model ids back to Claude Code aliases', () => {
const normalized = normalizeClaudeCodeModel('claude-sonnet-4-20250514', [
{ modelId: 'sonnet', upstreamModelId: 'claude-sonnet-4-20250514' },
{ modelId: 'opus', upstreamModelId: 'claude-opus-4-20250514' },
]);

assert.equal(normalized, 'sonnet');
});
it('preserves exe paths', () => {
const exePath = process.platform === 'win32'
? 'C:\\Program Files\\Claude\\claude.exe'
: '/usr/local/bin/claude';

const config = resolveClaudeLaunchConfig(exePath);
assert.equal(config.pathToClaudeCodeExecutable, exePath);
assert.equal(config.executable, undefined);
});
});
27 changes: 27 additions & 0 deletions src/__tests__/unit/claude-launch-error-messaging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';

import { classifyClaudeLaunchFailure } from '../../lib/claude-client';

describe('claude launch error messaging', () => {
it('classifies spawn node ENOENT as missing node runtime', () => {
assert.equal(
classifyClaudeLaunchFailure('spawn node ENOENT', 'ENOENT'),
'missing_node_runtime',
);
});

it('classifies missing claude executable as missing cli', () => {
assert.equal(
classifyClaudeLaunchFailure('spawn claude ENOENT', 'ENOENT'),
'missing_claude_cli',
);
});

it('does not classify generic spawn failures without ENOENT', () => {
assert.equal(
classifyClaudeLaunchFailure('spawn node EPERM', 'EPERM'),
null,
);
});
});
9 changes: 6 additions & 3 deletions src/__tests__/unit/claude-session-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import assert from 'node:assert/strict';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { pathToFileURL } from 'url';

// We test the parser functions by creating temporary JSONL files
// that mimic Claude Code's session storage format.
Expand Down Expand Up @@ -114,15 +115,16 @@ function makeAssistantEntry(opts: {

// Since the project uses path aliases (@/), we import via a relative path
// that tsx can resolve with the project's tsconfig.
const parserPath = path.resolve(__dirname, '../../lib/claude-session-parser.ts');
const parserPath = pathToFileURL(path.resolve(__dirname, '../../lib/claude-session-parser.ts')).href;

describe('claude-session-parser', () => {
// We'll dynamically import the parser module
let parser: typeof import('../../lib/claude-session-parser');

before(async () => {
// Set HOME to our test directory so the parser looks for sessions there
// Set HOME/USERPROFILE to our test directory so the parser looks for sessions there
process.env.HOME = TEST_DIR;
process.env.USERPROFILE = TEST_DIR;

// Dynamic import - tsx handles the TypeScript + path alias resolution
parser = await import(parserPath);
Expand All @@ -131,8 +133,9 @@ describe('claude-session-parser', () => {
after(() => {
// Clean up test directory
fs.rmSync(TEST_DIR, { recursive: true, force: true });
// Restore HOME
// Restore HOME/USERPROFILE
process.env.HOME = os.homedir();
process.env.USERPROFILE = os.homedir();
});

describe('decodeProjectPath', () => {
Expand Down
62 changes: 62 additions & 0 deletions src/__tests__/unit/platform-claude-detection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import os from 'node:os';
import path from 'node:path';

import { getClaudeCandidatePaths, getExtraPathDirs } from '../../lib/platform';

describe('platform claude detection', () => {
it('includes Scoop shim directories in expanded Windows PATH', () => {
if (process.platform !== 'win32') return;

const home = os.homedir();
const dirs = getExtraPathDirs();

assert.ok(
dirs.includes(path.join(home, 'scoop', 'shims')),
'expected user Scoop shim dir to be searched',
);

const programData = process.env.ProgramData || 'C:\\ProgramData';
assert.ok(
dirs.includes(path.join(programData, 'scoop', 'shims')),
'expected system Scoop shim dir to be searched',
);
});

it('includes Scoop claude wrapper candidates on Windows', () => {
if (process.platform !== 'win32') return;

const home = os.homedir();
const candidates = getClaudeCandidatePaths();

assert.ok(
candidates.includes(path.join(home, 'scoop', 'shims', 'claude.cmd')),
'expected user Scoop claude.cmd candidate',
);

const programData = process.env.ProgramData || 'C:\\ProgramData';
assert.ok(
candidates.includes(path.join(programData, 'scoop', 'shims', 'claude.cmd')),
'expected system Scoop claude.cmd candidate',
);
});

it('includes Node.js global install directory candidates on Windows', () => {
if (process.platform !== 'win32') return;

const candidates = getClaudeCandidatePaths();
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';

assert.ok(
candidates.includes(path.join(programFiles, 'nodejs', 'claude.cmd')),
'expected Program Files nodejs claude.cmd candidate',
);

assert.ok(
candidates.includes(path.join(programFilesX86, 'nodejs', 'claude.cmd')),
'expected Program Files (x86) nodejs claude.cmd candidate',
);
});
});
Loading