feat(fanout): worktree fan-out board — race N agent lanes of one repo (W5)

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.
This commit is contained in:
Yaojia Wang
2026-07-13 05:09:19 +02:00
parent c81821b890
commit 9683a16f4f
20 changed files with 1225 additions and 8 deletions

View File

@@ -299,6 +299,18 @@ describe('loadConfig — session/rate/timer constants', () => {
expect(() => loadConfig({ MAX_SESSIONS: 'lots' })).toThrow()
})
it('defaults maxFanoutLanes to 6 (W5)', () => {
expect(loadConfig({}).maxFanoutLanes).toBe(6)
})
it('reads MAX_FANOUT_LANES from env', () => {
expect(loadConfig({ MAX_FANOUT_LANES: '3' }).maxFanoutLanes).toBe(3)
})
it('throws for a negative MAX_FANOUT_LANES (fail-fast, like MAX_SESSIONS)', () => {
expect(() => loadConfig({ MAX_FANOUT_LANES: '-1' })).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)

128
test/fanout.test.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* 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)
})
})

View File

@@ -0,0 +1,119 @@
/**
* test/http/session-groups.test.ts (W5) — pure grouping helpers, no I/O.
*
* deriveRepoRoot strips a `<name>-worktrees/<lane>` parent to `<...>/<name>`;
* groupSessionsByRepo clusters a repo + its worktree lanes into one SessionGroup,
* skips cwd:null, and preserves manager.list() (newest-first) order.
*/
import { describe, it, expect } from 'vitest'
import { deriveRepoRoot, groupSessionsByRepo } from '../../src/http/session-groups.js'
import type { LiveSessionInfo } from '../../src/types.js'
function live(id: string, cwd: string | null): LiveSessionInfo {
return {
id,
createdAt: 0,
clientCount: 0,
status: 'unknown',
exited: false,
cwd,
cols: 80,
rows: 24,
}
}
describe('deriveRepoRoot', () => {
it('strips a *-worktrees parent to the repo root', () => {
expect(deriveRepoRoot('/a/proj-worktrees/lane-1')).toBe('/a/proj')
})
it('returns the cwd unchanged when the parent is not a *-worktrees dir', () => {
expect(deriveRepoRoot('/a/proj')).toBe('/a/proj')
})
it('returns null for a null cwd', () => {
expect(deriveRepoRoot(null)).toBe(null)
})
it('returns null for an empty cwd', () => {
expect(deriveRepoRoot('')).toBe(null)
})
it('handles a nested repo path with a *-worktrees parent', () => {
expect(deriveRepoRoot('/home/me/code/web-terminal-worktrees/feat-x-lane-2')).toBe(
'/home/me/code/web-terminal',
)
})
it('does not treat a bare "-worktrees" segment (no repo name) specially', () => {
// parentName === '-worktrees' has no repo name to keep → falls through to cwd.
expect(deriveRepoRoot('/a/-worktrees/x')).toBe('/a/-worktrees/x')
})
})
describe('groupSessionsByRepo', () => {
it('returns [] for no sessions', () => {
expect(groupSessionsByRepo([])).toEqual([])
})
it('clusters a repo and its worktree lanes into ONE group', () => {
const sessions = [
live('s1', '/a/proj-worktrees/lane-1'),
live('s2', '/a/proj-worktrees/lane-2'),
]
const groups = groupSessionsByRepo(sessions)
expect(groups).toHaveLength(1)
expect(groups[0]!.repoRoot).toBe('/a/proj')
expect(groups[0]!.label).toBe('proj')
expect(groups[0]!.sessions.map((s) => s.id)).toEqual(['s1', 's2'])
})
it('groups the main repo cwd together with its worktree lanes', () => {
const sessions = [
live('main', '/a/proj'),
live('lane', '/a/proj-worktrees/feat-lane-1'),
]
const groups = groupSessionsByRepo(sessions)
expect(groups).toHaveLength(1)
expect(groups[0]!.repoRoot).toBe('/a/proj')
expect(groups[0]!.sessions.map((s) => s.id)).toEqual(['main', 'lane'])
})
it('puts an unrelated cwd in its own group', () => {
const groups = groupSessionsByRepo([
live('a', '/a/proj-worktrees/lane-1'),
live('b', '/b/other'),
])
expect(groups).toHaveLength(2)
expect(groups[0]!.repoRoot).toBe('/a/proj')
expect(groups[1]!.repoRoot).toBe('/b/other')
expect(groups[1]!.label).toBe('other')
})
it('skips sessions with a null cwd (not attributable to a repo)', () => {
const groups = groupSessionsByRepo([
live('nocwd', null),
live('real', '/a/proj'),
])
expect(groups).toHaveLength(1)
expect(groups[0]!.repoRoot).toBe('/a/proj')
expect(groups[0]!.sessions.map((s) => s.id)).toEqual(['real'])
})
it('preserves the input (newest-first) order across and within groups', () => {
const groups = groupSessionsByRepo([
live('newest', '/a/proj-worktrees/lane-2'),
live('older', '/a/proj-worktrees/lane-1'),
])
expect(groups[0]!.sessions.map((s) => s.id)).toEqual(['newest', 'older'])
})
it('does not mutate the input array', () => {
const input = [live('a', '/a/proj')]
const copy = [...input]
groupSessionsByRepo(input)
expect(input).toEqual(copy)
})
})

