Home screen gains a Sessions↔Projects segmented control. The Projects view discovers the host's git repos and, on click, opens a tab named after the repo, spawned in the repo dir, auto-running `claude` (1:N sessions per project). Backend (P2/P4): - src/http/projects.ts — parseGitHead + buildProjects(cfg, liveSessions): depth-capped BFS for .git (skips node_modules/dotdirs/symlinks, stops descending at a repo), .git/HEAD branch parse, rate-limited `git status` dirty check (execFile, no shell, 2s timeout, concurrency 8), merge of ~/.claude/projects cwds (reuses history.listSessions), cwd-prefix session merge (fresh each call), TTL discovery cache with in-flight dedup. - src/server.ts — read-only GET /projects (no Origin guard, []-fallback). Frontend (P5/P6): - public/projects.ts — mountProjects: 1:N cards (branch chip, dirty dot, session rows, "+ start claude here"), live search, localStorage favourites; pure helpers extracted for unit tests. - public/tabs.ts — openProject (#n suffix for dup names, sends 'claude\r'), countOpenWithTitlePrefix, Sessions↔Projects home toggle (updateHomeView). - public/style.css — Projects panel + .home-seg control styles. Config (P3, hardened): PROJECT_ROOTS/SCAN_DEPTH/SCAN_TTL/DIRTY_CHECK already added; this commit adds fail-fast rejection of relative PROJECT_ROOTS. Review hardening (4 confirmed HIGH + boundary validation): - carriage-return fix so claude auto-executes (A3); seg-control z-order; parallelised history merge; concurrent-cache-miss dedup. - normalizeProject guards the /projects response; getFavs filters non-strings. Tests: +57 (398 total). Backend src/http/projects.ts 93.4%, config.ts 98%. Verified: both typechecks clean, full suite green, build:web OK, coverage ≥80×4, real-browser smoke (83 repos, branch/dirty correct, click→claude tab).
441 lines
16 KiB
TypeScript
441 lines
16 KiB
TypeScript
/**
|
|
* test/config.test.ts — T4: config module unit tests (TDD)
|
|
*
|
|
* Strategy:
|
|
* - Mock `node:os` networkInterfaces to inject fake NICs (M1)
|
|
* - Assert defaults, env overrides, validation errors, allowedOrigins shape
|
|
*
|
|
* vi.mock is hoisted to the top of the file by vitest — the factory runs before
|
|
* any import, so config.ts will receive the mocked os module.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import type { NetworkInterfaceInfo } from 'node:os'
|
|
|
|
// ── mock node:os ─────────────────────────────────────────────────────────────
|
|
// vi.mock is hoisted by vitest, so this factory runs before config.ts is imported.
|
|
|
|
const mockNetworkInterfaces = vi.fn<[], ReturnType<typeof import('node:os').networkInterfaces>>()
|
|
mockNetworkInterfaces.mockReturnValue({})
|
|
|
|
const mockHomedir = vi.fn<[], string>()
|
|
mockHomedir.mockReturnValue('/home/testuser')
|
|
|
|
vi.mock('node:os', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('node:os')>()
|
|
return {
|
|
...actual,
|
|
default: {
|
|
...actual.default,
|
|
homedir: () => mockHomedir(),
|
|
networkInterfaces: () => mockNetworkInterfaces(),
|
|
},
|
|
homedir: () => mockHomedir(),
|
|
networkInterfaces: () => mockNetworkInterfaces(),
|
|
}
|
|
})
|
|
|
|
// ── mock tmux availability so resolveUseTmux's auto-detect is deterministic ──
|
|
const mockTmuxAvailable = vi.fn<[], boolean>()
|
|
mockTmuxAvailable.mockReturnValue(false)
|
|
vi.mock('../src/session/tmux.js', () => ({
|
|
tmuxAvailable: () => mockTmuxAvailable(),
|
|
tmuxName: (id: string) => `web_${id}`,
|
|
hasSession: () => false,
|
|
killSession: () => undefined,
|
|
}))
|
|
|
|
// Import config AFTER mock is set up (dynamic import ensures mock applies)
|
|
const { loadConfig } = await import('../src/config.js')
|
|
|
|
// Helper: build a fake IPv4 NIC entry
|
|
function makeNic(address: string, internal: boolean): NetworkInterfaceInfo {
|
|
return {
|
|
address,
|
|
netmask: '255.255.255.0',
|
|
family: 'IPv4',
|
|
mac: '00:00:00:00:00:00',
|
|
internal,
|
|
cidr: `${address}/24`,
|
|
}
|
|
}
|
|
|
|
// ── Default values ────────────────────────────────────────────────────────────
|
|
describe('loadConfig — defaults', () => {
|
|
beforeEach(() => {
|
|
mockNetworkInterfaces.mockReturnValue({})
|
|
mockHomedir.mockReturnValue('/home/testuser')
|
|
})
|
|
|
|
it('uses PORT 3000', () => {
|
|
const cfg = loadConfig({})
|
|
expect(cfg.port).toBe(3000)
|
|
})
|
|
|
|
it('uses BIND_HOST 0.0.0.0', () => {
|
|
const cfg = loadConfig({})
|
|
expect(cfg.bindHost).toBe('0.0.0.0')
|
|
})
|
|
|
|
it('uses IDLE_TTL 24h in ms', () => {
|
|
const cfg = loadConfig({})
|
|
expect(cfg.idleTtlMs).toBe(24 * 60 * 60 * 1000)
|
|
})
|
|
|
|
it('uses SCROLLBACK_BYTES 2MB', () => {
|
|
const cfg = loadConfig({})
|
|
expect(cfg.scrollbackBytes).toBe(2 * 1024 * 1024)
|
|
})
|
|
|
|
it('uses MAX_PAYLOAD_BYTES 1MB', () => {
|
|
const cfg = loadConfig({})
|
|
expect(cfg.maxPayloadBytes).toBe(1 * 1024 * 1024)
|
|
})
|
|
|
|
it('uses WS_PATH /term', () => {
|
|
const cfg = loadConfig({})
|
|
expect(cfg.wsPath).toBe('/term')
|
|
})
|
|
|
|
it('uses homeDir from os.homedir()', () => {
|
|
mockHomedir.mockReturnValue('/home/testuser')
|
|
const cfg = loadConfig({})
|
|
expect(cfg.homeDir).toBe('/home/testuser')
|
|
})
|
|
})
|
|
|
|
// ── Env overrides ─────────────────────────────────────────────────────────────
|
|
describe('loadConfig — env overrides', () => {
|
|
beforeEach(() => {
|
|
mockNetworkInterfaces.mockReturnValue({})
|
|
mockHomedir.mockReturnValue('/home/testuser')
|
|
})
|
|
|
|
it('reads PORT from env', () => {
|
|
const cfg = loadConfig({ PORT: '8080' })
|
|
expect(cfg.port).toBe(8080)
|
|
})
|
|
|
|
it('reads BIND_HOST from env', () => {
|
|
const cfg = loadConfig({ BIND_HOST: '127.0.0.1' })
|
|
expect(cfg.bindHost).toBe('127.0.0.1')
|
|
})
|
|
|
|
it('reads SHELL_PATH from env', () => {
|
|
const cfg = loadConfig({ SHELL_PATH: '/bin/bash' })
|
|
expect(cfg.shellPath).toBe('/bin/bash')
|
|
})
|
|
|
|
it('reads IDLE_TTL (seconds) and converts to ms', () => {
|
|
const cfg = loadConfig({ IDLE_TTL: '3600' })
|
|
expect(cfg.idleTtlMs).toBe(3600 * 1000)
|
|
})
|
|
|
|
it('reads SCROLLBACK_BYTES from env', () => {
|
|
const cfg = loadConfig({ SCROLLBACK_BYTES: '1048576' })
|
|
expect(cfg.scrollbackBytes).toBe(1048576)
|
|
})
|
|
|
|
it('reads MAX_PAYLOAD_BYTES from env', () => {
|
|
const cfg = loadConfig({ MAX_PAYLOAD_BYTES: '524288' })
|
|
expect(cfg.maxPayloadBytes).toBe(524288)
|
|
})
|
|
|
|
it('reads WS_PATH from env', () => {
|
|
const cfg = loadConfig({ WS_PATH: '/ws' })
|
|
expect(cfg.wsPath).toBe('/ws')
|
|
})
|
|
})
|
|
|
|
// ── Validation errors ─────────────────────────────────────────────────────────
|
|
describe('loadConfig — invalid values throw', () => {
|
|
beforeEach(() => {
|
|
mockNetworkInterfaces.mockReturnValue({})
|
|
})
|
|
|
|
it('throws for PORT below 1', () => {
|
|
expect(() => loadConfig({ PORT: '0' })).toThrow()
|
|
})
|
|
|
|
it('throws for PORT above 65535', () => {
|
|
expect(() => loadConfig({ PORT: '65536' })).toThrow()
|
|
})
|
|
|
|
it('throws for PORT that is not a number', () => {
|
|
expect(() => loadConfig({ PORT: 'abc' })).toThrow()
|
|
})
|
|
|
|
it('throws for IDLE_TTL that is not a number', () => {
|
|
expect(() => loadConfig({ IDLE_TTL: 'abc' })).toThrow()
|
|
})
|
|
|
|
it('throws for IDLE_TTL that is negative', () => {
|
|
expect(() => loadConfig({ IDLE_TTL: '-1' })).toThrow()
|
|
})
|
|
|
|
it('throws for SCROLLBACK_BYTES that is not a number', () => {
|
|
expect(() => loadConfig({ SCROLLBACK_BYTES: 'nope' })).toThrow()
|
|
})
|
|
|
|
it('throws for MAX_PAYLOAD_BYTES that is not a number', () => {
|
|
expect(() => loadConfig({ MAX_PAYLOAD_BYTES: 'nope' })).toThrow()
|
|
})
|
|
})
|
|
|
|
// ── allowedOrigins — M1 core tests ──────────────────────────────────────────
|
|
describe('loadConfig — allowedOrigins (M1)', () => {
|
|
beforeEach(() => {
|
|
mockNetworkInterfaces.mockReturnValue({})
|
|
mockHomedir.mockReturnValue('/home/testuser')
|
|
})
|
|
|
|
it('always includes localhost variants', () => {
|
|
const cfg = loadConfig({})
|
|
const origins = cfg.allowedOrigins
|
|
expect(origins).toContain('http://localhost:3000')
|
|
expect(origins).toContain('http://127.0.0.1:3000')
|
|
})
|
|
|
|
it('never contains 0.0.0.0 (M1: bindHost must not bleed in)', () => {
|
|
// even if BIND_HOST is 0.0.0.0 (default)
|
|
const cfg = loadConfig({ BIND_HOST: '0.0.0.0' })
|
|
const hasWildcard = cfg.allowedOrigins.some((o) => o.includes('0.0.0.0'))
|
|
expect(hasWildcard).toBe(false)
|
|
})
|
|
|
|
it('includes injected LAN NIC IPv4', () => {
|
|
mockNetworkInterfaces.mockReturnValue({
|
|
en0: [makeNic('192.168.1.100', false)],
|
|
})
|
|
const cfg = loadConfig({})
|
|
expect(cfg.allowedOrigins).toContain('http://192.168.1.100:3000')
|
|
})
|
|
|
|
it('includes https:// variant for injected NIC', () => {
|
|
mockNetworkInterfaces.mockReturnValue({
|
|
en0: [makeNic('10.0.0.5', false)],
|
|
})
|
|
const cfg = loadConfig({})
|
|
expect(cfg.allowedOrigins).toContain('https://10.0.0.5:3000')
|
|
})
|
|
|
|
it('excludes internal (loopback) NICs from LAN list (127.x added separately)', () => {
|
|
mockNetworkInterfaces.mockReturnValue({
|
|
lo0: [makeNic('127.0.0.1', true)],
|
|
})
|
|
const cfg = loadConfig({})
|
|
// 127.0.0.1 added via loopback constants; no duplicates beyond http+https
|
|
const count127 = cfg.allowedOrigins.filter((o) =>
|
|
o.includes('127.0.0.1'),
|
|
).length
|
|
// http and https = 2
|
|
expect(count127).toBeLessThanOrEqual(2)
|
|
})
|
|
|
|
it('skips IPv6 entries on NICs', () => {
|
|
mockNetworkInterfaces.mockReturnValue({
|
|
en0: [
|
|
{
|
|
address: 'fe80::1',
|
|
netmask: 'ffff:ffff:ffff:ffff::',
|
|
family: 'IPv6',
|
|
mac: '00:00:00:00:00:00',
|
|
internal: false,
|
|
cidr: 'fe80::1/64',
|
|
scopeid: 0,
|
|
} as NetworkInterfaceInfo,
|
|
makeNic('192.168.1.50', false),
|
|
],
|
|
})
|
|
const cfg = loadConfig({})
|
|
const hasIPv6 = cfg.allowedOrigins.some((o) => o.includes('fe80'))
|
|
expect(hasIPv6).toBe(false)
|
|
expect(cfg.allowedOrigins).toContain('http://192.168.1.50:3000')
|
|
})
|
|
|
|
it('merges ALLOWED_ORIGINS env var into the list', () => {
|
|
const extra = 'http://mymac.local:3000,https://mymac.local:3000'
|
|
const cfg = loadConfig({ ALLOWED_ORIGINS: extra })
|
|
expect(cfg.allowedOrigins).toContain('http://mymac.local:3000')
|
|
expect(cfg.allowedOrigins).toContain('https://mymac.local:3000')
|
|
})
|
|
|
|
it('respects custom PORT when building origins', () => {
|
|
mockNetworkInterfaces.mockReturnValue({
|
|
en0: [makeNic('172.16.0.1', false)],
|
|
})
|
|
const cfg = loadConfig({ PORT: '4000' })
|
|
expect(cfg.allowedOrigins).toContain('http://172.16.0.1:4000')
|
|
expect(cfg.allowedOrigins).toContain('https://172.16.0.1:4000')
|
|
})
|
|
|
|
it('deduplicates origins when ALLOWED_ORIGINS overlaps with derived set', () => {
|
|
const cfg = loadConfig({
|
|
ALLOWED_ORIGINS: 'http://localhost:3000,http://localhost:3000',
|
|
})
|
|
const localhostCount = cfg.allowedOrigins.filter(
|
|
(o) => o === 'http://localhost:3000',
|
|
).length
|
|
expect(localhostCount).toBe(1)
|
|
})
|
|
})
|
|
|
|
// ── MAX_SESSIONS + new operational constants ──────────────────────────────────
|
|
describe('loadConfig — session/rate/timer constants', () => {
|
|
beforeEach(() => {
|
|
mockNetworkInterfaces.mockReturnValue({})
|
|
mockHomedir.mockReturnValue('/home/testuser')
|
|
})
|
|
|
|
it('defaults maxSessions to 50', () => {
|
|
expect(loadConfig({}).maxSessions).toBe(50)
|
|
})
|
|
|
|
it('reads MAX_SESSIONS from env', () => {
|
|
expect(loadConfig({ MAX_SESSIONS: '20' }).maxSessions).toBe(20)
|
|
})
|
|
|
|
it('throws for a non-integer MAX_SESSIONS', () => {
|
|
expect(() => loadConfig({ MAX_SESSIONS: 'lots' })).toThrow()
|
|
})
|
|
|
|
it('defaults maxMsgsPerSec to 2000 and reads MAX_MSGS_PER_SEC', () => {
|
|
expect(loadConfig({}).maxMsgsPerSec).toBe(2000)
|
|
expect(loadConfig({ MAX_MSGS_PER_SEC: '500' }).maxMsgsPerSec).toBe(500)
|
|
})
|
|
|
|
it('defaults permTimeoutMs / reapIntervalMs / previewBytes and reads overrides', () => {
|
|
const def = loadConfig({})
|
|
expect(def.permTimeoutMs).toBe(5 * 60_000)
|
|
expect(def.reapIntervalMs).toBe(60_000)
|
|
expect(def.previewBytes).toBe(24 * 1024)
|
|
const over = loadConfig({ PERM_TIMEOUT_MS: '1000', REAP_INTERVAL_MS: '2000', PREVIEW_BYTES: '4096' })
|
|
expect(over.permTimeoutMs).toBe(1000)
|
|
expect(over.reapIntervalMs).toBe(2000)
|
|
expect(over.previewBytes).toBe(4096)
|
|
})
|
|
})
|
|
|
|
// ── resolveUseTmux branches (USE_TMUX env) ────────────────────────────────────
|
|
describe('loadConfig — resolveUseTmux', () => {
|
|
beforeEach(() => {
|
|
mockNetworkInterfaces.mockReturnValue({})
|
|
mockHomedir.mockReturnValue('/home/testuser')
|
|
mockTmuxAvailable.mockReturnValue(false)
|
|
})
|
|
|
|
it.each(['1', 'true', 'on', 'ON', ' True '])('forces tmux ON for %j', (v) => {
|
|
expect(loadConfig({ USE_TMUX: v }).useTmux).toBe(true)
|
|
})
|
|
|
|
it.each(['0', 'false', 'off'])('forces tmux OFF for %j', (v) => {
|
|
expect(loadConfig({ USE_TMUX: v }).useTmux).toBe(false)
|
|
})
|
|
|
|
it('auto-detects (unset) → true when tmux is available', () => {
|
|
mockTmuxAvailable.mockReturnValue(true)
|
|
expect(loadConfig({}).useTmux).toBe(true)
|
|
})
|
|
|
|
it('auto-detects (unset / "auto") → false when tmux is unavailable', () => {
|
|
mockTmuxAvailable.mockReturnValue(false)
|
|
expect(loadConfig({}).useTmux).toBe(false)
|
|
expect(loadConfig({ USE_TMUX: 'auto' }).useTmux).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ── ALLOWED_ORIGINS scheme validation (M1) ────────────────────────────────────
|
|
describe('loadConfig — ALLOWED_ORIGINS scheme validation', () => {
|
|
beforeEach(() => {
|
|
mockNetworkInterfaces.mockReturnValue({})
|
|
mockHomedir.mockReturnValue('/home/testuser')
|
|
})
|
|
|
|
it('rejects non-http(s) scheme entries (file:, javascript:, bare host)', () => {
|
|
const cfg = loadConfig({
|
|
ALLOWED_ORIGINS: 'file:///etc/passwd,javascript:alert(1),notaurl,http://ok.local:3000',
|
|
})
|
|
expect(cfg.allowedOrigins).toContain('http://ok.local:3000')
|
|
expect(cfg.allowedOrigins.some((o) => o.startsWith('file:'))).toBe(false)
|
|
expect(cfg.allowedOrigins.some((o) => o.startsWith('javascript:'))).toBe(false)
|
|
expect(cfg.allowedOrigins).not.toContain('notaurl')
|
|
})
|
|
|
|
it('keeps valid https entries', () => {
|
|
const cfg = loadConfig({ ALLOWED_ORIGINS: 'https://phone.local:3000' })
|
|
expect(cfg.allowedOrigins).toContain('https://phone.local:3000')
|
|
})
|
|
})
|
|
|
|
// ── Immutability ──────────────────────────────────────────────────────────────
|
|
describe('loadConfig — returned object is frozen', () => {
|
|
beforeEach(() => {
|
|
mockNetworkInterfaces.mockReturnValue({})
|
|
mockHomedir.mockReturnValue('/home/testuser')
|
|
})
|
|
|
|
it('result is Object.frozen', () => {
|
|
const cfg = loadConfig({})
|
|
expect(Object.isFrozen(cfg)).toBe(true)
|
|
})
|
|
|
|
it('allowedOrigins array is frozen', () => {
|
|
const cfg = loadConfig({})
|
|
expect(Object.isFrozen(cfg.allowedOrigins)).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ── v0.6 project discovery config ───────────────────────────────────────────────
|
|
describe('loadConfig — project discovery (v0.6)', () => {
|
|
beforeEach(() => {
|
|
mockNetworkInterfaces.mockReturnValue({})
|
|
mockHomedir.mockReturnValue('/home/testuser')
|
|
})
|
|
|
|
it('defaults: projectRoots=[homeDir], depth 4, ttl 10000, dirtyCheck true', () => {
|
|
const cfg = loadConfig({})
|
|
expect(cfg.projectRoots).toEqual(['/home/testuser'])
|
|
expect(cfg.projectScanDepth).toBe(4)
|
|
expect(cfg.projectScanTtlMs).toBe(10_000)
|
|
expect(cfg.projectDirtyCheck).toBe(true)
|
|
expect(Object.isFrozen(cfg.projectRoots)).toBe(true)
|
|
})
|
|
|
|
it('PROJECT_ROOTS: comma-separated absolute paths, trimmed', () => {
|
|
const cfg = loadConfig({ PROJECT_ROOTS: '/a, /b/c ,/d' })
|
|
expect(cfg.projectRoots).toEqual(['/a', '/b/c', '/d'])
|
|
})
|
|
|
|
it('PROJECT_ROOTS: expands ~ and ~/sub to homeDir', () => {
|
|
const cfg = loadConfig({ PROJECT_ROOTS: '~,~/code' })
|
|
expect(cfg.projectRoots).toEqual(['/home/testuser', '/home/testuser/code'])
|
|
})
|
|
|
|
it('PROJECT_ROOTS: empty / whitespace falls back to [homeDir]', () => {
|
|
expect(loadConfig({ PROJECT_ROOTS: '' }).projectRoots).toEqual(['/home/testuser'])
|
|
expect(loadConfig({ PROJECT_ROOTS: ' ' }).projectRoots).toEqual(['/home/testuser'])
|
|
})
|
|
|
|
it('PROJECT_ROOTS: a relative path fails fast with a clear error', () => {
|
|
expect(() => loadConfig({ PROJECT_ROOTS: 'relative/dir' })).toThrow(/absolute path/)
|
|
expect(() => loadConfig({ PROJECT_ROOTS: '/ok,./bad' })).toThrow(/PROJECT_ROOTS/)
|
|
})
|
|
|
|
it('PROJECT_SCAN_DEPTH / PROJECT_SCAN_TTL override and validate', () => {
|
|
const cfg = loadConfig({ PROJECT_SCAN_DEPTH: '2', PROJECT_SCAN_TTL: '0' })
|
|
expect(cfg.projectScanDepth).toBe(2)
|
|
expect(cfg.projectScanTtlMs).toBe(0)
|
|
expect(() => loadConfig({ PROJECT_SCAN_DEPTH: 'deep' })).toThrow(/PROJECT_SCAN_DEPTH/)
|
|
expect(() => loadConfig({ PROJECT_SCAN_TTL: '-1' })).toThrow(/PROJECT_SCAN_TTL/)
|
|
})
|
|
|
|
it('PROJECT_DIRTY_CHECK: parses on/off forms, defaults true', () => {
|
|
expect(loadConfig({ PROJECT_DIRTY_CHECK: '0' }).projectDirtyCheck).toBe(false)
|
|
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'off' }).projectDirtyCheck).toBe(false)
|
|
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'false' }).projectDirtyCheck).toBe(false)
|
|
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'on' }).projectDirtyCheck).toBe(true)
|
|
expect(loadConfig({ PROJECT_DIRTY_CHECK: '1' }).projectDirtyCheck).toBe(true)
|
|
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'garbage' }).projectDirtyCheck).toBe(true)
|
|
})
|
|
})
|