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).
98 lines
3.6 KiB
TypeScript
98 lines
3.6 KiB
TypeScript
// @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()
|
|
})
|
|
})
|