Files
web-terminal/test/push/push-service.test.ts
Yaojia Wang 9683a16f4f 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.
2026-07-13 05:09:19 +02:00

328 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
Config,
NotifyClass,
PushPayload,
PushSubscriptionRecord,
Session,
} from '../../src/types.js'
import type { SubscriptionStore } from '../../src/push/subscription-store.js'
import {
createPushService,
type PushSendOptions,
type PushSender,
} from '../../src/push/push-service.js'
/* ── fixtures ─────────────────────────────────────────────────────── */
const BASE_CFG: Config = {
port: 3000,
bindHost: '0.0.0.0',
shellPath: '/bin/zsh',
homeDir: '/home/tester',
idleTtlMs: 10_000,
scrollbackBytes: 2 * 1024 * 1024,
maxPayloadBytes: 1024 * 1024,
wsPath: '/term',
maxSessions: 50,
maxMsgsPerSec: 2000,
permTimeoutMs: 300_000,
reapIntervalMs: 60_000,
previewBytes: 24 * 1024,
useTmux: false,
allowedOrigins: [],
projectRoots: ['/home/tester'],
projectScanDepth: 4,
projectScanTtlMs: 10_000,
projectDirtyCheck: true,
editorCmd: 'code',
vapidPublicKey: 'PUBKEY',
vapidPrivateKey: 'PRIVKEY',
vapidSubject: 'mailto:admin@example.com',
pushStorePath: '/tmp/push-subs.json',
pushMaxSubs: 50,
notifyDone: true,
notifyDnd: false,
decisionTokenTtlMs: 300_000,
timelineMax: 200,
timelineEnabled: true,
stuckTtlMs: 600_000,
stuckAlert: true,
diffTimeoutMs: 2000,
diffMaxBytes: 2 * 1024 * 1024,
diffMaxFiles: 300,
statuslineTtlMs: 30_000,
worktreeEnabled: true,
worktreeRoot: undefined,
worktreeTimeoutMs: 10_000,
maxFanoutLanes: 6,
defaultPermissionMode: 'default',
allowAutoMode: false,
}
function cfg(overrides: Partial<Config> = {}): Config {
return { ...BASE_CFG, ...overrides }
}
const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
function fakeSession(id = SID, cwd: string | null = '/home/tester/my-repo'): Session {
return { meta: { id, createdAt: 0, shellPath: '/bin/zsh' }, cwd } as unknown as Session
}
function rec(endpoint: string): PushSubscriptionRecord {
return { endpoint, keys: { p256dh: 'p-' + endpoint, auth: 'a-' + endpoint }, createdAt: 1 }
}
interface SentCall {
record: PushSubscriptionRecord
payload: PushPayload
options: PushSendOptions
}
/** A fake sender that records calls and can be told to throw per-endpoint. */
function makeSender(failures: Record<string, number> = {}): PushSender & {
sent: SentCall[]
vapid: { subject: string; publicKey: string; privateKey: string } | null
} {
const sent: SentCall[] = []
let vapid: { subject: string; publicKey: string; privateKey: string } | null = null
return {
sent,
get vapid() {
return vapid
},
setVapid(subject, publicKey, privateKey) {
vapid = { subject, publicKey, privateKey }
},
async send(record, payload, options) {
const code = failures[record.endpoint]
if (code) {
const e = new Error('push failed') as Error & { statusCode: number }
e.statusCode = code
throw e
}
sent.push({ record, payload: JSON.parse(payload) as PushPayload, options })
},
}
}
/** A fake store backed by an in-memory array. */
function makeStore(initial: PushSubscriptionRecord[] = []): SubscriptionStore & {
pruned: string[][]
persistCount: number
} {
let records = [...initial]
const pruned: string[][] = []
let persistCount = 0
return {
pruned,
get persistCount() {
return persistCount
},
list: () => Object.freeze([...records]),
add(r) {
records = [...records.filter((x) => x.endpoint !== r.endpoint), r]
},
remove(endpoint) {
records = records.filter((x) => x.endpoint !== endpoint)
},
prune(dead) {
pruned.push([...dead])
records = records.filter((x) => !dead.includes(x.endpoint))
},
async persist() {
persistCount++
},
}
}
beforeEach(() => {
vi.restoreAllMocks()
})
/* ── isEnabled truth table ────────────────────────────────────────── */
describe('createPushService — isEnabled', () => {
it('is true only when both VAPID keys are configured', () => {
expect(createPushService(cfg(), makeStore(), { sender: makeSender() }).isEnabled()).toBe(true)
expect(
createPushService(cfg({ vapidPublicKey: undefined }), makeStore(), { sender: makeSender() }).isEnabled(),
).toBe(false)
expect(
createPushService(cfg({ vapidPrivateKey: undefined }), makeStore(), { sender: makeSender() }).isEnabled(),
).toBe(false)
expect(
createPushService(cfg({ vapidPublicKey: undefined, vapidPrivateKey: undefined }), makeStore(), {
sender: makeSender(),
}).isEnabled(),
).toBe(false)
})
it('registers VAPID details with the sender when enabled', () => {
const sender = makeSender()
createPushService(cfg(), makeStore(), { sender })
expect(sender.vapid).toEqual({
subject: 'mailto:admin@example.com',
publicKey: 'PUBKEY',
privateKey: 'PRIVKEY',
})
})
it('does not register VAPID details when disabled', () => {
const sender = makeSender()
createPushService(cfg({ vapidPrivateKey: undefined }), makeStore(), { sender })
expect(sender.vapid).toBeNull()
})
it('falls back to a default subject when VAPID_SUBJECT is unset', () => {
const sender = makeSender()
createPushService(cfg({ vapidSubject: undefined }), makeStore(), { sender })
expect(sender.vapid?.subject).toMatch(/^mailto:/)
})
})
/* ── notify: short-circuits ───────────────────────────────────────── */
describe('notify — short-circuits', () => {
it('is a no-op when push is disabled', async () => {
const sender = makeSender()
const svc = createPushService(cfg({ vapidPublicKey: undefined }), makeStore([rec('e1')]), { sender })
await svc.notify(fakeSession(), 'needs-input', 'tok')
expect(sender.sent).toHaveLength(0)
})
it('is a no-op when NOTIFY_DND is on', async () => {
const sender = makeSender()
const svc = createPushService(cfg({ notifyDnd: true }), makeStore([rec('e1')]), { sender })
await svc.notify(fakeSession(), 'needs-input', 'tok')
expect(sender.sent).toHaveLength(0)
})
it('drops DONE notifications when NOTIFY_DONE is off, but still sends needs-input/stuck', async () => {
const sender = makeSender()
const svc = createPushService(cfg({ notifyDone: false }), makeStore([rec('e1')]), { sender })
await svc.notify(fakeSession(), 'done')
expect(sender.sent).toHaveLength(0)
await svc.notify(fakeSession(), 'needs-input', 'tok')
await svc.notify(fakeSession(), 'stuck')
expect(sender.sent.map((c) => c.payload.cls)).toEqual(['needs-input', 'stuck'])
})
it('is a no-op when there are no subscriptions', async () => {
const sender = makeSender()
const svc = createPushService(cfg(), makeStore([]), { sender })
await svc.notify(fakeSession(), 'needs-input', 'tok')
expect(sender.sent).toHaveLength(0)
})
})
/* ── notify: payload shape ────────────────────────────────────────── */
describe('notify — payload (§3.3 PushPayload)', () => {
it('sends a minimal needs-input payload with the capability token to every subscription', async () => {
const sender = makeSender()
const svc = createPushService(cfg(), makeStore([rec('e1'), rec('e2')]), { sender })
await svc.notify(fakeSession(), 'needs-input', 'cap-tok')
expect(sender.sent).toHaveLength(2)
const { payload } = sender.sent[0]!
expect(payload.sessionId).toBe(SID)
expect(payload.cls).toBe('needs-input')
expect(payload.token).toBe('cap-tok')
// minimal: only known keys, no raw output / secrets
expect(Object.keys(payload).sort()).toEqual(['cls', 'detail', 'sessionId', 'token'])
expect(payload.detail).toBe('my-repo')
})
it('omits the token for non-needs-input classes even when one is passed', async () => {
const sender = makeSender()
const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender })
await svc.notify(fakeSession(), 'done', 'leaked-token')
expect(sender.sent[0]!.payload.token).toBeUndefined()
})
it('omits detail when the session has no cwd', async () => {
const sender = makeSender()
const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender })
await svc.notify(fakeSession(SID, null), 'stuck')
expect(sender.sent[0]!.payload.detail).toBeUndefined()
})
})
/* ── notify: web-push options ─────────────────────────────────────── */
describe('notify — send options', () => {
it('uses high urgency for needs-input/stuck and low for done', async () => {
const sender = makeSender()
const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender })
await svc.notify(fakeSession(), 'needs-input', 'tok')
await svc.notify(fakeSession(), 'stuck')
await svc.notify(fakeSession(), 'done')
expect(sender.sent.map((c) => c.options.urgency)).toEqual(['high', 'high', 'low'])
})
it('sets a per-session collapse topic (valid Topic header, no dashes, <=32 chars)', async () => {
const sender = makeSender()
const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender })
await svc.notify(fakeSession(), 'needs-input', 'tok')
const topic = sender.sent[0]!.options.topic!
expect(topic).toBe(SID.replace(/-/g, ''))
expect(topic.length).toBeLessThanOrEqual(32)
expect(topic).toMatch(/^[A-Za-z0-9_-]+$/)
})
})
/* ── notify: pruning dead subscriptions ───────────────────────────── */
describe('notify — pruning', () => {
for (const code of [404, 410] as const) {
it(`prunes + persists a subscription that returns ${code}`, async () => {
const sender = makeSender({ e2: code })
const store = makeStore([rec('e1'), rec('e2'), rec('e3')])
const svc = createPushService(cfg(), store, { sender })
await svc.notify(fakeSession(), 'needs-input', 'tok')
expect(store.pruned).toEqual([['e2']])
expect(store.persistCount).toBe(1)
// the live subscriptions still received the push
expect(sender.sent.map((c) => c.record.endpoint).sort()).toEqual(['e1', 'e3'])
})
}
it('does NOT prune on transient errors (e.g. 500); logs and keeps the subscription', async () => {
const err = vi.spyOn(console, 'error').mockImplementation(() => {})
const sender = makeSender({ e1: 500 })
const store = makeStore([rec('e1'), rec('e2')])
const svc = createPushService(cfg(), store, { sender })
await svc.notify(fakeSession(), 'done')
expect(store.pruned).toEqual([])
expect(store.persistCount).toBe(0)
expect(err).toHaveBeenCalled()
expect(sender.sent.map((c) => c.record.endpoint)).toEqual(['e2'])
})
it('does not persist when no subscriptions are dead', async () => {
const sender = makeSender()
const store = makeStore([rec('e1')])
const svc = createPushService(cfg(), store, { sender })
await svc.notify(fakeSession(), 'needs-input', 'tok')
expect(store.persistCount).toBe(0)
})
})
/* ── default sender (web-push) is wired without crashing ──────────── */
describe('createPushService — default sender', () => {
it('constructs with the real web-push sender when none is injected', () => {
// disabled config → setVapidDetails not called, so no real keys needed
const svc = createPushService(cfg({ vapidPublicKey: undefined, vapidPrivateKey: undefined }), makeStore())
expect(svc.isEnabled()).toBe(false)
})
})
/* exhaustiveness guard: every NotifyClass is exercised above */
const _classes: NotifyClass[] = ['needs-input', 'done', 'stuck']
void _classes