feat(queue): server-side PTY-inject + idle-drained follow-up queue (W2)
The walk-away primitive: queue follow-up prompts and let a session advance itself
while you're gone — "run the tests" drains, and when Claude next goes idle "open a
PR" drains. Injection reuses writeInput (byte-identical to a keystroke, broadcasts
to all mirrored devices); the byte-shuttle is untouched.
- POST/GET/DELETE /live-sessions/:id/queue — POST/DELETE behind requireAllowedOrigin
+ a per-IP rate limit (QUEUE_RATE_MAX=20/min) + SESSION_ID_RE; text non-empty,
byte-capped (QUEUE_ITEM_MAX_BYTES=4096, 16kb body), count-capped (QUEUE_MAX_ITEMS=10).
- Bounded per-session queue in manager (enqueueFollowup/drainOne/clearQueue);
new optional queueLength on LiveSessionInfo.
- Idle drain: the Stop/SessionEnd /hook branch scheduleDrain()s a debounced timer
(QUEUE_SETTLE_MS=1500). It drains exactly one entry only if the session still
exists, hasn't exited, produced no output during settle, and is idle — three
guards (debounced single timer + pop-one + settle re-check) → one entry per idle.
- New additive ServerMessage {type:'queue',length} broadcasts the count to mirrors
(⧗N badge); FE enqueue via the quick-reply editor + TabApp.enqueueToActive.
Injected bytes go verbatim to the PTY (never built into a shell command). Verified
independently: typecheck + build:web clean, 1737 tests pass (real-PTY integration
covers idle-drain / one-per-idle / settle-guard). Foundation for templated launches,
auto-continue, and issue-intake (docs/ROADMAP.md).
This commit is contained in:
@@ -730,3 +730,40 @@ describe('loadConfig — v0.7 all new fields present in frozen result', () => {
|
||||
expect(cfg).toHaveProperty('allowAutoMode')
|
||||
})
|
||||
})
|
||||
|
||||
// ── W2 inject-queue config ────────────────────────────────────────────────────
|
||||
describe('loadConfig — W2 inject queue', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
})
|
||||
|
||||
it('defaults: queueEnabled true, queueMaxItems 10, queueItemMaxBytes 4096, queueSettleMs 1500', () => {
|
||||
const cfg = loadConfig({})
|
||||
expect(cfg.queueEnabled).toBe(true)
|
||||
expect(cfg.queueMaxItems).toBe(10)
|
||||
expect(cfg.queueItemMaxBytes).toBe(4096)
|
||||
expect(cfg.queueSettleMs).toBe(1500)
|
||||
})
|
||||
|
||||
it('reads env overrides', () => {
|
||||
const cfg = loadConfig({
|
||||
QUEUE_ENABLED: '0',
|
||||
QUEUE_MAX_ITEMS: '3',
|
||||
QUEUE_ITEM_MAX_BYTES: '256',
|
||||
QUEUE_SETTLE_MS: '500',
|
||||
})
|
||||
expect(cfg.queueEnabled).toBe(false)
|
||||
expect(cfg.queueMaxItems).toBe(3)
|
||||
expect(cfg.queueItemMaxBytes).toBe(256)
|
||||
expect(cfg.queueSettleMs).toBe(500)
|
||||
})
|
||||
|
||||
it('throws for a negative QUEUE_MAX_ITEMS (fail-fast)', () => {
|
||||
expect(() => loadConfig({ QUEUE_MAX_ITEMS: '-1' })).toThrow()
|
||||
})
|
||||
|
||||
it('throws for a non-integer QUEUE_SETTLE_MS', () => {
|
||||
expect(() => loadConfig({ QUEUE_SETTLE_MS: 'soon' })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
395
test/integration/queue.test.ts
Normal file
395
test/integration/queue.test.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* W2 — integration tests for the server-side PTY-inject queue routes and the
|
||||
* idle-drain wiring, against a real startServer.
|
||||
*
|
||||
* Sandbox-runnable (no PTY): Origin/CSRF guard, id/text validation, size + depth
|
||||
* caps, rate limit, QUEUE_ENABLED=0 → 503, unknown-session 404.
|
||||
*
|
||||
* itPty-gated (needs a real shell — auto-skips where posix_spawn is blocked):
|
||||
* - happy path: POST queue → 200 {length:1}; GET /live-sessions shows
|
||||
* queueLength; GET/DELETE .../queue.
|
||||
* - idle-drain: enqueue → POST Stop → the marker echoes through the PTY.
|
||||
* - one-per-idle pacing: two entries, one Stop drains only the first.
|
||||
* - settle guard: a non-idle hook during the window cancels the drain.
|
||||
*/
|
||||
|
||||
import net from 'node:net'
|
||||
|
||||
import { afterEach, 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'
|
||||
|
||||
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('bad addr'))
|
||||
return
|
||||
}
|
||||
const port = addr.port
|
||||
srv.close(() => resolve(port))
|
||||
})
|
||||
srv.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
const handles: { close(): Promise<void> }[] = []
|
||||
|
||||
async function spawnServer(
|
||||
overrides: Record<string, string | undefined> = {},
|
||||
): Promise<{ port: number; origin: string }> {
|
||||
const 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',
|
||||
...overrides,
|
||||
})
|
||||
const handle = startServer(cfg)
|
||||
handles.push(handle)
|
||||
await new Promise<void>((r) => setTimeout(r, 80))
|
||||
return { port, origin: `http://127.0.0.1:${port}` }
|
||||
}
|
||||
|
||||
function waitForOpen(ws: WebSocket): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error('open timeout')), 3000)
|
||||
ws.once('open', () => {
|
||||
clearTimeout(t)
|
||||
resolve()
|
||||
})
|
||||
ws.once('error', (e) => {
|
||||
clearTimeout(t)
|
||||
reject(e)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function waitForMessage(
|
||||
ws: WebSocket,
|
||||
pred: (m: Record<string, unknown>) => boolean,
|
||||
timeoutMs = 6000,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => {
|
||||
ws.off('message', onMsg)
|
||||
reject(new Error('message timeout'))
|
||||
}, timeoutMs)
|
||||
const onMsg = (raw: WebSocket.RawData): void => {
|
||||
let m: Record<string, unknown>
|
||||
try {
|
||||
m = JSON.parse(raw.toString('utf8')) as Record<string, unknown>
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (pred(m)) {
|
||||
clearTimeout(t)
|
||||
ws.off('message', onMsg)
|
||||
resolve(m)
|
||||
}
|
||||
}
|
||||
ws.on('message', onMsg)
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolve once no WS message has arrived for `quietMs` (the shell prompt settled). */
|
||||
function waitForQuiet(ws: WebSocket, quietMs = 300, maxMs = 4000): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
let quietTimer: ReturnType<typeof setTimeout>
|
||||
const done = (): void => {
|
||||
clearTimeout(quietTimer)
|
||||
clearTimeout(hardStop)
|
||||
ws.off('message', onMsg)
|
||||
resolve()
|
||||
}
|
||||
const arm = (): void => {
|
||||
clearTimeout(quietTimer)
|
||||
quietTimer = setTimeout(done, quietMs)
|
||||
}
|
||||
const onMsg = (): void => arm()
|
||||
const hardStop = setTimeout(done, maxMs)
|
||||
ws.on('message', onMsg)
|
||||
arm()
|
||||
})
|
||||
}
|
||||
|
||||
/** Collect every output-frame's data string for `windowMs`. */
|
||||
function collectOutput(ws: WebSocket, windowMs: number): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let acc = ''
|
||||
const onMsg = (raw: WebSocket.RawData): void => {
|
||||
try {
|
||||
const m = JSON.parse(raw.toString('utf8')) as Record<string, unknown>
|
||||
if (m['type'] === 'output') acc += String(m['data'])
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
ws.on('message', onMsg)
|
||||
setTimeout(() => {
|
||||
ws.off('message', onMsg)
|
||||
resolve(acc)
|
||||
}, windowMs)
|
||||
})
|
||||
}
|
||||
|
||||
const isAttached = (m: Record<string, unknown>): boolean => m['type'] === 'attached'
|
||||
|
||||
async function attachSession(port: number, origin: string): Promise<{ ws: WebSocket; sessionId: string }> {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } })
|
||||
await waitForOpen(ws)
|
||||
ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
|
||||
const attached = await waitForMessage(ws, isAttached)
|
||||
return { ws, sessionId: attached['sessionId'] as string }
|
||||
}
|
||||
|
||||
function postQueue(
|
||||
port: number,
|
||||
id: string,
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<globalThis.Response> {
|
||||
return fetch(`http://127.0.0.1:${port}/live-sessions/${id}/queue`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function postStop(port: number, sessionId: string): Promise<globalThis.Response> {
|
||||
return fetch(`http://127.0.0.1:${port}/hook`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId },
|
||||
body: JSON.stringify({ hook_event_name: 'Stop' }),
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (handles.length > 0) await handles.pop()?.close()
|
||||
await new Promise<void>((r) => setTimeout(r, 30))
|
||||
})
|
||||
|
||||
const UNKNOWN = '00000000-0000-4000-8000-000000000000'
|
||||
|
||||
// ── Route guards + validation (no PTY needed) ─────────────────────────────────
|
||||
|
||||
describe('POST /live-sessions/:id/queue — guards & validation', () => {
|
||||
it('rejects a foreign Origin with 403 (CSRF)', async () => {
|
||||
const { port } = await spawnServer()
|
||||
const res = await postQueue(port, UNKNOWN, { text: 'x' }, { Origin: 'http://evil.example' })
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects a missing Origin with 403 (default-deny)', async () => {
|
||||
const { port } = await spawnServer()
|
||||
const res = await postQueue(port, UNKNOWN, { text: 'x' }) // no Origin header
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects a malformed session id with 400', async () => {
|
||||
const { port, origin } = await spawnServer()
|
||||
const res = await postQueue(port, 'not-a-uuid', { text: 'x' }, { Origin: origin })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects empty / non-string text with 400', async () => {
|
||||
const { port, origin } = await spawnServer()
|
||||
expect((await postQueue(port, UNKNOWN, { text: '' }, { Origin: origin })).status).toBe(400)
|
||||
expect((await postQueue(port, UNKNOWN, { text: 42 }, { Origin: origin })).status).toBe(400)
|
||||
expect((await postQueue(port, UNKNOWN, {}, { Origin: origin })).status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects oversized text with 413', async () => {
|
||||
const { port, origin } = await spawnServer({ QUEUE_ITEM_MAX_BYTES: '16' })
|
||||
const res = await postQueue(port, UNKNOWN, { text: 'x'.repeat(17) }, { Origin: origin })
|
||||
expect(res.status).toBe(413)
|
||||
})
|
||||
|
||||
it('returns 404 for a well-formed but unknown session id', async () => {
|
||||
const { port, origin } = await spawnServer()
|
||||
const res = await postQueue(port, UNKNOWN, { text: 'x' }, { Origin: origin })
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 503 when QUEUE_ENABLED=0', async () => {
|
||||
const { port, origin } = await spawnServer({ QUEUE_ENABLED: '0' })
|
||||
const res = await postQueue(port, UNKNOWN, { text: 'x' }, { Origin: origin })
|
||||
expect(res.status).toBe(503)
|
||||
})
|
||||
|
||||
it('rate-limits after QUEUE_RATE_MAX requests with 429', async () => {
|
||||
const { port, origin } = await spawnServer()
|
||||
// 20 allowed (each 400 on empty text — rate is checked before validation),
|
||||
// the 21st exceeds the per-IP window and returns 429.
|
||||
let sawRate = false
|
||||
for (let i = 0; i < 21; i++) {
|
||||
const res = await postQueue(port, UNKNOWN, { text: '' }, { Origin: origin })
|
||||
if (res.status === 429) sawRate = true
|
||||
}
|
||||
expect(sawRate).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET/DELETE /live-sessions/:id/queue — guards', () => {
|
||||
it('GET returns 404 for an unknown session', async () => {
|
||||
const { port } = await spawnServer()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/live-sessions/${UNKNOWN}/queue`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('DELETE rejects a foreign Origin with 403', async () => {
|
||||
const { port } = await spawnServer()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/live-sessions/${UNKNOWN}/queue`, {
|
||||
method: 'DELETE',
|
||||
headers: { Origin: 'http://evil.example' },
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('DELETE returns 404 for an unknown session', async () => {
|
||||
const { port, origin } = await spawnServer()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/live-sessions/${UNKNOWN}/queue`, {
|
||||
method: 'DELETE',
|
||||
headers: { Origin: origin },
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Happy path + drain (real PTY) ─────────────────────────────────────────────
|
||||
|
||||
describe('inject queue — real session', () => {
|
||||
itPty('enqueues, surfaces queueLength, lists items, and clears', async () => {
|
||||
const { port, origin } = await spawnServer()
|
||||
const { ws, sessionId } = await attachSession(port, origin)
|
||||
await waitForQuiet(ws)
|
||||
|
||||
const enq = await postQueue(port, sessionId, { text: 'echo hi', appendEnter: true }, { Origin: origin })
|
||||
expect(enq.status).toBe(200)
|
||||
expect(await enq.json()).toEqual({ length: 1 })
|
||||
|
||||
// /live-sessions surfaces the depth.
|
||||
const live = (await (await fetch(`http://127.0.0.1:${port}/live-sessions`)).json()) as Array<{
|
||||
id: string
|
||||
queueLength?: number
|
||||
}>
|
||||
expect(live.find((s) => s.id === sessionId)?.queueLength).toBe(1)
|
||||
|
||||
// GET .../queue returns the stored (verbatim) items.
|
||||
const view = (await (
|
||||
await fetch(`http://127.0.0.1:${port}/live-sessions/${sessionId}/queue`)
|
||||
).json()) as { length: number; items: string[] }
|
||||
expect(view.length).toBe(1)
|
||||
expect(view.items).toEqual(['echo hi\r'])
|
||||
|
||||
// DELETE clears the queue.
|
||||
const del = await fetch(`http://127.0.0.1:${port}/live-sessions/${sessionId}/queue`, {
|
||||
method: 'DELETE',
|
||||
headers: { Origin: origin },
|
||||
})
|
||||
expect(del.status).toBe(200)
|
||||
const view2 = (await (
|
||||
await fetch(`http://127.0.0.1:${port}/live-sessions/${sessionId}/queue`)
|
||||
).json()) as { length: number }
|
||||
expect(view2.length).toBe(0)
|
||||
|
||||
ws.close()
|
||||
})
|
||||
|
||||
itPty('drains the head into the PTY after a Stop hook (idle-drain)', async () => {
|
||||
const { port, origin } = await spawnServer({ QUEUE_SETTLE_MS: '50' })
|
||||
const { ws, sessionId } = await attachSession(port, origin)
|
||||
await waitForQuiet(ws)
|
||||
|
||||
const enq = await postQueue(
|
||||
port,
|
||||
sessionId,
|
||||
{ text: 'echo QUEUED_MARKER_A', appendEnter: true },
|
||||
{ Origin: origin },
|
||||
)
|
||||
expect(enq.status).toBe(200)
|
||||
|
||||
const marker = waitForMessage(
|
||||
ws,
|
||||
(m) => m['type'] === 'output' && String(m['data']).includes('QUEUED_MARKER_A'),
|
||||
)
|
||||
const stop = await postStop(port, sessionId)
|
||||
expect(stop.status).toBe(204)
|
||||
|
||||
await marker // the shell echoed the injected keystrokes → marker present
|
||||
ws.close()
|
||||
})
|
||||
|
||||
itPty('fires only one entry per idle (Stop) — the next waits for the next Stop', async () => {
|
||||
const { port, origin } = await spawnServer({ QUEUE_SETTLE_MS: '50' })
|
||||
const { ws, sessionId } = await attachSession(port, origin)
|
||||
await waitForQuiet(ws)
|
||||
|
||||
await postQueue(port, sessionId, { text: 'echo MARK_ONE', appendEnter: true }, { Origin: origin })
|
||||
await postQueue(port, sessionId, { text: 'echo MARK_TWO', appendEnter: true }, { Origin: origin })
|
||||
|
||||
// First Stop → only MARK_ONE drains.
|
||||
const one = waitForMessage(ws, (m) => m['type'] === 'output' && String(m['data']).includes('MARK_ONE'))
|
||||
await postStop(port, sessionId)
|
||||
await one
|
||||
|
||||
// Without another Stop, MARK_TWO must NOT be injected.
|
||||
const idleWindow = await collectOutput(ws, 500)
|
||||
expect(idleWindow.includes('MARK_TWO')).toBe(false)
|
||||
|
||||
// A second Stop drains MARK_TWO.
|
||||
await waitForQuiet(ws)
|
||||
const two = waitForMessage(ws, (m) => m['type'] === 'output' && String(m['data']).includes('MARK_TWO'))
|
||||
await postStop(port, sessionId)
|
||||
await two
|
||||
|
||||
ws.close()
|
||||
})
|
||||
|
||||
itPty('settle guard: a non-idle hook during the window cancels the drain', async () => {
|
||||
const { port, origin } = await spawnServer({ QUEUE_SETTLE_MS: '300' })
|
||||
const { ws, sessionId } = await attachSession(port, origin)
|
||||
await waitForQuiet(ws)
|
||||
|
||||
await postQueue(
|
||||
port,
|
||||
sessionId,
|
||||
{ text: 'echo SHOULD_NOT_RUN', appendEnter: true },
|
||||
{ Origin: origin },
|
||||
)
|
||||
|
||||
// Stop schedules a drain at +300ms; a PreToolUse immediately flips the status
|
||||
// back to 'working', so the settle-time idle re-check skips the drain.
|
||||
await postStop(port, sessionId)
|
||||
await fetch(`http://127.0.0.1:${port}/hook`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId },
|
||||
body: JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash' }),
|
||||
})
|
||||
|
||||
const window = await collectOutput(ws, 600) // past the 300ms settle
|
||||
expect(window.includes('SHOULD_NOT_RUN')).toBe(false)
|
||||
|
||||
ws.close()
|
||||
})
|
||||
})
|
||||
@@ -88,6 +88,11 @@ const CFG: Config = {
|
||||
worktreeTimeoutMs: 10_000,
|
||||
defaultPermissionMode: 'default',
|
||||
allowAutoMode: false,
|
||||
// W2 inject queue
|
||||
queueEnabled: true,
|
||||
queueMaxItems: 10,
|
||||
queueItemMaxBytes: 4096,
|
||||
queueSettleMs: 1500,
|
||||
};
|
||||
|
||||
const DIMS: Dims = { cols: 80, rows: 24 };
|
||||
@@ -1145,3 +1150,137 @@ describe('list — lastOutputAt field (T-iOS-37)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── W2: inject-queue methods (enqueueFollowup / drainOne / clearQueue) ────────
|
||||
describe('W2 inject queue — enqueueFollowup', () => {
|
||||
it('appends and returns {ok,length}, broadcasting {type:queue,length} to clients', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
nextPty = createMockPty();
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
ws.sent.length = 0;
|
||||
|
||||
const r1 = mgr.enqueueFollowup(s.meta.id, 'echo one\r');
|
||||
expect(r1).toEqual({ ok: true, length: 1 });
|
||||
expect(s.queue).toEqual(['echo one\r']);
|
||||
expect(parseSent(ws).some((m) => m.type === 'queue' && m.length === 1)).toBe(true);
|
||||
|
||||
const r2 = mgr.enqueueFollowup(s.meta.id, 'echo two\r');
|
||||
expect(r2).toEqual({ ok: true, length: 2 });
|
||||
expect(s.queue).toEqual(['echo one\r', 'echo two\r']);
|
||||
});
|
||||
|
||||
it('caps at queueMaxItems → {ok:false,reason:full}, no broadcast, queue unchanged', () => {
|
||||
const mgr = createSessionManager({ ...CFG, queueMaxItems: 2 });
|
||||
nextPty = createMockPty();
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
mgr.enqueueFollowup(s.meta.id, 'a');
|
||||
mgr.enqueueFollowup(s.meta.id, 'b');
|
||||
ws.sent.length = 0;
|
||||
|
||||
const r = mgr.enqueueFollowup(s.meta.id, 'c');
|
||||
expect(r).toEqual({ ok: false, reason: 'full' });
|
||||
expect(s.queue).toEqual(['a', 'b']); // immutable: unchanged
|
||||
expect(parseSent(ws).some((m) => m.type === 'queue')).toBe(false); // no broadcast
|
||||
});
|
||||
|
||||
it('unknown id → {ok:false,reason:unknown}', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
expect(mgr.enqueueFollowup('11111111-1111-4111-8111-111111111111', 'x')).toEqual({
|
||||
ok: false,
|
||||
reason: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('exited session → {ok:false,reason:exited}', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
nextPty = createMockPty();
|
||||
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
|
||||
s.exitedAt = 2_000;
|
||||
expect(mgr.enqueueFollowup(s.meta.id, 'x')).toEqual({ ok: false, reason: 'exited' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('W2 inject queue — drainOne', () => {
|
||||
it('pops the head, writes it to the PTY, shrinks the queue, and broadcasts depth', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
nextPty = createMockPty();
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
mgr.enqueueFollowup(s.meta.id, 'HEAD\r');
|
||||
mgr.enqueueFollowup(s.meta.id, 'TAIL\r');
|
||||
ws.sent.length = 0;
|
||||
const pty = s.pty as MockIPty;
|
||||
pty.writes.length = 0;
|
||||
|
||||
const drained = mgr.drainOne(s.meta.id);
|
||||
expect(drained).toBe('HEAD\r');
|
||||
expect(pty.writes).toEqual(['HEAD\r']); // byte-identical inject
|
||||
expect(s.queue).toEqual(['TAIL\r']); // length now 1
|
||||
expect(parseSent(ws).some((m) => m.type === 'queue' && m.length === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('empty queue → null, no PTY write', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
nextPty = createMockPty();
|
||||
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
|
||||
const pty = s.pty as MockIPty;
|
||||
pty.writes.length = 0;
|
||||
|
||||
expect(mgr.drainOne(s.meta.id)).toBeNull();
|
||||
expect(pty.writes).toEqual([]);
|
||||
});
|
||||
|
||||
it('exited session with items queued → null (double-guards L4), no write', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
nextPty = createMockPty();
|
||||
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
|
||||
mgr.enqueueFollowup(s.meta.id, 'x');
|
||||
s.exitedAt = 2_000;
|
||||
const pty = s.pty as MockIPty;
|
||||
pty.writes.length = 0;
|
||||
|
||||
expect(mgr.drainOne(s.meta.id)).toBeNull();
|
||||
expect(pty.writes).toEqual([]);
|
||||
});
|
||||
|
||||
it('unknown id → null', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
expect(mgr.drainOne('11111111-1111-4111-8111-111111111111')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('W2 inject queue — clearQueue', () => {
|
||||
it('empties the queue, broadcasts length 0, returns true', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
nextPty = createMockPty();
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
mgr.enqueueFollowup(s.meta.id, 'a');
|
||||
mgr.enqueueFollowup(s.meta.id, 'b');
|
||||
ws.sent.length = 0;
|
||||
|
||||
expect(mgr.clearQueue(s.meta.id)).toBe(true);
|
||||
expect(s.queue).toEqual([]);
|
||||
expect(parseSent(ws).some((m) => m.type === 'queue' && m.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('unknown id → false', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
expect(mgr.clearQueue('11111111-1111-4111-8111-111111111111')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('W2 inject queue — list() surfaces queueLength', () => {
|
||||
it('reports the pending depth per session', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
nextPty = createMockPty();
|
||||
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
|
||||
mgr.enqueueFollowup(s.meta.id, 'a');
|
||||
mgr.enqueueFollowup(s.meta.id, 'b');
|
||||
|
||||
const entry = mgr.list().find((e) => e.id === s.meta.id);
|
||||
expect(entry?.queueLength).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
97
test/queue.test.ts
Normal file
97
test/queue.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* test/queue.test.ts — W2 frontend inject-queue helper (public/queue.ts).
|
||||
*
|
||||
* fetch is mocked; every helper must NEVER throw (mirrors quick-reply's
|
||||
* never-throw discipline). Covers: POST url/body + parsed result, non-2xx →
|
||||
* {ok:false,status}, network reject → {ok:false}, DELETE, and the frame parser.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
|
||||
import { enqueueFollowup, clearQueue, queueLengthFromFrame } from '../public/queue.js'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
// @ts-expect-error — reset the mocked global between tests
|
||||
delete globalThis.fetch
|
||||
})
|
||||
|
||||
function mockFetch(impl: (url: string, init?: RequestInit) => Promise<Response> | Response): void {
|
||||
globalThis.fetch = vi.fn(impl as unknown as typeof fetch)
|
||||
}
|
||||
|
||||
describe('enqueueFollowup', () => {
|
||||
it('POSTs to the session queue URL with a JSON {text,appendEnter} body', async () => {
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = []
|
||||
mockFetch((url, init) => {
|
||||
calls.push({ url, init })
|
||||
return new Response(JSON.stringify({ length: 1 }), { status: 200 })
|
||||
})
|
||||
|
||||
const result = await enqueueFollowup('sess-1', 'run tests', true)
|
||||
|
||||
expect(result).toEqual({ ok: true, length: 1 })
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.url).toBe('/live-sessions/sess-1/queue')
|
||||
expect(calls[0]!.init?.method).toBe('POST')
|
||||
expect(JSON.parse(String(calls[0]!.init?.body))).toEqual({
|
||||
text: 'run tests',
|
||||
appendEnter: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns {ok:false,status} on a non-2xx response and never throws', async () => {
|
||||
mockFetch(() => new Response('nope', { status: 409 }))
|
||||
const result = await enqueueFollowup('sess-1', 'x', false)
|
||||
expect(result).toEqual({ ok: false, status: 409 })
|
||||
})
|
||||
|
||||
it('returns {ok:false} on a network reject and never throws', async () => {
|
||||
mockFetch(() => Promise.reject(new Error('offline')))
|
||||
const result = await enqueueFollowup('sess-1', 'x', false)
|
||||
expect(result).toEqual({ ok: false })
|
||||
})
|
||||
|
||||
it('returns {ok:true} with undefined length when the body omits length', async () => {
|
||||
mockFetch(() => new Response(JSON.stringify({}), { status: 200 }))
|
||||
const result = await enqueueFollowup('sess-1', 'x', false)
|
||||
expect(result).toEqual({ ok: true, length: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearQueue', () => {
|
||||
it('DELETEs the session queue URL and returns true on success', async () => {
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = []
|
||||
mockFetch((url, init) => {
|
||||
calls.push({ url, init })
|
||||
return new Response(JSON.stringify({ length: 0 }), { status: 200 })
|
||||
})
|
||||
|
||||
const ok = await clearQueue('sess-2')
|
||||
expect(ok).toBe(true)
|
||||
expect(calls[0]!.url).toBe('/live-sessions/sess-2/queue')
|
||||
expect(calls[0]!.init?.method).toBe('DELETE')
|
||||
})
|
||||
|
||||
it('returns false on a non-2xx and on a network reject (never throws)', async () => {
|
||||
mockFetch(() => new Response('', { status: 404 }))
|
||||
expect(await clearQueue('sess-2')).toBe(false)
|
||||
|
||||
mockFetch(() => Promise.reject(new Error('offline')))
|
||||
expect(await clearQueue('sess-2')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('queueLengthFromFrame', () => {
|
||||
it('returns the length for a {type:queue,length} frame', () => {
|
||||
expect(queueLengthFromFrame({ type: 'queue', length: 3 })).toBe(3)
|
||||
})
|
||||
|
||||
it('returns null for unrelated or malformed frames', () => {
|
||||
expect(queueLengthFromFrame({ type: 'status', status: 'idle' })).toBeNull()
|
||||
expect(queueLengthFromFrame({ type: 'queue' })).toBeNull()
|
||||
expect(queueLengthFromFrame(null)).toBeNull()
|
||||
expect(queueLengthFromFrame('nope')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -549,4 +549,45 @@ describe('mountQuickReply — DOM', () => {
|
||||
expect(countAfter).toBe(countBefore)
|
||||
handle.dispose()
|
||||
})
|
||||
|
||||
// ── W2: the editor's "Queue" button (only shown when onQueue is wired) ────────
|
||||
it('shows no Queue button when onQueue is not provided', () => {
|
||||
const container = document.createElement('div')
|
||||
const handle = mountQuickReply(container, { onSend: vi.fn() })
|
||||
;(container.querySelector('.qr-add-btn') as HTMLButtonElement).click()
|
||||
expect(container.querySelector('.qr-editor-queue')).toBeNull()
|
||||
handle.dispose()
|
||||
})
|
||||
|
||||
it('Queue button calls onQueue with the typed text + appendEnter and closes the editor', () => {
|
||||
const container = document.createElement('div')
|
||||
const onQueue = vi.fn()
|
||||
const handle = mountQuickReply(container, { onSend: vi.fn(), onQueue })
|
||||
;(container.querySelector('.qr-add-btn') as HTMLButtonElement).click()
|
||||
|
||||
const textInput = container.querySelector('.qr-editor-text') as HTMLInputElement
|
||||
textInput.value = 'run tests then open a PR'
|
||||
const enterCheck = container.querySelector('.qr-editor-enter') as HTMLInputElement
|
||||
enterCheck.checked = true
|
||||
|
||||
;(container.querySelector('.qr-editor-queue') as HTMLButtonElement).click()
|
||||
|
||||
expect(onQueue).toHaveBeenCalledWith('run tests then open a PR', true)
|
||||
// Enqueue must NOT persist a chip nor send immediately.
|
||||
expect(loadPalette().some((c) => c.text === 'run tests then open a PR')).toBe(false)
|
||||
expect(container.querySelector('.qr-editor')).toBeNull()
|
||||
handle.dispose()
|
||||
})
|
||||
|
||||
it('Queue button with empty text is a no-op (onQueue not called, editor stays)', () => {
|
||||
const container = document.createElement('div')
|
||||
const onQueue = vi.fn()
|
||||
const handle = mountQuickReply(container, { onSend: vi.fn(), onQueue })
|
||||
;(container.querySelector('.qr-add-btn') as HTMLButtonElement).click()
|
||||
;(container.querySelector('.qr-editor-queue') as HTMLButtonElement).click()
|
||||
|
||||
expect(onQueue).not.toHaveBeenCalled()
|
||||
expect(container.querySelector('.qr-editor')).not.toBeNull()
|
||||
handle.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,8 @@ class FakeTerminalSession {
|
||||
pendingEpoch = 0
|
||||
/** B2: latest statusLine telemetry (single source of truth). */
|
||||
telemetry: unknown = null
|
||||
/** W2: pending inject-queue depth (tests set it before driving onQueue). */
|
||||
queueLength = 0
|
||||
/** Captured so tests can assert initialInput ends with \r (Finding 1). */
|
||||
initialInput: string | undefined = undefined
|
||||
connect = vi.fn()
|
||||
@@ -50,6 +52,7 @@ class FakeTerminalSession {
|
||||
onActivity?: () => void
|
||||
onTitle?: (t: string) => void
|
||||
onTelemetry?: (t: unknown) => void
|
||||
onQueue?: (n: number) => void
|
||||
}
|
||||
static instances: FakeTerminalSession[] = []
|
||||
constructor(opts: {
|
||||
@@ -60,6 +63,7 @@ class FakeTerminalSession {
|
||||
onActivity?: () => void
|
||||
onTitle?: (t: string) => void
|
||||
onTelemetry?: (t: unknown) => void
|
||||
onQueue?: (n: number) => void
|
||||
[key: string]: unknown
|
||||
}) {
|
||||
constructed += 1
|
||||
@@ -89,13 +93,28 @@ vi.mock('../public/cell-monitor.js', () => ({ mountCellMonitor }))
|
||||
const mountPushToggle = vi.fn()
|
||||
vi.mock('../public/push.js', () => ({ mountPushToggle }))
|
||||
|
||||
const quickReply: { onSend?: (d: string) => void } = {}
|
||||
const mountQuickReply = vi.fn((_c: HTMLElement, opts: { onSend: (d: string) => void }) => {
|
||||
quickReply.onSend = opts.onSend
|
||||
return { dispose: vi.fn() }
|
||||
})
|
||||
const quickReply: {
|
||||
onSend?: (d: string) => void
|
||||
onQueue?: (text: string, appendEnter: boolean) => void
|
||||
} = {}
|
||||
const mountQuickReply = vi.fn(
|
||||
(
|
||||
_c: HTMLElement,
|
||||
opts: { onSend: (d: string) => void; onQueue?: (text: string, appendEnter: boolean) => void },
|
||||
) => {
|
||||
quickReply.onSend = opts.onSend
|
||||
quickReply.onQueue = opts.onQueue
|
||||
return { dispose: vi.fn() }
|
||||
},
|
||||
)
|
||||
vi.mock('../public/quick-reply.js', () => ({ mountQuickReply }))
|
||||
|
||||
// ── Mock the W2 queue helper (assert enqueue routing without real fetch) ──────
|
||||
const enqueueFollowupMock = vi.fn(async () => ({ ok: true, length: 1 }))
|
||||
vi.mock('../public/queue.js', () => ({
|
||||
enqueueFollowup: (...args: unknown[]) => enqueueFollowupMock(...(args as [])),
|
||||
}))
|
||||
|
||||
const timelineDispose = vi.fn()
|
||||
const mountTimeline = vi.fn(() => ({ dispose: timelineDispose }))
|
||||
vi.mock('../public/timeline.js', () => ({ mountTimeline }))
|
||||
@@ -179,6 +198,8 @@ beforeEach(() => {
|
||||
voiceStart.mockClear()
|
||||
voiceStop.mockClear()
|
||||
quickReply.onSend = undefined
|
||||
quickReply.onQueue = undefined
|
||||
enqueueFollowupMock.mockClear()
|
||||
voice.onTranscript = undefined
|
||||
voice.onInterim = undefined
|
||||
// Default: server forbids 'auto' (loadUiConfig resolves to allowAutoMode=false).
|
||||
@@ -1636,3 +1657,64 @@ describe('TabApp — split-grid watch board (v1)', () => {
|
||||
expect(mountCellMonitor.mock.lastCall?.[1]).toBe('sess-1')
|
||||
})
|
||||
})
|
||||
|
||||
// ── W2: inject-queue badge + enqueue affordance ──────────────────────────────
|
||||
describe('TabApp — W2 inject queue', () => {
|
||||
it('a queue frame (onQueue) updates the active tab’s ⧗N badge in place', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
const inst = FakeTerminalSession.instances[0]!
|
||||
|
||||
// No queued items → empty badge.
|
||||
expect((tabBar.querySelector('.tab-queue') as HTMLElement).textContent).toBe('')
|
||||
|
||||
// Server broadcasts depth 3 → badge shows ⧗3.
|
||||
inst.queueLength = 3
|
||||
inst.cbs.onQueue?.(3)
|
||||
expect((tabBar.querySelector('.tab-queue') as HTMLElement).textContent).toBe('⧗3')
|
||||
|
||||
// Drains back to 0 → badge clears.
|
||||
inst.queueLength = 0
|
||||
inst.cbs.onQueue?.(0)
|
||||
expect((tabBar.querySelector('.tab-queue') as HTMLElement).textContent).toBe('')
|
||||
})
|
||||
|
||||
it('enqueueToActive routes to enqueueFollowup with the active session id', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.openSession('11111111-1111-4111-8111-111111111111')
|
||||
|
||||
app.enqueueToActive('run tests', true)
|
||||
|
||||
expect(enqueueFollowupMock).toHaveBeenCalledWith(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
'run tests',
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('the quick-reply Queue affordance enqueues for the active session', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.openSession('22222222-2222-4222-8222-222222222222')
|
||||
|
||||
// The onQueue handler wired into mountQuickReply routes through enqueueToActive.
|
||||
quickReply.onQueue?.('open a PR', true)
|
||||
|
||||
expect(enqueueFollowupMock).toHaveBeenCalledWith(
|
||||
'22222222-2222-4222-8222-222222222222',
|
||||
'open a PR',
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('enqueueToActive is a no-op when no session has attached (id null)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab() // fresh session → id null until server assigns
|
||||
|
||||
app.enqueueToActive('x', false)
|
||||
expect(enqueueFollowupMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user