View File

@@ -0,0 +1,172 @@
/**
* Integration test for GET /live-sessions/grouped (W5 fan-out board) and the
* /config/ui maxFanoutLanes field.
*
* Starts a real HTTP server. The grouped route is read-only (no Origin guard) and
* derives groups purely from cwd — the empty-manager path needs no PTY. The
* real-PTY case (two sessions under one *-worktrees dir clustering into one group)
* is itPty-gated: posix_spawn is blocked in the sandbox, so it auto-skips there
* and runs in CI.
*/
import net from 'node:net'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
import WebSocket from 'ws'
import * as nodePty from 'node-pty'
import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
import type { SessionGroup, UiConfig } from '../../src/types.js'
const PTY_AVAILABLE = (() => {
try {
const p = nodePty.spawn(process.env['SHELL'] ?? '/bin/sh', [], { cols: 80, rows: 24 })
p.kill()
return true
} catch {
return false
}
})()
const itPty = PTY_AVAILABLE ? it : it.skip
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer()
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
if (addr === null || typeof addr === 'string') {
srv.close()
reject(new Error('unexpected address type'))
return
}
const port = addr.port
srv.close(() => resolve(port))
})
srv.on('error', reject)
})
}
function waitForOpen(ws: WebSocket, timeoutMs = 3_000): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('WS open timeout')), timeoutMs)
ws.once('open', () => {
clearTimeout(timer)
resolve()
})
ws.once('error', (err) => {
clearTimeout(timer)
reject(err)
})
})
}
/** Resolve once an 'attached' frame arrives (output frames may interleave). */
function waitForAttached(ws: WebSocket, timeoutMs = 4_000): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('attached timeout')), timeoutMs)
const onMsg = (raw: WebSocket.RawData): void => {
let msg: { type?: string }
try {
msg = JSON.parse(raw.toString('utf8'))
} catch {
return
}
if (msg.type === 'attached') {
clearTimeout(timer)
ws.off('message', onMsg)
resolve()
}
}
ws.on('message', onMsg)
})
}
describe('GET /live-sessions/grouped + /config/ui maxFanoutLanes — integration', () => {
let port: number
let serverHandle: { close(): Promise<void> }
const origin = (): string => `http://127.0.0.1:${port}`
beforeAll(async () => {
port = await getFreePort()
const cfg = loadConfig({
PORT: String(port),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
USE_TMUX: '0',
IDLE_TTL: '86400',
MAX_FANOUT_LANES: '5',
})
serverHandle = startServer(cfg)
await new Promise<void>((r) => setTimeout(r, 100))
})
afterAll(async () => {
await serverHandle.close()
})
it('returns 200 [] on an empty manager, WITHOUT requiring an Origin header', async () => {
const res = await fetch(`${origin()}/live-sessions/grouped`)
expect(res.status).toBe(200) // read-only: not 403 even with no Origin
const body = (await res.json()) as SessionGroup[]
expect(Array.isArray(body)).toBe(true)
expect(body).toHaveLength(0)
})
it('does not shadow "grouped" as a session :id (returns an array, not an error)', async () => {
const res = await fetch(`${origin()}/live-sessions/grouped`)
const body = await res.json()
expect(Array.isArray(body)).toBe(true)
})
it('exposes maxFanoutLanes on /config/ui', async () => {
const res = await fetch(`${origin()}/config/ui`)
expect(res.status).toBe(200)
const body = (await res.json()) as UiConfig
expect(body.maxFanoutLanes).toBe(5)
})
// ── real-PTY: two sessions under one *-worktrees dir cluster into one group ──
let tmpRepo: string | null = null
const sockets: WebSocket[] = []
afterEach(() => {
for (const ws of sockets.splice(0)) {
try {
ws.close()
} catch {
// ignore
}
}
})
itPty('clusters two worktree-lane sessions into a single group', async () => {
tmpRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'fanout-'))
const laneDir = path.join(tmpRepo, 'proj-worktrees')
const lane1 = path.join(laneDir, 'lane-1')
const lane2 = path.join(laneDir, 'lane-2')
await fs.mkdir(lane1, { recursive: true })
await fs.mkdir(lane2, { recursive: true })
for (const cwd of [lane1, lane2]) {
const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { origin: origin() })
sockets.push(ws)
await waitForOpen(ws)
ws.send(JSON.stringify({ type: 'attach', sessionId: null, cwd }))
await waitForAttached(ws)
}
// Give the sessions a beat to register their cwd.
await new Promise<void>((r) => setTimeout(r, 100))
const res = await fetch(`${origin()}/live-sessions/grouped`)
const groups = (await res.json()) as SessionGroup[]
const proj = groups.find((g) => g.repoRoot === path.join(tmpRepo!, 'proj'))
expect(proj).toBeDefined()
expect(proj!.label).toBe('proj')
expect(proj!.sessions.length).toBe(2)
})
})

