// @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 { 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 ', text: 'safe', appendEnter: false }), ]), ) const container = document.createElement('div') const onSend = vi.fn() const handle = mountQuickReply(container, { onSend }) // No ', ) 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() }) // ── 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() }) })