/** * test/fanout.test.ts (W5) — pure launch-command + branch-slug builders. * * The load-bearing case is shellSingleQuote: the prompt is typed as raw bytes into * a lane's shell before `claude` parses it, so it must become one literal argv * element — no shell metacharacter may execute. */ import { describe, it, expect } from 'vitest' import { shellSingleQuote, sanitizePrompt, buildFanoutCmd, laneBranch, slugify, FANOUT_PROMPT_MAX, } from '../public/fanout.js' // Local mirror of validateBranchNameClient's rules (projects.ts) — inlined so this // stays a pure node test (importing projects.js would drag in @xterm/xterm). function isValidBranch(branch: string): boolean { if (!branch || branch.length > 250) return false if (/[\x00-\x20\x7f]/.test(branch)) return false if (branch.startsWith('-') || branch.includes('..') || branch.endsWith('.lock')) return false if (/[~^:?*[\\]/.test(branch)) return false if (branch.includes('@{')) return false if (branch.startsWith('/') || branch.endsWith('/') || branch.includes('//')) return false return true } describe('shellSingleQuote', () => { it('single-quotes a plain string', () => { expect(shellSingleQuote('fix bug')).toBe(`'fix bug'`) }) it("escapes an embedded single quote with the '\\'' sequence", () => { expect(shellSingleQuote("it's ok")).toBe(`'it'\\''s ok'`) }) it('neutralizes shell metacharacters as literal text', () => { // $(...), backticks, ;, && all sit inside single quotes → passed verbatim. const dangerous = 'a; rm -rf / && echo $(whoami) `id`' const quoted = shellSingleQuote(dangerous) expect(quoted.startsWith("'")).toBe(true) expect(quoted.endsWith("'")).toBe(true) // No unescaped single quote can break out of the quoting. expect(quoted.slice(1, -1).includes("'")).toBe(false) }) }) describe('sanitizePrompt', () => { it('collapses newlines (\\n, \\r\\n) to single spaces', () => { expect(sanitizePrompt('a\nb\r\nc')).toBe('a b c') }) it('collapses a bare carriage return too (would submit a partial line)', () => { expect(sanitizePrompt('a\rb')).toBe('a b') }) it('trims surrounding whitespace', () => { expect(sanitizePrompt(' hello ')).toBe('hello') }) it('caps the length at FANOUT_PROMPT_MAX', () => { const long = 'x'.repeat(FANOUT_PROMPT_MAX + 500) expect(sanitizePrompt(long)).toHaveLength(FANOUT_PROMPT_MAX) }) }) describe('buildFanoutCmd', () => { it('adds --permission-mode for a non-default mode and ends with \\r', () => { expect(buildFanoutCmd('fix bug', 'plan', true)).toBe(`claude --permission-mode plan 'fix bug'\r`) }) it('omits the flag for the default mode', () => { expect(buildFanoutCmd('fix bug', 'default', true)).toBe(`claude 'fix bug'\r`) }) it('downgrades auto to default when the server forbids it (SEC-M5)', () => { expect(buildFanoutCmd('fix bug', 'auto', false)).toBe(`claude 'fix bug'\r`) }) it('honors auto when the server allows it', () => { expect(buildFanoutCmd('fix bug', 'auto', true)).toBe(`claude --permission-mode auto 'fix bug'\r`) }) it('shell-quotes an injection-style prompt into one argv element', () => { const cmd = buildFanoutCmd('rm -rf $(pwd); echo hi', 'default', false) expect(cmd).toBe(`claude 'rm -rf $(pwd); echo hi'\r`) }) it('collapses a multi-line prompt before quoting', () => { expect(buildFanoutCmd('line1\nline2', 'default', false)).toBe(`claude 'line1 line2'\r`) }) }) describe('laneBranch', () => { it('builds `${base}-lane-${i}`', () => { expect(laneBranch('feat-x', 3)).toBe('feat-x-lane-3') }) it('produces a valid git branch name', () => { expect(isValidBranch(laneBranch('feat-x', 3))).toBe(true) expect(isValidBranch(laneBranch(slugify('Add dark mode!'), 1))).toBe(true) }) }) describe('slugify', () => { it('lowercases and dash-collapses non-alphanumeric runs', () => { expect(slugify('Add Dark Mode!')).toBe('add-dark-mode') }) it('strips leading/trailing dashes', () => { expect(slugify(' ...hello... ')).toBe('hello') }) it('falls back to "fanout" for an empty/symbol-only prompt', () => { expect(slugify('!!!')).toBe('fanout') expect(slugify('')).toBe('fanout') }) it('caps the slug length and stays a valid branch base', () => { const slug = slugify('a'.repeat(100)) expect(slug.length).toBeLessThanOrEqual(40) expect(isValidBranch(slug)).toBe(true) }) })