Files
web-terminal/test/quick-reply.test.ts
Yaojia Wang d6809c65c4 feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.

Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.

New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).

Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).

Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
2026-06-30 17:42:18 +02:00

553 lines
20 KiB
TypeScript

// @vitest-environment jsdom
/**
* test/quick-reply.test.ts — N-quickreply: quick-reply chips + saved-prompt palette.
*
* Feature: A3 (Walk-away Workbench)
* Owns: public/quick-reply.ts
*
* Acceptance criteria (AC-A3.x):
* AC-A3.1 Built-in chips present and send correct bytes (including \r).
* AC-A3.2 User palette CRUD is immutable; round-trip persists via localStorage.
* AC-A3.3 Chip click sends via the onSend path (same text + optional \r).
* AC-A3.4 Labels with special characters rendered as inert text (textContent),
* never injected as HTML (SEC-L3).
*
* Coverage target: ≥80% of public/quick-reply.ts
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ── import module under test ──────────────────────────────────────────────────
const mod = await import('../public/quick-reply.js')
const {
BUILT_IN_CHIPS,
addChip,
removeChip,
reorderChip,
updateChip,
loadPalette,
savePalette,
mountQuickReply,
PALETTE_KEY,
} = mod
// ── helpers ───────────────────────────────────────────────────────────────────
function makeChip(over: Partial<import('../public/quick-reply.js').Chip> = {}): import('../public/quick-reply.js').Chip {
return {
id: 'test-id',
text: 'hello',
label: 'hello',
appendEnter: true,
...over,
}
}
beforeEach(() => {
localStorage.clear()
vi.restoreAllMocks()
})
// ─────────────────────────── BUILT_IN_CHIPS ──────────────────────────────────
describe('BUILT_IN_CHIPS', () => {
it('exports exactly 6 built-in chips', () => {
expect(BUILT_IN_CHIPS).toHaveLength(6)
})
it('contains yes, continue, 1, 2, 3, Esc (in that order)', () => {
const labels = BUILT_IN_CHIPS.map((c) => c.label)
expect(labels).toContain('yes')
expect(labels).toContain('continue')
expect(labels).toContain('1')
expect(labels).toContain('2')
expect(labels).toContain('3')
expect(labels).toContain('Esc')
})
it('yes chip has appendEnter=true and text "yes"', () => {
const yes = BUILT_IN_CHIPS.find((c) => c.label === 'yes')
expect(yes).toBeDefined()
expect(yes!.appendEnter).toBe(true)
expect(yes!.text).toBe('yes')
})
it('continue chip has appendEnter=true and text "continue"', () => {
const cont = BUILT_IN_CHIPS.find((c) => c.label === 'continue')
expect(cont).toBeDefined()
expect(cont!.appendEnter).toBe(true)
expect(cont!.text).toBe('continue')
})
it('1/2/3 chips have appendEnter=true', () => {
for (const label of ['1', '2', '3']) {
const chip = BUILT_IN_CHIPS.find((c) => c.label === label)
expect(chip).toBeDefined()
expect(chip!.appendEnter).toBe(true)
}
})
it('Esc chip has appendEnter=false and text \\x1b', () => {
const esc = BUILT_IN_CHIPS.find((c) => c.label === 'Esc')
expect(esc).toBeDefined()
expect(esc!.appendEnter).toBe(false)
expect(esc!.text).toBe('\x1b')
expect(esc!.text.charCodeAt(0)).toBe(0x1b)
})
it('all built-in chips have unique ids', () => {
const ids = BUILT_IN_CHIPS.map((c) => c.id)
expect(new Set(ids).size).toBe(6)
})
it('is frozen / immutable (modification attempt does not affect original)', () => {
// Reading from a frozen array should work; appending returns new array
const extended = [...BUILT_IN_CHIPS, makeChip({ id: 'extra' })]
expect(extended).toHaveLength(7)
expect(BUILT_IN_CHIPS).toHaveLength(6)
})
})
// ─────────────────────────── addChip ─────────────────────────────────────────
describe('addChip', () => {
it('returns a new array with the chip appended', () => {
const chips = [makeChip({ id: 'a' })]
const newChip = makeChip({ id: 'b' })
const result = addChip(chips, newChip)
expect(result).toHaveLength(2)
expect(result[1]).toBe(newChip)
})
it('does NOT mutate the original array', () => {
const chips = [makeChip({ id: 'a' })]
const original = [...chips]
addChip(chips, makeChip({ id: 'b' }))
expect(chips).toHaveLength(original.length)
expect(chips[0]).toBe(original[0])
})
it('works on an empty array', () => {
const chip = makeChip({ id: 'first' })
const result = addChip([], chip)
expect(result).toHaveLength(1)
expect(result[0]).toBe(chip)
})
it('returns a different array reference', () => {
const chips = [makeChip()]
const result = addChip(chips, makeChip({ id: 'x' }))
expect(result).not.toBe(chips)
})
})
// ─────────────────────────── removeChip ──────────────────────────────────────
describe('removeChip', () => {
it('removes the chip with the matching id', () => {
const a = makeChip({ id: 'a' })
const b = makeChip({ id: 'b' })
const c = makeChip({ id: 'c' })
const result = removeChip([a, b, c], 'b')
expect(result).toHaveLength(2)
expect(result.find((ch) => ch.id === 'b')).toBeUndefined()
})
it('does NOT mutate the original array', () => {
const chips = [makeChip({ id: 'a' }), makeChip({ id: 'b' })]
removeChip(chips, 'a')
expect(chips).toHaveLength(2)
})
it('returns a different array reference', () => {
const chips = [makeChip({ id: 'a' })]
expect(removeChip(chips, 'a')).not.toBe(chips)
})
it('no-op when id not found — length unchanged', () => {
const chips = [makeChip({ id: 'a' })]
const result = removeChip(chips, 'nonexistent')
expect(result).toHaveLength(1)
})
it('removes only the matched id when duplicates do not exist', () => {
const a = makeChip({ id: 'a', label: 'A' })
const b = makeChip({ id: 'b', label: 'B' })
const result = removeChip([a, b], 'a')
expect(result).toHaveLength(1)
expect(result[0]!.label).toBe('B')
})
})
// ─────────────────────────── reorderChip ─────────────────────────────────────
describe('reorderChip', () => {
it('moves a chip from fromIndex to toIndex', () => {
const a = makeChip({ id: 'a' })
const b = makeChip({ id: 'b' })
const c = makeChip({ id: 'c' })
const result = reorderChip([a, b, c], 0, 2)
expect(result.map((ch) => ch.id)).toEqual(['b', 'c', 'a'])
})
it('does NOT mutate the original array', () => {
const chips = [makeChip({ id: 'a' }), makeChip({ id: 'b' })]
reorderChip(chips, 0, 1)
expect(chips[0]!.id).toBe('a')
})
it('returns a different array reference', () => {
const chips = [makeChip({ id: 'a' }), makeChip({ id: 'b' })]
expect(reorderChip(chips, 0, 1)).not.toBe(chips)
})
it('moves to the same position (no-op reorder)', () => {
const a = makeChip({ id: 'a' })
const b = makeChip({ id: 'b' })
const result = reorderChip([a, b], 0, 0)
expect(result.map((ch) => ch.id)).toEqual(['a', 'b'])
})
it('handles out-of-bounds fromIndex gracefully', () => {
const chips = [makeChip({ id: 'a' })]
const result = reorderChip(chips, 99, 0)
expect(result).toHaveLength(1)
})
it('handles out-of-bounds toIndex gracefully', () => {
const chips = [makeChip({ id: 'a' })]
const result = reorderChip(chips, 0, 99)
expect(result).toHaveLength(1)
})
})
// ─────────────────────────── updateChip ──────────────────────────────────────
describe('updateChip', () => {
it('updates the chip with matching id (partial patch)', () => {
const chip = makeChip({ id: 'x', label: 'before' })
const result = updateChip([chip], 'x', { label: 'after' })
expect(result[0]!.label).toBe('after')
expect(result[0]!.id).toBe('x') // id preserved
})
it('does NOT mutate the original chip object', () => {
const chip = makeChip({ id: 'x', label: 'original' })
updateChip([chip], 'x', { label: 'changed' })
expect(chip.label).toBe('original')
})
it('does NOT mutate the original array', () => {
const chips = [makeChip({ id: 'x' })]
updateChip(chips, 'x', { label: 'new' })
expect(chips).toHaveLength(1)
})
it('returns a different array reference', () => {
const chips = [makeChip({ id: 'x' })]
expect(updateChip(chips, 'x', {})).not.toBe(chips)
})
it('leaves non-matching chips unchanged', () => {
const a = makeChip({ id: 'a', label: 'A' })
const b = makeChip({ id: 'b', label: 'B' })
const result = updateChip([a, b], 'a', { label: 'A2' })
expect(result[1]!.label).toBe('B')
expect(result[1]).toBe(b)
})
it('no-op when id not found', () => {
const chip = makeChip({ id: 'x', label: 'X' })
const result = updateChip([chip], 'nonexistent', { label: 'changed' })
expect(result[0]!.label).toBe('X')
})
it('can update appendEnter', () => {
const chip = makeChip({ id: 'x', appendEnter: true })
const result = updateChip([chip], 'x', { appendEnter: false })
expect(result[0]!.appendEnter).toBe(false)
})
})
// ─────────────────────────── loadPalette ─────────────────────────────────────
describe('loadPalette', () => {
it('returns empty array when localStorage has no key', () => {
expect(loadPalette()).toEqual([])
})
it('returns the stored chips on valid JSON', () => {
const chips = [makeChip({ id: 'p1', text: 'run tests', label: 'run tests', appendEnter: true })]
localStorage.setItem(PALETTE_KEY, JSON.stringify(chips))
const result = loadPalette()
expect(result).toHaveLength(1)
expect(result[0]!.id).toBe('p1')
expect(result[0]!.text).toBe('run tests')
})
it('does NOT throw on bad JSON', () => {
localStorage.setItem(PALETTE_KEY, 'not-valid-json{{{')
expect(() => loadPalette()).not.toThrow()
expect(loadPalette()).toEqual([])
})
it('does NOT throw when localStorage contains a non-array JSON value', () => {
localStorage.setItem(PALETTE_KEY, JSON.stringify({ foo: 'bar' }))
expect(() => loadPalette()).not.toThrow()
expect(loadPalette()).toEqual([])
})
it('returns empty array when stored data has items with missing fields', () => {
const bad = [{ id: 'x' }] // missing text/label/appendEnter
localStorage.setItem(PALETTE_KEY, JSON.stringify(bad))
expect(() => loadPalette()).not.toThrow()
// Either returns [] or filters invalid items
const result = loadPalette()
expect(Array.isArray(result)).toBe(true)
const valid = result.filter((c) => typeof c.id === 'string' && typeof c.text === 'string')
expect(valid.length).toBe(0)
})
it('returns empty array when stored data is null JSON', () => {
localStorage.setItem(PALETTE_KEY, 'null')
expect(loadPalette()).toEqual([])
})
it('does NOT throw when localStorage throws (simulated private mode)', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('Storage blocked in private mode')
})
expect(() => loadPalette()).not.toThrow()
expect(loadPalette()).toEqual([])
})
})
// ─────────────────────────── savePalette ─────────────────────────────────────
describe('savePalette', () => {
it('saves chips to localStorage', () => {
const chips = [makeChip({ id: 'saved' })]
savePalette(chips)
const raw = localStorage.getItem(PALETTE_KEY)
expect(raw).toBeTruthy()
const parsed = JSON.parse(raw!)
expect(parsed).toHaveLength(1)
expect(parsed[0].id).toBe('saved')
})
it('saves an empty array without throwing', () => {
expect(() => savePalette([])).not.toThrow()
const raw = localStorage.getItem(PALETTE_KEY)
expect(JSON.parse(raw!)).toEqual([])
})
it('does NOT throw when localStorage is unavailable', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('Storage quota exceeded')
})
expect(() => savePalette([makeChip()])).not.toThrow()
})
})
// ─────────────────────────── round-trip persistence ──────────────────────────
describe('savePalette + loadPalette — round-trip', () => {
it('persists and restores a palette with multiple chips', () => {
const chips = [
makeChip({ id: 'a', text: 'run tests', label: 'run tests', appendEnter: true }),
makeChip({ id: 'b', text: '/clear', label: '/clear', appendEnter: false }),
]
savePalette(chips)
const loaded = loadPalette()
expect(loaded).toHaveLength(2)
expect(loaded[0]!.text).toBe('run tests')
expect(loaded[1]!.text).toBe('/clear')
expect(loaded[1]!.appendEnter).toBe(false)
})
it('persists appendEnter=false correctly', () => {
savePalette([makeChip({ id: 'x', appendEnter: false })])
const loaded = loadPalette()
expect(loaded[0]!.appendEnter).toBe(false)
})
})
// ─────────────────────────── mountQuickReply ─────────────────────────────────
describe('mountQuickReply — DOM', () => {
it('renders built-in chip buttons inside the container', () => {
const container = document.createElement('div')
const onSend = vi.fn()
const handle = mountQuickReply(container, { onSend })
const buttons = container.querySelectorAll('button.qr-chip')
// All 6 built-in chips should be rendered
expect(buttons.length).toBeGreaterThanOrEqual(6)
handle.dispose()
})
it('clicking a built-in chip with appendEnter=true sends text + \\r', () => {
const container = document.createElement('div')
const onSend = vi.fn()
const handle = mountQuickReply(container, { onSend })
// Find the 'yes' chip button
const yesBtn = Array.from(container.querySelectorAll('button.qr-chip')).find(
(b) => b.textContent === 'yes',
)
expect(yesBtn).toBeDefined()
;(yesBtn as HTMLButtonElement).click()
expect(onSend).toHaveBeenCalledWith('yes\r')
handle.dispose()
})
it('clicking the Esc chip (appendEnter=false) sends \\x1b without \\r', () => {
const container = document.createElement('div')
const onSend = vi.fn()
const handle = mountQuickReply(container, { onSend })
const escBtn = Array.from(container.querySelectorAll('button.qr-chip')).find(
(b) => b.textContent === 'Esc',
)
expect(escBtn).toBeDefined()
;(escBtn as HTMLButtonElement).click()
expect(onSend).toHaveBeenCalledWith('\x1b')
expect(onSend).not.toHaveBeenCalledWith('\x1b\r')
handle.dispose()
})
it('user palette chips are also rendered', () => {
localStorage.setItem(
PALETTE_KEY,
JSON.stringify([makeChip({ id: 'u1', label: 'run tests', text: 'run tests', appendEnter: true })]),
)
const container = document.createElement('div')
const onSend = vi.fn()
const handle = mountQuickReply(container, { onSend })
const buttons = Array.from(container.querySelectorAll('button.qr-chip'))
const found = buttons.find((b) => b.textContent === 'run tests')
expect(found).toBeDefined()
handle.dispose()
})
it('user palette chip click sends correct text', () => {
localStorage.setItem(
PALETTE_KEY,
JSON.stringify([makeChip({ id: 'u1', label: 'commit', text: 'git commit -m', appendEnter: false })]),
)
const container = document.createElement('div')
const onSend = vi.fn()
const handle = mountQuickReply(container, { onSend })
const commitBtn = Array.from(container.querySelectorAll('button.qr-chip')).find(
(b) => b.textContent === 'commit',
)
;(commitBtn as HTMLButtonElement).click()
expect(onSend).toHaveBeenCalledWith('git commit -m')
handle.dispose()
})
it('chip labels with <script> appear as plain text — not injected as HTML (AC-A3.4 / SEC-L3)', () => {
localStorage.setItem(
PALETTE_KEY,
JSON.stringify([
makeChip({ id: 'xss', label: '<script>alert(1)</script>', text: 'safe', appendEnter: false }),
]),
)
const container = document.createElement('div')
const onSend = vi.fn()
const handle = mountQuickReply(container, { onSend })
// No <script> element should be injected into the DOM
expect(container.querySelectorAll('script').length).toBe(0)
// The label text should appear as inert text
const xssBtn = Array.from(container.querySelectorAll('button.qr-chip')).find(
(b) => b.textContent === '<script>alert(1)</script>',
)
expect(xssBtn).toBeDefined()
handle.dispose()
})
it('contains a + button to add a new snippet', () => {
const container = document.createElement('div')
const onSend = vi.fn()
const handle = mountQuickReply(container, { onSend })
const addBtn = container.querySelector('.qr-add-btn')
expect(addBtn).not.toBeNull()
handle.dispose()
})
it('dispose removes chip buttons from the container', () => {
const container = document.createElement('div')
const handle = mountQuickReply(container, { onSend: vi.fn() })
expect(container.children.length).toBeGreaterThan(0)
handle.dispose()
expect(container.children.length).toBe(0)
})
it('inline editor appears when + is clicked', () => {
const container = document.createElement('div')
const handle = mountQuickReply(container, { onSend: vi.fn() })
const addBtn = container.querySelector('.qr-add-btn') as HTMLButtonElement
addBtn.click()
expect(container.querySelector('.qr-editor')).not.toBeNull()
handle.dispose()
})
it('inline editor cancel button removes the editor', () => {
const container = document.createElement('div')
const handle = mountQuickReply(container, { onSend: vi.fn() })
const addBtn = container.querySelector('.qr-add-btn') as HTMLButtonElement
addBtn.click()
const cancelBtn = container.querySelector('.qr-editor-cancel') as HTMLButtonElement
cancelBtn.click()
expect(container.querySelector('.qr-editor')).toBeNull()
handle.dispose()
})
it('saving a new chip from editor persists it to localStorage and re-renders', () => {
const container = document.createElement('div')
const handle = mountQuickReply(container, { onSend: vi.fn() })
const addBtn = container.querySelector('.qr-add-btn') as HTMLButtonElement
addBtn.click()
const textInput = container.querySelector('.qr-editor-text') as HTMLInputElement
textInput.value = 'write tests'
const labelInput = container.querySelector('.qr-editor-label') as HTMLInputElement
labelInput.value = 'write tests'
const saveBtn = container.querySelector('.qr-editor-save') as HTMLButtonElement
saveBtn.click()
// Editor should be gone
expect(container.querySelector('.qr-editor')).toBeNull()
// New chip should be in localStorage
const stored = loadPalette()
expect(stored.some((c) => c.text === 'write tests')).toBe(true)
// New chip button should appear
const newBtn = Array.from(container.querySelectorAll('button.qr-chip')).find(
(b) => b.textContent === 'write tests',
)
expect(newBtn).toBeDefined()
handle.dispose()
})
it('editor save with empty text is a no-op', () => {
const container = document.createElement('div')
const handle = mountQuickReply(container, { onSend: vi.fn() })
const countBefore = loadPalette().length
const addBtn = container.querySelector('.qr-add-btn') as HTMLButtonElement
addBtn.click()
// Leave text empty
const saveBtn = container.querySelector('.qr-editor-save') as HTMLButtonElement
saveBtn.click()
// Editor stays open (no text to save)
const countAfter = loadPalette().length
expect(countAfter).toBe(countBefore)
handle.dispose()
})
})