View File

@@ -206,13 +206,13 @@ describe('GET /config/ui', () => {
const { port } = await spawnServer()
const res = await fetch(`http://127.0.0.1:${port}/config/ui`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ allowAutoMode: false })
expect(await res.json()).toEqual({ allowAutoMode: false, maxFanoutLanes: 6 })
})
it('reports allowAutoMode:true when ALLOW_AUTO_MODE=1', async () => {
const { port } = await spawnServer({ ALLOW_AUTO_MODE: '1' })
const res = await fetch(`http://127.0.0.1:${port}/config/ui`)
expect(await res.json()).toEqual({ allowAutoMode: true })
expect(await res.json()).toEqual({ allowAutoMode: true, maxFanoutLanes: 6 })
})
it('omits costBudgetUsd when the budget is unset (W3 b)', async () => {
@@ -225,6 +225,6 @@ describe('GET /config/ui', () => {
it('reports costBudgetUsd when COST_BUDGET_USD is set (W3 b)', async () => {
const { port } = await spawnServer({ COST_BUDGET_USD: '7.5' })
const res = await fetch(`http://127.0.0.1:${port}/config/ui`)
expect(await res.json()).toEqual({ allowAutoMode: false, costBudgetUsd: 7.5 })
expect(await res.json()).toEqual({ allowAutoMode: false, costBudgetUsd: 7.5, maxFanoutLanes: 6 })
})
})

View File

@@ -86,6 +86,7 @@ const CFG: Config = {
worktreeEnabled: true,
worktreeRoot: undefined,
worktreeTimeoutMs: 10_000,
maxFanoutLanes: 6,
defaultPermissionMode: 'default',
allowAutoMode: false,
// W2 inject queue

View File

@@ -114,6 +114,7 @@ const BASE_CFG: Config = {
worktreeEnabled: true,
worktreeRoot: undefined,
worktreeTimeoutMs: 10_000,
maxFanoutLanes: 6,
defaultPermissionMode: 'default',
allowAutoMode: false,
}

View File

@@ -133,6 +133,7 @@ const BASE_CFG: Config = {
worktreeEnabled: true,
worktreeRoot: undefined,
worktreeTimeoutMs: 10_000,
maxFanoutLanes: 6,
defaultPermissionMode: 'default',
allowAutoMode: false,
}

View File

@@ -55,6 +55,7 @@ const BASE_CFG: Config = {
worktreeEnabled: true,
worktreeRoot: undefined,
worktreeTimeoutMs: 10_000,
maxFanoutLanes: 6,
defaultPermissionMode: 'default',
allowAutoMode: false,
}

View File

@@ -156,7 +156,26 @@ const mountProjects = vi.fn(() => ({
},
refresh: vi.fn(),
}))
vi.mock('../public/projects.js', () => ({ mountProjects }))
// W5: TabApp imports these worktree helpers from projects.js for launchFanout /
// keepFanoutWinner. Mocked so we drive them without real fetch and assert routing.
const createWorktreeReqMock = vi.fn(
async (_repoPath: string, branch: string) => ({ ok: true, path: `/wt/${branch}` }) as {
ok: boolean
path?: string
status?: number
error?: string
},
)
const removeWorktreeReqMock = vi.fn(
async () => ({ ok: true }) as { ok: boolean; status?: number; error?: string },
)
const validateBranchNameClientMock = vi.fn((_b: string): string | null => null)
vi.mock('../public/projects.js', () => ({
mountProjects,
createWorktreeReq: (...a: unknown[]) => createWorktreeReqMock(...(a as [string, string])),
removeWorktreeReq: (...a: unknown[]) => removeWorktreeReqMock(...(a as [])),
validateBranchNameClient: (...a: unknown[]) => validateBranchNameClientMock(...(a as [string])),
}))
// settings.js is light but imports nothing heavy; let it load for real.
@@ -200,6 +219,16 @@ beforeEach(() => {
quickReply.onSend = undefined
quickReply.onQueue = undefined
enqueueFollowupMock.mockClear()
// W5 fan-out mocks: reset to permissive defaults each test.
createWorktreeReqMock.mockReset()
createWorktreeReqMock.mockImplementation(async (_repoPath: string, branch: string) => ({
ok: true,
path: `/wt/${branch}`,
}))
removeWorktreeReqMock.mockReset()
removeWorktreeReqMock.mockResolvedValue({ ok: true })
validateBranchNameClientMock.mockReset()
validateBranchNameClientMock.mockReturnValue(null)
voice.onTranscript = undefined
voice.onInterim = undefined
// Default: server forbids 'auto' (loadUiConfig resolves to allowAutoMode=false).
@@ -208,6 +237,7 @@ beforeEach(() => {
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks() // restore window.confirm etc. spied via vi.spyOn (W5 tests)
})
describe('TabApp — v0.5 launcher chooser', () => {
@@ -1718,3 +1748,169 @@ describe('TabApp — W2 inject queue', () => {
expect(enqueueFollowupMock).not.toHaveBeenCalled()
})
})
// ── W5: fan-out board (launchFanout + keepFanoutWinner) ──────────────────────
describe('TabApp — W5 fan-out board', () => {
const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0))
/** Fan out N lanes and stamp server ids on the resulting sessions. */
async function launched(
app: InstanceType<typeof TabApp>,
lanes: number,
branchBase = 'feat',
): Promise<void> {
await app.launchFanout('/repo', 'repo', { prompt: 'add feature', lanes, branchBase, mode: 'default' })
FakeTerminalSession.instances.forEach((inst, i) => (inst.id = `sess-${i}`))
}
const keepBtnOf = (i: number): HTMLButtonElement =>
FakeTerminalSession.instances[i]!.el.closest('.term-cell')!.querySelector('.cell-keep')!
it('launchFanout creates N worktrees SEQUENTIALLY, one tab per lane, on a grid-4 board', async () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
await app.launchFanout('/repo', 'repo', {
prompt: 'add feature',
lanes: 3,
branchBase: 'feat',
mode: 'default',
})
expect(app.snapshot()).toHaveLength(3)
expect(FakeTerminalSession.instances).toHaveLength(3)
// Sequential, in lane order.
expect(createWorktreeReqMock).toHaveBeenCalledTimes(3)
expect(createWorktreeReqMock.mock.calls.map((c) => c[1])).toEqual([
'feat-lane-1',
'feat-lane-2',
'feat-lane-3',
])
// Same shell-quoted launch command injected into EVERY lane.
for (const inst of FakeTerminalSession.instances) {
expect(inst.initialInput).toBe("claude 'add feature'\r")
}
// Each lane spawns in its own worktree cwd.
const cwds = FakeTerminalSession.instances.map(
(i) => (i.cbs as Record<string, unknown>)['cwd'],
)
expect(cwds).toEqual(['/wt/feat-lane-1', '/wt/feat-lane-2', '/wt/feat-lane-3'])
expect(app.getGridLayout()).toBe('grid-4')
})
it('launchFanout uses grid-6 for more than four lanes', async () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
await launched(app, 5)
expect(app.snapshot()).toHaveLength(5)
expect(app.getGridLayout()).toBe('grid-6')
})
it('launchFanout refuses an invalid branch base (no worktrees, banner shown)', async () => {
validateBranchNameClientMock.mockReturnValue('bad branch')
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
await app.launchFanout('/repo', 'repo', { prompt: 'x', lanes: 3, branchBase: '-bad', mode: 'default' })
expect(createWorktreeReqMock).not.toHaveBeenCalled()
expect(app.snapshot()).toHaveLength(0)
expect(document.getElementById('fanout-banner')!.style.display).toBe('block')
})
it('launchFanout tolerates a partial failure — records it in the banner, never throws', async () => {
createWorktreeReqMock.mockImplementation(async (_repo: string, branch: string) =>
branch.endsWith('-2')
? { ok: false, status: 500, error: 'boom' }
: { ok: true, path: `/wt/${branch}` },
)
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
await expect(
app.launchFanout('/repo', 'repo', { prompt: 'x', lanes: 3, branchBase: 'feat', mode: 'default' }),
).resolves.toBeUndefined()
expect(app.snapshot()).toHaveLength(2) // lanes 1 and 3 created
const banner = document.getElementById('fanout-banner')!
expect(banner.style.display).toBe('block')
expect(banner.textContent).toContain('Started 2 of 3')
})
it('every created lane cell shows a 🏆 Keep button (fan-out lanes only)', async () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
await launched(app, 3)
for (let i = 0; i < 3; i++) expect(keepBtnOf(i).style.display).not.toBe('none')
})
it('a plain (non-fan-out) tab has no visible Keep button', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const cell = FakeTerminalSession.instances[0]!.el.closest('.term-cell')!
expect((cell.querySelector('.cell-keep') as HTMLElement).style.display).toBe('none')
})
it('keepFanoutWinner keeps the winner, closes losers, removes their worktrees, layout → single', async () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
await launched(app, 3)
vi.spyOn(window, 'confirm').mockReturnValue(true)
keepBtnOf(0).click() // keep sess-0 (feat-lane-1)
await flush()
expect(app.snapshot()).toHaveLength(1) // only the winner remains
expect(app.activeSessionId()).toBe('sess-0')
// One DELETE per loser, force=false, with the loser's worktree path.
expect(removeWorktreeReqMock).toHaveBeenCalledTimes(2)
const paths = removeWorktreeReqMock.mock.calls.map((c) => (c as unknown[])[1]).sort()
expect(paths).toEqual(['/wt/feat-lane-2', '/wt/feat-lane-3'])
expect(removeWorktreeReqMock.mock.calls.every((c) => (c as unknown[])[2] === false)).toBe(true)
expect(app.getGridLayout()).toBe('single')
})
it('keepFanoutWinner force-removes a dirty (409) loser without a second confirm', async () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
await launched(app, 2)
removeWorktreeReqMock
.mockResolvedValueOnce({ ok: false, status: 409, error: 'dirty' })
.mockResolvedValueOnce({ ok: true })
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
keepBtnOf(0).click() // keep sess-0; one loser (sess-1)
await flush()
expect(removeWorktreeReqMock).toHaveBeenCalledTimes(2)
expect((removeWorktreeReqMock.mock.calls[0] as unknown[])[2]).toBe(false) // first try
expect((removeWorktreeReqMock.mock.calls[1] as unknown[])[2]).toBe(true) // forced retry
expect(confirmSpy).toHaveBeenCalledTimes(1) // only the batch confirm
})
it('keepFanoutWinner cancelled → no tabs closed, no worktree removed', async () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
await launched(app, 3)
vi.spyOn(window, 'confirm').mockReturnValue(false)
keepBtnOf(0).click()
await flush()
expect(app.snapshot()).toHaveLength(3) // nothing closed
expect(removeWorktreeReqMock).not.toHaveBeenCalled()
})
it('keepFanoutWinner refuses when the winner has not attached (null id) — deletes nothing', async () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
// Fan out but do NOT stamp ids (sessions still null).
await app.launchFanout('/repo', 'repo', { prompt: 'x', lanes: 3, branchBase: 'feat', mode: 'default' })
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
keepBtnOf(0).click() // entry.session.id is null → winnerSessionId ''
await flush()
expect(confirmSpy).not.toHaveBeenCalled() // refused before the confirm
expect(removeWorktreeReqMock).not.toHaveBeenCalled()
expect(app.snapshot()).toHaveLength(3)
expect(document.getElementById('fanout-banner')!.style.display).toBe('block')
})
})

