Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.
- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
"grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
+ caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
+ extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
One-click merge deferred (winner session stays open for a manual merge).
Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
129 lines
4.4 KiB
TypeScript
129 lines
4.4 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|
|
})
|