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:
74
public/fanout.ts
Normal file
74
public/fanout.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* public/fanout.ts (W5 fan-out board) — PURE launch-command + branch-slug helpers.
|
||||||
|
*
|
||||||
|
* No DOM, no fetch, no side effects — every export is unit-testable in isolation.
|
||||||
|
* The load-bearing invariant is the prompt shell-quoting (shellSingleQuote): the
|
||||||
|
* prompt is typed as raw bytes into a lane's shell BEFORE `claude` parses it, so
|
||||||
|
* it must be wrapped as a single literal argv element — no `$(...)`, backtick,
|
||||||
|
* `;`, `&&`, or redirection may execute. NEVER String-concat the prompt into the
|
||||||
|
* command unquoted (see the plan's Security section).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PermissionMode } from '../src/types.js'
|
||||||
|
|
||||||
|
/** Max prompt length typed into a lane (bounded DoS + keeps the command sane). */
|
||||||
|
export const FANOUT_PROMPT_MAX = 4000
|
||||||
|
|
||||||
|
/** Default/max fan-out lanes (mirrors the server MAX_FANOUT_LANES default and the
|
||||||
|
* grid-6 board capacity). The FE stepper max and the launch clamp both use it. */
|
||||||
|
export const FANOUT_MAX_LANES = 6
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POSIX single-quote a string so the shell passes it verbatim as ONE argv element.
|
||||||
|
* Wrap in single quotes and replace every embedded `'` with the classic
|
||||||
|
* `'\''` sequence (close-quote, escaped-quote, re-open-quote). Pure.
|
||||||
|
*/
|
||||||
|
export function shellSingleQuote(s: string): string {
|
||||||
|
return `'${s.replace(/'/g, `'\\''`)}'`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a prompt for typing into a PTY: collapse every line break (`\r`, `\n`,
|
||||||
|
* `\r\n`) to a single space — a bare newline keystroke would submit a partial line
|
||||||
|
* before `claude` starts — then trim and cap at FANOUT_PROMPT_MAX. Pure.
|
||||||
|
*/
|
||||||
|
export function sanitizePrompt(s: string): string {
|
||||||
|
return s.replace(/[\r\n]+/g, ' ').trim().slice(0, FANOUT_PROMPT_MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `claude` launch command for one lane. Single-quotes the sanitized
|
||||||
|
* prompt (injection-safe), prefixes `--permission-mode <m>` for non-default modes,
|
||||||
|
* and ends with `\r` so the shell auto-executes it. 'auto' is downgraded to
|
||||||
|
* 'default' when the server forbids it (SEC-M5 parity with resolveMode). Pure.
|
||||||
|
*/
|
||||||
|
export function buildFanoutCmd(
|
||||||
|
prompt: string,
|
||||||
|
mode: PermissionMode,
|
||||||
|
allowAutoMode: boolean,
|
||||||
|
): string {
|
||||||
|
const effective: PermissionMode = mode === 'auto' && !allowAutoMode ? 'default' : mode
|
||||||
|
const flag = effective === 'default' ? '' : `--permission-mode ${effective} `
|
||||||
|
return `claude ${flag}${shellSingleQuote(sanitizePrompt(prompt))}\r`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lane branch name: `${base}-lane-${i}` (i is 1-based). Pure. */
|
||||||
|
export function laneBranch(base: string, i: number): string {
|
||||||
|
return `${base}-lane-${i}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a safe git-branch-base slug from a free-text prompt: lowercase, non-
|
||||||
|
* alphanumeric runs → single `-`, strip leading/trailing dashes, cap at 40 chars.
|
||||||
|
* Falls back to 'fanout' when nothing survives. The result is a valid branch base
|
||||||
|
* (passes validateBranchNameClient). Pure.
|
||||||
|
*/
|
||||||
|
export function slugify(prompt: string): string {
|
||||||
|
const slug = prompt
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 40)
|
||||||
|
.replace(/-+$/g, '')
|
||||||
|
return slug || 'fanout'
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -456,6 +456,20 @@ body {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--surface-3);
|
background: var(--surface-3);
|
||||||
}
|
}
|
||||||
|
.cell-keep {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.cell-keep:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-3);
|
||||||
|
}
|
||||||
.cell-monitor-btn {
|
.cell-monitor-btn {
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -1944,6 +1958,60 @@ body {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
/* W5 fan-out form — sibling of the New Worktree form. */
|
||||||
|
.proj-fanout-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.proj-fanout-prompt {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 44px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.proj-fanout-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.proj-fanout-lanes {
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
.proj-fanout-branch {
|
||||||
|
flex: 1 1 160px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
.proj-fanout-error {
|
||||||
|
color: var(--danger, #e5534b);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.proj-fanout-submit {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.proj-fanout-submit:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
/* W5 fan-out status/error banner — fixed, dismissible, textContent only. */
|
||||||
|
.fanout-banner {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 16px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-width: min(680px, 92vw);
|
||||||
|
z-index: 60;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-3, #2a2c33);
|
||||||
|
color: var(--text, #e7e8ec);
|
||||||
|
border: 1px solid var(--border, #3a3d46);
|
||||||
|
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
/* Section header with an inline action (CLAUDE.md generate/update). */
|
/* Section header with an inline action (CLAUDE.md generate/update). */
|
||||||
.proj-section-row {
|
.proj-section-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
231
public/tabs.ts
231
public/tabs.ts
@@ -25,8 +25,21 @@ import { TerminalSession } from './terminal-session.js'
|
|||||||
import { ICON_TIMELINE } from './icons.js'
|
import { ICON_TIMELINE } from './icons.js'
|
||||||
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
|
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
|
||||||
import { mountLauncher, type Launcher } from './launcher.js'
|
import { mountLauncher, type Launcher } from './launcher.js'
|
||||||
import { mountProjects, type ProjectsPanel } from './projects.js'
|
import {
|
||||||
import type { ApprovalPreview, ClaudeStatus, PermissionMode, UiConfig } from '../src/types.js'
|
mountProjects,
|
||||||
|
createWorktreeReq,
|
||||||
|
removeWorktreeReq,
|
||||||
|
validateBranchNameClient,
|
||||||
|
type ProjectsPanel,
|
||||||
|
} from './projects.js'
|
||||||
|
import { buildFanoutCmd, laneBranch, FANOUT_MAX_LANES } from './fanout.js'
|
||||||
|
import type {
|
||||||
|
ApprovalPreview,
|
||||||
|
ClaudeStatus,
|
||||||
|
FanoutLaunchOpts,
|
||||||
|
PermissionMode,
|
||||||
|
UiConfig,
|
||||||
|
} from '../src/types.js'
|
||||||
import { renderDiffFile } from './diff.js'
|
import { renderDiffFile } from './diff.js'
|
||||||
import { renderTelemetryGauge } from './preview-grid.js'
|
import { renderTelemetryGauge } from './preview-grid.js'
|
||||||
import { mountPushToggle } from './push.js'
|
import { mountPushToggle } from './push.js'
|
||||||
@@ -72,12 +85,35 @@ const ALL_PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits
|
|||||||
|
|
||||||
type HomeView = 'sessions' | 'projects'
|
type HomeView = 'sessions' | 'projects'
|
||||||
|
|
||||||
|
/** W5: one lane of a fan-out group — its worktree + branch (the session that runs
|
||||||
|
* in it is tracked via the tagged TabEntry, matched by TabEntry.fanoutGroupId). */
|
||||||
|
interface FanoutLane {
|
||||||
|
branch: string
|
||||||
|
worktreePath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** W5: an in-memory fan-out group — one prompt fanned across N lanes of one repo.
|
||||||
|
* Lives only for the session (v1): reload does not reconstruct the board (matches
|
||||||
|
* v0.5 "no auto-restore"); keepFanoutWinner operates on the currently-open lanes. */
|
||||||
|
interface FanoutGroup {
|
||||||
|
id: string
|
||||||
|
repoPath: string
|
||||||
|
repoName: string
|
||||||
|
prompt: string
|
||||||
|
lanes: FanoutLane[]
|
||||||
|
}
|
||||||
|
|
||||||
interface TabEntry {
|
interface TabEntry {
|
||||||
session: TerminalSession
|
session: TerminalSession
|
||||||
customTitle: string | null // user-set; null = use auto/fallback (persisted)
|
customTitle: string | null // user-set; null = use auto/fallback (persisted)
|
||||||
autoTitle: string | null // current folder from the terminal title
|
autoTitle: string | null // current folder from the terminal title
|
||||||
hasActivity: boolean // inactive tab got output since last viewed
|
hasActivity: boolean // inactive tab got output since last viewed
|
||||||
el: HTMLDivElement | null // the .tab element (updated in place)
|
el: HTMLDivElement | null // the .tab element (updated in place)
|
||||||
|
// W5: when set, this tab is a lane of a fan-out group (id) with its own worktree
|
||||||
|
// + branch; the cell gets a 🏆 Keep button that resolves the race for this lane.
|
||||||
|
fanoutGroupId?: string
|
||||||
|
worktreePath?: string
|
||||||
|
branch?: string
|
||||||
// Split-grid: the .term-cell wrapper around session.el (header + terminal +
|
// Split-grid: the .term-cell wrapper around session.el (header + terminal +
|
||||||
// optional inline-approve footer). One per tab; the grid lays these out.
|
// optional inline-approve footer). One per tab; the grid lays these out.
|
||||||
cell: HTMLDivElement | null
|
cell: HTMLDivElement | null
|
||||||
@@ -144,6 +180,12 @@ export class TabApp {
|
|||||||
// W3(b): the server COST_BUDGET_USD (from /config/ui); 0 = disabled. The per-tab
|
// W3(b): the server COST_BUDGET_USD (from /config/ui); 0 = disabled. The per-tab
|
||||||
// gauge warn-styles the cost chip once costUsd >= this budget.
|
// gauge warn-styles the cost chip once costUsd >= this budget.
|
||||||
private costBudgetUsd = 0
|
private costBudgetUsd = 0
|
||||||
|
// W5 fan-out board: the server MAX_FANOUT_LANES (from /config/ui) — the hard cap
|
||||||
|
// launchFanout clamps to (alongside grid-6 capacity + the server DoS cap).
|
||||||
|
private maxFanoutLanes = FANOUT_MAX_LANES
|
||||||
|
// W5: in-memory fan-out group model (session-scoped; not persisted in v1).
|
||||||
|
private readonly fanoutGroups = new Map<string, FanoutGroup>()
|
||||||
|
private fanoutBanner: HTMLElement | null = null
|
||||||
private pushHost!: HTMLElement // A1: 🔔 host, mounted once, re-parented per rebuild
|
private pushHost!: HTMLElement // A1: 🔔 host, mounted once, re-parented per rebuild
|
||||||
private timelinePanel!: HTMLElement // A4: shared timeline panel (one mounted at a time)
|
private timelinePanel!: HTMLElement // A4: shared timeline panel (one mounted at a time)
|
||||||
private timelineOpen = false
|
private timelineOpen = false
|
||||||
@@ -180,6 +222,7 @@ export class TabApp {
|
|||||||
this.projects = mountProjects(this.paneHost, {
|
this.projects = mountProjects(this.paneHost, {
|
||||||
onOpenProject: (repoPath, repoName, cmd) => this.openProject(repoPath, repoName, cmd),
|
onOpenProject: (repoPath, repoName, cmd) => this.openProject(repoPath, repoName, cmd),
|
||||||
onEnterSession: (id) => this.openSession(id),
|
onEnterSession: (id) => this.openSession(id),
|
||||||
|
onFanout: (repoPath, repoName, opts) => void this.launchFanout(repoPath, repoName, opts),
|
||||||
})
|
})
|
||||||
|
|
||||||
this.segControl = this.buildSegControl()
|
this.segControl = this.buildSegControl()
|
||||||
@@ -195,6 +238,7 @@ export class TabApp {
|
|||||||
this.setupQuickReply() // A3 chips above the key bar
|
this.setupQuickReply() // A3 chips above the key bar
|
||||||
this.setupTimelinePanel() // A4 activity timeline
|
this.setupTimelinePanel() // A4 activity timeline
|
||||||
this.setupVoiceOverlay() // A2 interim-transcript overlay
|
this.setupVoiceOverlay() // A2 interim-transcript overlay
|
||||||
|
this.setupFanoutBanner() // W5 fan-out status/error banner
|
||||||
void this.loadUiConfig() // B4 allowAutoMode gate (best-effort)
|
void this.loadUiConfig() // B4 allowAutoMode gate (best-effort)
|
||||||
|
|
||||||
// v0.5: do NOT auto-create or auto-restore tabs. Land on the home screen;
|
// v0.5: do NOT auto-create or auto-restore tabs. Land on the home screen;
|
||||||
@@ -264,6 +308,35 @@ export class TabApp {
|
|||||||
this.voiceConfirmOverlay = confirmOverlay
|
this.voiceConfirmOverlay = confirmOverlay
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** W5: a dismissible banner for fan-out status/errors (partial-launch, keep-
|
||||||
|
* winner failures). textContent only (SEC-L3/H6); tap or auto-hide to clear. */
|
||||||
|
private setupFanoutBanner(): void {
|
||||||
|
const banner = document.createElement('div')
|
||||||
|
banner.id = 'fanout-banner'
|
||||||
|
banner.className = 'fanout-banner'
|
||||||
|
banner.style.display = 'none'
|
||||||
|
banner.addEventListener('click', () => {
|
||||||
|
banner.style.display = 'none'
|
||||||
|
})
|
||||||
|
document.body.appendChild(banner)
|
||||||
|
this.fanoutBanner = banner
|
||||||
|
}
|
||||||
|
|
||||||
|
private fanoutBannerTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
/** Show a fan-out banner message (textContent only). Auto-hides after 8s. */
|
||||||
|
private showFanoutBanner(msg: string): void {
|
||||||
|
const banner = this.fanoutBanner
|
||||||
|
if (!banner) return
|
||||||
|
banner.textContent = msg // SEC-L3/H6: textContent, never innerHTML
|
||||||
|
banner.style.display = 'block'
|
||||||
|
if (this.fanoutBannerTimer !== null) clearTimeout(this.fanoutBannerTimer)
|
||||||
|
this.fanoutBannerTimer = setTimeout(() => {
|
||||||
|
banner.style.display = 'none'
|
||||||
|
this.fanoutBannerTimer = null
|
||||||
|
}, 8000)
|
||||||
|
}
|
||||||
|
|
||||||
/** B4: fetch the server UI config (allowAutoMode). Best-effort, never throws. */
|
/** B4: fetch the server UI config (allowAutoMode). Best-effort, never throws. */
|
||||||
private async loadUiConfig(): Promise<void> {
|
private async loadUiConfig(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -283,6 +356,11 @@ export class TabApp {
|
|||||||
if (typeof budget === 'number' && Number.isFinite(budget) && budget > 0) {
|
if (typeof budget === 'number' && Number.isFinite(budget) && budget > 0) {
|
||||||
this.costBudgetUsd = budget
|
this.costBudgetUsd = budget
|
||||||
}
|
}
|
||||||
|
// W5: read the fan-out lane cap (optional; older servers omit → keep default).
|
||||||
|
const lanes = (data as Record<string, unknown>)?.['maxFanoutLanes']
|
||||||
|
if (typeof lanes === 'number' && Number.isFinite(lanes) && lanes >= 2) {
|
||||||
|
this.maxFanoutLanes = Math.floor(lanes)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort — leave allowAutoMode false (auto hidden) on any failure
|
// best-effort — leave allowAutoMode false (auto hidden) on any failure
|
||||||
}
|
}
|
||||||
@@ -798,7 +876,23 @@ export class TabApp {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
this.toggleMonitor(this.tabs.indexOf(entry))
|
this.toggleMonitor(this.tabs.indexOf(entry))
|
||||||
})
|
})
|
||||||
head.append(monBtn, maxBtn)
|
// W5: 🏆 keep-winner — only shown (via renderCell) when this tab is a fan-out
|
||||||
|
// lane. Resolves the race: keep this lane, discard the others' worktrees.
|
||||||
|
const keepBtn = document.createElement('button')
|
||||||
|
keepBtn.type = 'button'
|
||||||
|
keepBtn.className = 'cell-keep'
|
||||||
|
keepBtn.textContent = '🏆'
|
||||||
|
keepBtn.title = 'Keep this lane, discard the others'
|
||||||
|
keepBtn.setAttribute('aria-label', 'Keep this fan-out lane as the winner')
|
||||||
|
keepBtn.style.display = 'none'
|
||||||
|
keepBtn.addEventListener('pointerdown', (e) => e.stopPropagation())
|
||||||
|
keepBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (entry.fanoutGroupId !== undefined) {
|
||||||
|
void this.keepFanoutWinner(entry.fanoutGroupId, entry.session.id ?? '')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
head.append(monBtn, keepBtn, maxBtn)
|
||||||
// v2: drag a tab from the tab bar onto this quadrant to assign it here.
|
// v2: drag a tab from the tab bar onto this quadrant to assign it here.
|
||||||
this.wireCellDropTarget(cell, () => this.tabs.indexOf(entry))
|
this.wireCellDropTarget(cell, () => this.tabs.indexOf(entry))
|
||||||
cell.append(head, session.el)
|
cell.append(head, session.el)
|
||||||
@@ -852,6 +946,134 @@ export class TabApp {
|
|||||||
).length
|
).length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── W5 fan-out board ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fan ONE prompt across N lanes of `repoPath`: create N worktrees SEQUENTIALLY
|
||||||
|
* (git's `worktree add` takes a repo lock — parallel adds race), then open one
|
||||||
|
* Claude session per created lane pre-injected with the same shell-quoted prompt,
|
||||||
|
* and lay them out on the split-grid board. Composition of shipped parts —
|
||||||
|
* createWorktreeReq + addEntry (openProject shape) + setGridLayout. Partial
|
||||||
|
* success is surfaced in the banner (no auto-rollback in v1); never throws.
|
||||||
|
*/
|
||||||
|
async launchFanout(repoPath: string, repoName: string, opts: FanoutLaunchOpts): Promise<void> {
|
||||||
|
const base = opts.branchBase.trim()
|
||||||
|
const branchErr = validateBranchNameClient(base)
|
||||||
|
if (branchErr !== null) {
|
||||||
|
this.showFanoutBanner(`Cannot fan out — ${branchErr}.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Effective N: min(requested, server cap, grid-6 capacity). The remaining
|
||||||
|
// maxSessions − liveCount bound is enforced server-side (assertUnderSessionCap
|
||||||
|
// throws → that lane shows exit(-1); reported as "started K of N" below).
|
||||||
|
const n = Math.min(
|
||||||
|
Math.max(2, Math.floor(opts.lanes)),
|
||||||
|
this.maxFanoutLanes,
|
||||||
|
FANOUT_MAX_LANES,
|
||||||
|
)
|
||||||
|
// Build the launch command ONCE — every lane runs the identical prompt.
|
||||||
|
const cmd = buildFanoutCmd(opts.prompt, this.resolveMode(opts.mode ?? 'default'), this.allowAutoMode)
|
||||||
|
|
||||||
|
const groupId = `fanout-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
const lanes: FanoutLane[] = []
|
||||||
|
const failures: string[] = []
|
||||||
|
for (let i = 1; i <= n; i++) {
|
||||||
|
const branch = laneBranch(base, i)
|
||||||
|
const r = await createWorktreeReq(repoPath, branch) // sequential (repo lock)
|
||||||
|
if (r.ok && typeof r.path === 'string') {
|
||||||
|
lanes.push({ branch, worktreePath: r.path })
|
||||||
|
} else {
|
||||||
|
failures.push(`${branch}: ${r.error ?? 'failed'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lanes.length === 0) {
|
||||||
|
this.showFanoutBanner(`Fan-out failed — no lanes created. ${failures.join('; ')}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const group: FanoutGroup = { id: groupId, repoPath, repoName, prompt: opts.prompt, lanes }
|
||||||
|
this.fanoutGroups.set(groupId, group)
|
||||||
|
|
||||||
|
const created: TabEntry[] = []
|
||||||
|
for (const lane of lanes) {
|
||||||
|
const entry = this.addEntry(null, `${repoName}·${lane.branch}`, lane.worktreePath || undefined, cmd)
|
||||||
|
entry.fanoutGroupId = groupId
|
||||||
|
entry.worktreePath = lane.worktreePath
|
||||||
|
entry.branch = lane.branch
|
||||||
|
created.push(entry)
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
this.rebuild()
|
||||||
|
// Watch board: 2×2 up to 4 lanes, else 2×3 (grid-6 holds up to 6).
|
||||||
|
this.setGridLayout(lanes.length <= 4 ? 'grid-4' : 'grid-6')
|
||||||
|
const first = created[0]
|
||||||
|
if (first) this.activate(this.tabs.indexOf(first))
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
this.showFanoutBanner(`Started ${lanes.length} of ${n} lanes. Failed: ${failures.join('; ')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the winning lane and discard the others: a single batch confirm, then
|
||||||
|
* each losing lane's tab is closed (detach — its PTY is reclaimed by IDLE_TTL)
|
||||||
|
* and its worktree removed (auto-forcing on a 409 dirty tree, inside the one
|
||||||
|
* confirm — no per-lane prompt). The winner's tab + worktree stay so the user
|
||||||
|
* runs the merge inside it (one-click merge is out of v1 scope). Never throws.
|
||||||
|
*/
|
||||||
|
async keepFanoutWinner(groupId: string, winnerSessionId: string): Promise<void> {
|
||||||
|
const group = this.fanoutGroups.get(groupId)
|
||||||
|
if (!group) return
|
||||||
|
const laneTabs = this.tabs.filter((t) => t.fanoutGroupId === groupId)
|
||||||
|
const winner = laneTabs.find((t) => t.session.id === winnerSessionId)
|
||||||
|
if (!winner) {
|
||||||
|
// No lane matches — refuse rather than treat every lane (incl. the intended
|
||||||
|
// winner) as a loser and delete them all. Happens if the winner hasn't
|
||||||
|
// attached yet (session.id still null).
|
||||||
|
this.showFanoutBanner('Cannot keep this lane yet — it has not connected.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const losers = laneTabs.filter((t) => t !== winner)
|
||||||
|
const winnerBranch = winner.branch ?? winnerSessionId
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Keep ${winnerBranch} and discard the other ${losers.length} lane(s) (delete their worktrees)?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const failures: string[] = []
|
||||||
|
for (const loser of losers) {
|
||||||
|
const idx = this.tabs.indexOf(loser)
|
||||||
|
if (idx >= 0) this.closeTab(idx) // detach; PTY reaped by IDLE_TTL
|
||||||
|
const wt = loser.worktreePath
|
||||||
|
if (wt !== undefined && wt !== '') {
|
||||||
|
let r = await removeWorktreeReq(group.repoPath, wt, false)
|
||||||
|
if (!r.ok && r.status === 409) {
|
||||||
|
r = await removeWorktreeReq(group.repoPath, wt, true) // dirty → force (batch-confirmed)
|
||||||
|
}
|
||||||
|
if (!r.ok) failures.push(`${loser.branch ?? wt}: ${r.error ?? 'failed'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fanoutGroups.delete(groupId)
|
||||||
|
winner.fanoutGroupId = undefined // drop the 🏆 button — the race is resolved
|
||||||
|
|
||||||
|
if (losers.length > 0) {
|
||||||
|
this.setGridLayout('single')
|
||||||
|
const wi = this.tabs.indexOf(winner)
|
||||||
|
if (wi >= 0) this.activate(wi)
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
this.rebuild()
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
this.showFanoutBanner(`Kept ${winnerBranch}. Some worktrees could not be removed: ${failures.join('; ')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
activate(i: number): void {
|
activate(i: number): void {
|
||||||
if (i < 0 || i >= this.tabs.length) return
|
if (i < 0 || i >= this.tabs.length) return
|
||||||
// Board-aware focus: in a split grid the focused pane must be ON the board,
|
// Board-aware focus: in a split grid the focused pane must be ON the board,
|
||||||
@@ -1344,6 +1566,9 @@ export class TabApp {
|
|||||||
if (maxBtn) maxBtn.textContent = maximized ? '⤡' : '⛶'
|
if (maxBtn) maxBtn.textContent = maximized ? '⤡' : '⛶'
|
||||||
const monBtn = cell.querySelector<HTMLElement>('.cell-monitor-btn')
|
const monBtn = cell.querySelector<HTMLElement>('.cell-monitor-btn')
|
||||||
if (monBtn) monBtn.classList.toggle('active', grid && entry.monitor)
|
if (monBtn) monBtn.classList.toggle('active', grid && entry.monitor)
|
||||||
|
// W5: the 🏆 keep button appears only on a fan-out lane's cell (any layout).
|
||||||
|
const keepBtn = cell.querySelector<HTMLElement>('.cell-keep')
|
||||||
|
if (keepBtn) keepBtn.style.display = entry.fanoutGroupId !== undefined ? '' : 'none'
|
||||||
|
|
||||||
const nameEl = cell.querySelector('.cell-name')
|
const nameEl = cell.querySelector('.cell-name')
|
||||||
if (nameEl) nameEl.textContent = this.displayTitle(entry, idx)
|
if (nameEl) nameEl.textContent = this.displayTitle(entry, idx)
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ const DEFAULT_GH_TIMEOUT_MS = 8_000
|
|||||||
const DEFAULT_STATUSLINE_TTL_MS = 30_000
|
const DEFAULT_STATUSLINE_TTL_MS = 30_000
|
||||||
// B3 worktrees
|
// B3 worktrees
|
||||||
const DEFAULT_WORKTREE_TIMEOUT_MS = 10_000
|
const DEFAULT_WORKTREE_TIMEOUT_MS = 10_000
|
||||||
|
// W5 fan-out board — max lanes one task may be fanned across (matches grid-6 capacity)
|
||||||
|
const DEFAULT_MAX_FANOUT_LANES = 6
|
||||||
// W4 git write (stage / commit / push)
|
// W4 git write (stage / commit / push)
|
||||||
const DEFAULT_GIT_OPS_TIMEOUT_MS = 10_000 // stage/commit exec bound (local, like worktree)
|
const DEFAULT_GIT_OPS_TIMEOUT_MS = 10_000 // stage/commit exec bound (local, like worktree)
|
||||||
const DEFAULT_GIT_PUSH_TIMEOUT_MS = 120_000 // push is network-bound → longer bound
|
const DEFAULT_GIT_PUSH_TIMEOUT_MS = 120_000 // push is network-bound → longer bound
|
||||||
@@ -410,6 +412,14 @@ export function loadConfig(env: EnvLike): Config {
|
|||||||
DEFAULT_WORKTREE_TIMEOUT_MS,
|
DEFAULT_WORKTREE_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// W5 fan-out board — cap on lanes one task may be fanned across (DoS guard,
|
||||||
|
// like MAX_SESSIONS). 0 is allowed (disables fan-out) via parseNonNegativeInt.
|
||||||
|
const maxFanoutLanes = parseNonNegativeInt(
|
||||||
|
env['MAX_FANOUT_LANES'],
|
||||||
|
'MAX_FANOUT_LANES',
|
||||||
|
DEFAULT_MAX_FANOUT_LANES,
|
||||||
|
)
|
||||||
|
|
||||||
// W4 git write (stage / commit / push) — the highest-risk write channel
|
// W4 git write (stage / commit / push) — the highest-risk write channel
|
||||||
const gitOpsEnabled = parseBool(env['GIT_OPS_ENABLED'], true) // master kill-switch
|
const gitOpsEnabled = parseBool(env['GIT_OPS_ENABLED'], true) // master kill-switch
|
||||||
const gitOpsTimeoutMs = parseNonNegativeInt(
|
const gitOpsTimeoutMs = parseNonNegativeInt(
|
||||||
@@ -497,6 +507,8 @@ export function loadConfig(env: EnvLike): Config {
|
|||||||
worktreeEnabled,
|
worktreeEnabled,
|
||||||
worktreeRoot,
|
worktreeRoot,
|
||||||
worktreeTimeoutMs,
|
worktreeTimeoutMs,
|
||||||
|
// W5 fan-out board
|
||||||
|
maxFanoutLanes,
|
||||||
// W4 git write (stage / commit / push)
|
// W4 git write (stage / commit / push)
|
||||||
gitOpsEnabled,
|
gitOpsEnabled,
|
||||||
gitOpsTimeoutMs,
|
gitOpsTimeoutMs,
|
||||||
|
|||||||
57
src/http/session-groups.ts
Normal file
57
src/http/session-groups.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* src/http/session-groups.ts (W5 fan-out board) — cluster running sessions by
|
||||||
|
* the repo they belong to, for the fan-out discovery endpoint.
|
||||||
|
*
|
||||||
|
* PURE + STRING-ONLY (no `git` exec, no filesystem I/O) — fully node-unit-testable.
|
||||||
|
* Fan-out worktrees always live under `<repo>-worktrees/` (createWorktree's base,
|
||||||
|
* worktrees.ts computeWorktreeDir), so a session whose cwd is
|
||||||
|
* `<repo>-worktrees/<lane>` shares a group with the repo and its sibling lanes.
|
||||||
|
* A session that is NOT under a `*-worktrees` parent groups under its own cwd.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'node:path'
|
||||||
|
import type { LiveSessionInfo, SessionGroup } from '../types.js'
|
||||||
|
|
||||||
|
/** The suffix createWorktree appends to derive the worktree base dir name. */
|
||||||
|
const WORKTREES_SUFFIX = '-worktrees'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the repo root for a session cwd. If the cwd's PARENT directory is a
|
||||||
|
* `<name>-worktrees` folder, the repo root is `<dirname-of-parent>/<name>` (strip
|
||||||
|
* the `-worktrees` suffix). Otherwise the cwd is its own repo root. `null` in →
|
||||||
|
* `null` out (a session with no known cwd has no repo to group under). Pure.
|
||||||
|
*/
|
||||||
|
export function deriveRepoRoot(cwd: string | null): string | null {
|
||||||
|
if (cwd === null || cwd === '') return null
|
||||||
|
const parent = path.dirname(cwd)
|
||||||
|
const parentName = path.basename(parent)
|
||||||
|
if (parentName.length > WORKTREES_SUFFIX.length && parentName.endsWith(WORKTREES_SUFFIX)) {
|
||||||
|
const repoName = parentName.slice(0, -WORKTREES_SUFFIX.length)
|
||||||
|
return path.join(path.dirname(parent), repoName)
|
||||||
|
}
|
||||||
|
return cwd
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group live sessions by derived repo root. Sessions whose cwd resolves to the
|
||||||
|
* same repo (a repo + its `*-worktrees/*` lanes) land in one SessionGroup.
|
||||||
|
* Sessions with `cwd === null` are SKIPPED (no repo to attribute them to).
|
||||||
|
* Group order and per-group member order both follow the input order (which
|
||||||
|
* manager.list() already yields newest-first). Never mutates the input. Pure.
|
||||||
|
*/
|
||||||
|
export function groupSessionsByRepo(sessions: readonly LiveSessionInfo[]): SessionGroup[] {
|
||||||
|
const byRoot = new Map<string, SessionGroup>()
|
||||||
|
const order: string[] = []
|
||||||
|
for (const session of sessions) {
|
||||||
|
const repoRoot = deriveRepoRoot(session.cwd)
|
||||||
|
if (repoRoot === null) continue // no cwd → not attributable to a repo
|
||||||
|
let group = byRoot.get(repoRoot)
|
||||||
|
if (group === undefined) {
|
||||||
|
group = { repoRoot, label: path.basename(repoRoot), sessions: [] }
|
||||||
|
byRoot.set(repoRoot, group)
|
||||||
|
order.push(repoRoot)
|
||||||
|
}
|
||||||
|
group.sessions.push(session)
|
||||||
|
}
|
||||||
|
return order.map((root) => byRoot.get(root)!)
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ import { buildDigest, clampSince } from './http/digest.js'
|
|||||||
import { getPrStatus } from './http/gh.js'
|
import { getPrStatus } from './http/gh.js'
|
||||||
import { parseStatusLine } from './http/statusline.js'
|
import { parseStatusLine } from './http/statusline.js'
|
||||||
import { createWorktree, removeWorktree, pruneWorktrees } from './http/worktrees.js'
|
import { createWorktree, removeWorktree, pruneWorktrees } from './http/worktrees.js'
|
||||||
|
import { groupSessionsByRepo } from './http/session-groups.js'
|
||||||
import { stageFiles, commit as gitCommit, push as gitPush } from './http/git-ops.js'
|
import { stageFiles, commit as gitCommit, push as gitPush } from './http/git-ops.js'
|
||||||
import { createSessionManager } from './session/manager.js'
|
import { createSessionManager } from './session/manager.js'
|
||||||
import { detachWs, writeInput, setClientDims } from './session/session.js'
|
import { detachWs, writeInput, setClientDims } from './session/session.js'
|
||||||
@@ -333,6 +334,14 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
|||||||
res.json(manager.list())
|
res.json(manager.list())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── W5 fan-out board: running sessions clustered by repo (read-only) ──────
|
||||||
|
// Pure string grouping over manager.list() (no git/FS work); same threat model
|
||||||
|
// as /live-sessions and /digest → no Origin guard. Registered BEFORE the
|
||||||
|
// /live-sessions/:id routes so "grouped" is never captured as an :id.
|
||||||
|
app.get('/live-sessions/grouped', (_req, res) => {
|
||||||
|
res.json(groupSessionsByRepo(manager.list()))
|
||||||
|
})
|
||||||
|
|
||||||
// ── W3 quick-wins (c): "while you were away" reconnect digest (read-only) ──
|
// ── W3 quick-wins (c): "while you were away" reconnect digest (read-only) ──
|
||||||
// A pure read-side aggregate over manager.list() + in-memory telemetry/status;
|
// A pure read-side aggregate over manager.list() + in-memory telemetry/status;
|
||||||
// no Origin guard (same threat model as /live-sessions). `?since=<epochMs>` is
|
// no Origin guard (same threat model as /live-sessions). `?since=<epochMs>` is
|
||||||
@@ -1102,6 +1111,8 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
|||||||
// W3(b): expose the cost budget only when set (>0) so the FE can derive
|
// W3(b): expose the cost budget only when set (>0) so the FE can derive
|
||||||
// cost-overage warn styling; a non-secret number, safe over /config/ui.
|
// cost-overage warn styling; a non-secret number, safe over /config/ui.
|
||||||
...(cfg.costBudgetUsd > 0 ? { costBudgetUsd: cfg.costBudgetUsd } : {}),
|
...(cfg.costBudgetUsd > 0 ? { costBudgetUsd: cfg.costBudgetUsd } : {}),
|
||||||
|
// W5 fan-out board: let the FE stepper max mirror MAX_FANOUT_LANES.
|
||||||
|
maxFanoutLanes: cfg.maxFanoutLanes,
|
||||||
}
|
}
|
||||||
res.json(uiConfig)
|
res.json(uiConfig)
|
||||||
})
|
})
|
||||||
|
|||||||
26
src/types.ts
26
src/types.ts
@@ -72,6 +72,8 @@ export interface Config {
|
|||||||
readonly worktreeEnabled: boolean; // WORKTREE_ENABLED, default true
|
readonly worktreeEnabled: boolean; // WORKTREE_ENABLED, default true
|
||||||
readonly worktreeRoot: string | undefined; // WORKTREE_ROOT (undefined → computed)
|
readonly worktreeRoot: string | undefined; // WORKTREE_ROOT (undefined → computed)
|
||||||
readonly worktreeTimeoutMs: number; // WORKTREE_TIMEOUT_MS, default 10000
|
readonly worktreeTimeoutMs: number; // WORKTREE_TIMEOUT_MS, default 10000
|
||||||
|
// W5 fan-out board — cap on lanes one task may be fanned across (grid-6 capacity)
|
||||||
|
readonly maxFanoutLanes: number; // MAX_FANOUT_LANES, default 6
|
||||||
// W4 git write (stage / commit / push) — the highest-risk write channel
|
// W4 git write (stage / commit / push) — the highest-risk write channel
|
||||||
readonly gitOpsEnabled: boolean; // GIT_OPS_ENABLED, default true (kill-switch → all 3 routes 403)
|
readonly gitOpsEnabled: boolean; // GIT_OPS_ENABLED, default true (kill-switch → all 3 routes 403)
|
||||||
readonly gitOpsTimeoutMs: number; // GIT_OPS_TIMEOUT_MS, default 10000 (stage/commit exec bound)
|
readonly gitOpsTimeoutMs: number; // GIT_OPS_TIMEOUT_MS, default 10000 (stage/commit exec bound)
|
||||||
@@ -311,6 +313,27 @@ export interface LiveSessionInfo {
|
|||||||
readonly queueLength?: number;
|
readonly queueLength?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ───────────────── W5 fan-out board (parallel agent lanes) ───────────────── */
|
||||||
|
|
||||||
|
/** A group of running sessions sharing a repo/worktree-root (fan-out discovery).
|
||||||
|
* Derived STRING-ONLY from each session's cwd (no git exec): fan-out worktrees
|
||||||
|
* live under `<repo>-worktrees/`, so sessions whose cwd shares that parent
|
||||||
|
* cluster into one group. impl: src/http/session-groups.ts groupSessionsByRepo. */
|
||||||
|
export interface SessionGroup {
|
||||||
|
repoRoot: string; // derived repo dir (parent of the *-worktrees folder, or the cwd)
|
||||||
|
label: string; // basename(repoRoot)
|
||||||
|
sessions: LiveSessionInfo[]; // members, newest-first (already the manager.list order)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Launch options the FE passes to TabApp.launchFanout (FE-internal, but typed
|
||||||
|
* shared so the form and the launcher agree on ONE shape). */
|
||||||
|
export interface FanoutLaunchOpts {
|
||||||
|
prompt: string;
|
||||||
|
lanes: number; // 2..maxFanoutLanes (clamped to grid-6 capacity + the DoS cap)
|
||||||
|
branchBase: string; // slug; lanes get `${branchBase}-lane-${i}`
|
||||||
|
mode?: PermissionMode; // reuse PermissionMode
|
||||||
|
}
|
||||||
|
|
||||||
/* ───────────────── project manager (v0.6, §4.3 FEATURE doc) ──────────────── */
|
/* ───────────────── project manager (v0.6, §4.3 FEATURE doc) ──────────────── */
|
||||||
|
|
||||||
/** One running session belonging to a project (live-sessions归并 by cwd).
|
/** One running session belonging to a project (live-sessions归并 by cwd).
|
||||||
@@ -658,6 +681,9 @@ export interface UiConfig {
|
|||||||
/** W3 quick-wins (b): the cost-budget threshold (USD). Present when > 0 so the
|
/** W3 quick-wins (b): the cost-budget threshold (USD). Present when > 0 so the
|
||||||
* FE can derive cost-overage warn styling client-side; omitted when disabled. */
|
* FE can derive cost-overage warn styling client-side; omitted when disabled. */
|
||||||
costBudgetUsd?: number;
|
costBudgetUsd?: number;
|
||||||
|
/** W5 fan-out board: server-controlled cap on fan-out lanes so the FE stepper
|
||||||
|
* max mirrors MAX_FANOUT_LANES. Additive OPTIONAL (older clients default to 6). */
|
||||||
|
maxFanoutLanes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── W3 quick-wins (c) reconnect digest (GET /digest) ── */
|
/* ── W3 quick-wins (c) reconnect digest (GET /digest) ── */
|
||||||
|
|||||||
@@ -299,6 +299,18 @@ describe('loadConfig — session/rate/timer constants', () => {
|
|||||||
expect(() => loadConfig({ MAX_SESSIONS: 'lots' })).toThrow()
|
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', () => {
|
it('defaults maxMsgsPerSec to 2000 and reads MAX_MSGS_PER_SEC', () => {
|
||||||
expect(loadConfig({}).maxMsgsPerSec).toBe(2000)
|
expect(loadConfig({}).maxMsgsPerSec).toBe(2000)
|
||||||
expect(loadConfig({ MAX_MSGS_PER_SEC: '500' }).maxMsgsPerSec).toBe(500)
|
expect(loadConfig({ MAX_MSGS_PER_SEC: '500' }).maxMsgsPerSec).toBe(500)
|
||||||
|
|||||||
128
test/fanout.test.ts
Normal file
128
test/fanout.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
119
test/http/session-groups.test.ts
Normal file
119
test/http/session-groups.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
172
test/integration/grouped-endpoint.test.ts
Normal file
172
test/integration/grouped-endpoint.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -206,13 +206,13 @@ describe('GET /config/ui', () => {
|
|||||||
const { port } = await spawnServer()
|
const { port } = await spawnServer()
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/config/ui`)
|
const res = await fetch(`http://127.0.0.1:${port}/config/ui`)
|
||||||
expect(res.status).toBe(200)
|
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 () => {
|
it('reports allowAutoMode:true when ALLOW_AUTO_MODE=1', async () => {
|
||||||
const { port } = await spawnServer({ ALLOW_AUTO_MODE: '1' })
|
const { port } = await spawnServer({ ALLOW_AUTO_MODE: '1' })
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/config/ui`)
|
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 () => {
|
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 () => {
|
it('reports costBudgetUsd when COST_BUDGET_USD is set (W3 b)', async () => {
|
||||||
const { port } = await spawnServer({ COST_BUDGET_USD: '7.5' })
|
const { port } = await spawnServer({ COST_BUDGET_USD: '7.5' })
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/config/ui`)
|
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 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ const CFG: Config = {
|
|||||||
worktreeEnabled: true,
|
worktreeEnabled: true,
|
||||||
worktreeRoot: undefined,
|
worktreeRoot: undefined,
|
||||||
worktreeTimeoutMs: 10_000,
|
worktreeTimeoutMs: 10_000,
|
||||||
|
maxFanoutLanes: 6,
|
||||||
defaultPermissionMode: 'default',
|
defaultPermissionMode: 'default',
|
||||||
allowAutoMode: false,
|
allowAutoMode: false,
|
||||||
// W2 inject queue
|
// W2 inject queue
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ const BASE_CFG: Config = {
|
|||||||
worktreeEnabled: true,
|
worktreeEnabled: true,
|
||||||
worktreeRoot: undefined,
|
worktreeRoot: undefined,
|
||||||
worktreeTimeoutMs: 10_000,
|
worktreeTimeoutMs: 10_000,
|
||||||
|
maxFanoutLanes: 6,
|
||||||
defaultPermissionMode: 'default',
|
defaultPermissionMode: 'default',
|
||||||
allowAutoMode: false,
|
allowAutoMode: false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ const BASE_CFG: Config = {
|
|||||||
worktreeEnabled: true,
|
worktreeEnabled: true,
|
||||||
worktreeRoot: undefined,
|
worktreeRoot: undefined,
|
||||||
worktreeTimeoutMs: 10_000,
|
worktreeTimeoutMs: 10_000,
|
||||||
|
maxFanoutLanes: 6,
|
||||||
defaultPermissionMode: 'default',
|
defaultPermissionMode: 'default',
|
||||||
allowAutoMode: false,
|
allowAutoMode: false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const BASE_CFG: Config = {
|
|||||||
worktreeEnabled: true,
|
worktreeEnabled: true,
|
||||||
worktreeRoot: undefined,
|
worktreeRoot: undefined,
|
||||||
worktreeTimeoutMs: 10_000,
|
worktreeTimeoutMs: 10_000,
|
||||||
|
maxFanoutLanes: 6,
|
||||||
defaultPermissionMode: 'default',
|
defaultPermissionMode: 'default',
|
||||||
allowAutoMode: false,
|
allowAutoMode: false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,7 +156,26 @@ const mountProjects = vi.fn(() => ({
|
|||||||
},
|
},
|
||||||
refresh: 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.
|
// settings.js is light but imports nothing heavy; let it load for real.
|
||||||
|
|
||||||
@@ -200,6 +219,16 @@ beforeEach(() => {
|
|||||||
quickReply.onSend = undefined
|
quickReply.onSend = undefined
|
||||||
quickReply.onQueue = undefined
|
quickReply.onQueue = undefined
|
||||||
enqueueFollowupMock.mockClear()
|
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.onTranscript = undefined
|
||||||
voice.onInterim = undefined
|
voice.onInterim = undefined
|
||||||
// Default: server forbids 'auto' (loadUiConfig resolves to allowAutoMode=false).
|
// Default: server forbids 'auto' (loadUiConfig resolves to allowAutoMode=false).
|
||||||
@@ -208,6 +237,7 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
|
vi.restoreAllMocks() // restore window.confirm etc. spied via vi.spyOn (W5 tests)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('TabApp — v0.5 launcher chooser', () => {
|
describe('TabApp — v0.5 launcher chooser', () => {
|
||||||
@@ -1718,3 +1748,169 @@ describe('TabApp — W2 inject queue', () => {
|
|||||||
expect(enqueueFollowupMock).not.toHaveBeenCalled()
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ vi.mock('../public/gh-chip.js', () => ({ mountPrChip: mockMountPrChip }))
|
|||||||
const {
|
const {
|
||||||
validateBranchNameClient,
|
validateBranchNameClient,
|
||||||
renderNewWorktreeForm,
|
renderNewWorktreeForm,
|
||||||
|
renderFanoutForm,
|
||||||
renderProjectDetail,
|
renderProjectDetail,
|
||||||
makeWorktreeRow,
|
makeWorktreeRow,
|
||||||
confirmAndRemoveWorktree,
|
confirmAndRemoveWorktree,
|
||||||
@@ -53,7 +54,7 @@ const {
|
|||||||
/* ── Helpers ───────────────────────────────────────────────────────────────── */
|
/* ── Helpers ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function makeHooks() {
|
function makeHooks() {
|
||||||
return { onOpenProject: vi.fn(), onEnterSession: vi.fn() }
|
return { onOpenProject: vi.fn(), onEnterSession: vi.fn(), onFanout: vi.fn() }
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeCbs() {
|
function makeCbs() {
|
||||||
@@ -286,6 +287,7 @@ describe('renderNewWorktreeForm', () => {
|
|||||||
// Flush async microtasks
|
// Flush async microtasks
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
|
||||||
expect(hooks.onOpenProject).toHaveBeenCalledWith(
|
expect(hooks.onOpenProject).toHaveBeenCalledWith(
|
||||||
'/home/user/my-repo-worktrees/feat',
|
'/home/user/my-repo-worktrees/feat',
|
||||||
@@ -309,6 +311,7 @@ describe('renderNewWorktreeForm', () => {
|
|||||||
|
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
|
||||||
expect(hooks.onOpenProject).toHaveBeenCalledWith('/repo', 'feat', 'claude\r')
|
expect(hooks.onOpenProject).toHaveBeenCalledWith('/repo', 'feat', 'claude\r')
|
||||||
})
|
})
|
||||||
@@ -327,6 +330,7 @@ describe('renderNewWorktreeForm', () => {
|
|||||||
|
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
|
||||||
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
|
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
|
||||||
expect(errorEl.style.display).not.toBe('none')
|
expect(errorEl.style.display).not.toBe('none')
|
||||||
@@ -345,6 +349,7 @@ describe('renderNewWorktreeForm', () => {
|
|||||||
|
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
|
||||||
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
|
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
|
||||||
expect(errorEl.textContent).toBeTruthy()
|
expect(errorEl.textContent).toBeTruthy()
|
||||||
@@ -365,6 +370,7 @@ describe('renderNewWorktreeForm', () => {
|
|||||||
|
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
|
||||||
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
|
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
|
||||||
// No <script> element in DOM — textContent was used
|
// 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 ───────────────────────────── */
|
/* ── renderProjectDetail — A4: Activity section ───────────────────────────── */
|
||||||
|
|
||||||
describe('renderProjectDetail — A4 Activity section', () => {
|
describe('renderProjectDetail — A4 Activity section', () => {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export default defineConfig({
|
|||||||
'public/link-paths.ts',
|
'public/link-paths.ts',
|
||||||
'public/queue.ts',
|
'public/queue.ts',
|
||||||
'public/tabs.ts',
|
'public/tabs.ts',
|
||||||
|
'public/fanout.ts',
|
||||||
'public/grid-layout.ts',
|
'public/grid-layout.ts',
|
||||||
'public/grid-presets.ts',
|
'public/grid-presets.ts',
|
||||||
'public/cell-monitor.ts',
|
'public/cell-monitor.ts',
|
||||||
|
|||||||
Reference in New Issue
Block a user