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.
553 lines
20 KiB
TypeScript
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()
|
|
})
|
|
})
|