View File

@@ -44,6 +44,7 @@ vi.mock('../public/gh-chip.js', () => ({ mountPrChip: mockMountPrChip }))
const {
validateBranchNameClient,
renderNewWorktreeForm,
renderFanoutForm,
renderProjectDetail,
makeWorktreeRow,
confirmAndRemoveWorktree,
@@ -53,7 +54,7 @@ const {
/* ── Helpers ───────────────────────────────────────────────────────────────── */
function makeHooks() {
return { onOpenProject: vi.fn(), onEnterSession: vi.fn() }
return { onOpenProject: vi.fn(), onEnterSession: vi.fn(), onFanout: vi.fn() }
}
function makeCbs() {
@@ -286,6 +287,7 @@ describe('renderNewWorktreeForm', () => {
// Flush async microtasks
await Promise.resolve()
await Promise.resolve()
await new Promise((r) => setTimeout(r, 0))
expect(hooks.onOpenProject).toHaveBeenCalledWith(
'/home/user/my-repo-worktrees/feat',
@@ -309,6 +311,7 @@ describe('renderNewWorktreeForm', () => {
await Promise.resolve()
await Promise.resolve()
await new Promise((r) => setTimeout(r, 0))
expect(hooks.onOpenProject).toHaveBeenCalledWith('/repo', 'feat', 'claude\r')
})
@@ -327,6 +330,7 @@ describe('renderNewWorktreeForm', () => {
await Promise.resolve()
await Promise.resolve()
await new Promise((r) => setTimeout(r, 0))
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
expect(errorEl.style.display).not.toBe('none')
@@ -345,6 +349,7 @@ describe('renderNewWorktreeForm', () => {
await Promise.resolve()
await Promise.resolve()
await new Promise((r) => setTimeout(r, 0))
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
expect(errorEl.textContent).toBeTruthy()
@@ -365,6 +370,7 @@ describe('renderNewWorktreeForm', () => {
await Promise.resolve()
await Promise.resolve()
await new Promise((r) => setTimeout(r, 0))
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
// No <script> element in DOM — textContent was used
@@ -492,6 +498,112 @@ describe('renderProjectDetail — B3 New Worktree Form', () => {
})
})
/* ── renderFanoutForm (W5 fan-out board) ──────────────────────────────────── */
describe('renderFanoutForm', () => {
it('renders a prompt textarea, lane stepper, branch input, mode select, submit', () => {
const form = renderFanoutForm(makeDetail(), makeHooks())
expect(form.querySelector('textarea.proj-fanout-prompt')).not.toBeNull()
const lanes = form.querySelector('input.proj-fanout-lanes') as HTMLInputElement
expect(lanes).not.toBeNull()
expect(lanes.min).toBe('2')
expect(lanes.max).toBe('6') // default maxFanoutLanes
expect(form.querySelector('input.proj-fanout-branch')).not.toBeNull()
expect(form.querySelector('select.proj-fanout-mode')).not.toBeNull()
expect(form.querySelector('button.proj-fanout-submit')).not.toBeNull()
})
it('honors a server-controlled max lane count', () => {
const form = renderFanoutForm(makeDetail(), makeHooks(), 3)
expect((form.querySelector('input.proj-fanout-lanes') as HTMLInputElement).max).toBe('3')
})
it('disables submit until a prompt is entered', () => {
const form = renderFanoutForm(makeDetail(), makeHooks())
const submit = form.querySelector('button.proj-fanout-submit') as HTMLButtonElement
expect(submit.disabled).toBe(true)
const prompt = form.querySelector('textarea.proj-fanout-prompt') as HTMLTextAreaElement
prompt.value = 'add dark mode'
prompt.dispatchEvent(new Event('input', { bubbles: true }))
expect(submit.disabled).toBe(false)
})
it('auto-fills the branch base from the prompt slug until the user edits it', () => {
const form = renderFanoutForm(makeDetail(), makeHooks())
const prompt = form.querySelector('textarea.proj-fanout-prompt') as HTMLTextAreaElement
const branch = form.querySelector('input.proj-fanout-branch') as HTMLInputElement
prompt.value = 'Add Dark Mode!'
prompt.dispatchEvent(new Event('input', { bubbles: true }))
expect(branch.value).toBe('add-dark-mode')
branch.value = 'custom-base'
branch.dispatchEvent(new Event('input', { bubbles: true }))
prompt.value = 'Add Dark Mode changed'
prompt.dispatchEvent(new Event('input', { bubbles: true }))
expect(branch.value).toBe('custom-base') // not clobbered after manual edit
})
it('submitting calls onFanout with the repo path, name, and options', () => {
const hooks = makeHooks()
const detail = makeDetail({ path: '/home/user/my-repo', name: 'my-repo' })
const form = renderFanoutForm(detail, hooks)
const prompt = form.querySelector('textarea.proj-fanout-prompt') as HTMLTextAreaElement
const lanes = form.querySelector('input.proj-fanout-lanes') as HTMLInputElement
const mode = form.querySelector('select.proj-fanout-mode') as HTMLSelectElement
prompt.value = 'add dark mode'
prompt.dispatchEvent(new Event('input', { bubbles: true }))
lanes.value = '4'
mode.value = 'plan'
;(form.querySelector('button.proj-fanout-submit') as HTMLButtonElement).click()
expect(hooks.onFanout).toHaveBeenCalledWith('/home/user/my-repo', 'my-repo', {
prompt: 'add dark mode',
lanes: 4,
branchBase: 'add-dark-mode',
mode: 'plan',
})
})
it('shows an inline error (textContent) and does not call onFanout with no prompt', () => {
const hooks = makeHooks()
const form = renderFanoutForm(makeDetail(), hooks)
const submit = form.querySelector('button.proj-fanout-submit') as HTMLButtonElement
submit.disabled = false // exercise the submit-handler guard directly
submit.click()
const err = form.querySelector('.proj-fanout-error') as HTMLElement
expect(err.textContent).not.toBe('')
expect(err.style.display).not.toBe('none')
expect(hooks.onFanout).not.toHaveBeenCalled()
})
it('rejects an invalid branch base via inline error, not onFanout', () => {
const hooks = makeHooks()
const form = renderFanoutForm(makeDetail(), hooks)
const prompt = form.querySelector('textarea.proj-fanout-prompt') as HTMLTextAreaElement
const branch = form.querySelector('input.proj-fanout-branch') as HTMLInputElement
prompt.value = 'ok prompt'
prompt.dispatchEvent(new Event('input', { bubbles: true }))
branch.value = '-bad' // leading dash → invalid git branch
branch.dispatchEvent(new Event('input', { bubbles: true }))
;(form.querySelector('button.proj-fanout-submit') as HTMLButtonElement).click()
expect(hooks.onFanout).not.toHaveBeenCalled()
expect((form.querySelector('.proj-fanout-error') as HTMLElement).textContent).not.toBe('')
})
it('renders the Fan out section inside a git project detail', () => {
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
expect(root.querySelector('.proj-fanout-form')).not.toBeNull()
expect(root.textContent).toContain('Fan out')
})
it('omits the Fan out form for a non-git directory', () => {
const root = renderProjectDetail(
makeDetail({ isGit: false, branch: undefined }),
makeHooks(),
makeCbs(),
)
expect(root.querySelector('.proj-fanout-form')).toBeNull()
})
})
/* ── renderProjectDetail — A4: Activity section ───────────────────────────── */
describe('renderProjectDetail — A4 Activity section', () => {