Files
hapi/web/src/components/AssistantChat/ScratchlistPanel.test.tsx
T
KorenKritaandGitHub fb3988a81f fix(web): restore failed sends atomically (#1326)
* fix(web): restore failed sends atomically

* fix(web): wait for composer draft hydration

* fix(web): count restored attachments after success

* fix(web): keep scratchlist copy available

* fix(web): move suppressed retry errors to target session
2026-08-03 09:25:01 +08:00

424 lines
18 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { I18nProvider } from '@/lib/i18n-context'
import {
persistScratchlist,
readScratchlist,
type ScratchlistEntry,
} from '@/lib/scratchlist'
import { ScratchlistPanel } from './ScratchlistPanel'
const SID = 'session-test'
function renderPanel(props?: {
onPromoteToComposer?: (text: string) => void
onPromoteToQueue?: (text: string) => Promise<boolean>
sessionId?: string
}) {
const onPromoteToComposer = props?.onPromoteToComposer ?? vi.fn()
const onPromoteToQueue = props?.onPromoteToQueue ?? vi.fn(async () => true)
return {
onPromoteToComposer,
onPromoteToQueue,
...render(
<I18nProvider>
<ScratchlistPanel
sessionId={props?.sessionId ?? SID}
onPromoteToComposer={onPromoteToComposer}
onPromoteToQueue={onPromoteToQueue}
/>
</I18nProvider>
),
}
}
function makeEntry(overrides: Partial<ScratchlistEntry> & { id: string }): ScratchlistEntry {
return {
text: 'note',
createdAt: 1000,
...overrides,
}
}
function expandPanel(): void {
fireEvent.click(screen.getByRole('button', { name: /Scratchlist/ }))
}
afterEach(() => {
cleanup()
})
beforeEach(() => {
localStorage.clear()
})
describe('ScratchlistPanel', () => {
it('renders the held / not-sent label so users distinguish it from the queue', () => {
renderPanel()
// The held-label is rendered inside the toggle button as visual chrome
// (aria-hidden) so use textContent rather than a name match.
const toggle = screen.getByRole('button', { name: /Scratchlist/ })
expect(toggle.textContent).toContain('held')
})
it('uses the chat user surface for the panel background and keeps a subtle amber border (regression guard for #812)', () => {
// The amber chrome was too loud as an always-visible scroll element
// (#812). The fix swaps the warning *fill* for the chat-user-surface
// tone but keeps the warning *border* as a soft accent so the panel
// still reads as a different destination from a normal user message.
// The strong amber destination signal lives on the composer Send
// button, not here. See PR 827 (swear01) for the styling note this
// test guards.
renderPanel()
const panel = screen.getByTestId('scratchlist-panel')
expect(panel.className).toContain('bg-[var(--app-chat-user-surface-bg)]')
expect(panel.className).not.toContain('bg-[var(--app-badge-warning-bg)]')
expect(panel.className).toContain('border-[var(--app-badge-warning-border)]')
})
it('starts collapsed by default; clicking the header expands it', () => {
renderPanel()
const toggle = screen.getByRole('button', { name: /Scratchlist/ })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expandPanel()
expect(toggle.getAttribute('aria-expanded')).toBe('true')
})
it('marks the inner content `inert` while collapsed so hidden controls are not focusable', () => {
// Regression guard: upstream PR review flagged that under the
// CSS-only collapse the textarea + action buttons were still in
// the focus / a11y tree. The fix is `inert` on the inner; this
// test fails if anyone reverts that.
const { container } = renderPanel()
const inner = container.querySelector('.collapsible-inner')
expect(inner).not.toBeNull()
expect(inner!.hasAttribute('inert')).toBe(true)
expandPanel()
// jsdom doesn't always reflect the React `inert={false}` prop as
// an attribute removal — accept either "absent" or empty string,
// which both indicate non-inert per the HTML spec.
const value = inner!.getAttribute('inert')
expect(value === null || value === 'false' || value === '').toBe(true)
})
it('hydrates entries that were persisted before mount', () => {
persistScratchlist(SID, [
makeEntry({ id: 'persisted-1', text: 'persisted note' }),
])
renderPanel()
expandPanel()
expect(screen.getByText('persisted note')).toBeTruthy()
})
it('adds a new entry via the add button and persists it', () => {
renderPanel()
expandPanel()
const input = screen.getByLabelText('Add scratchlist entry') as HTMLTextAreaElement
fireEvent.change(input, { target: { value: 'first thought' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(screen.getByText('first thought')).toBeTruthy()
const stored = readScratchlist(SID)
expect(stored.map((e) => e.text)).toEqual(['first thought'])
})
it('adds a new entry on Enter; Shift+Enter does not add (preserves newline)', () => {
renderPanel()
expandPanel()
const input = screen.getByLabelText('Add scratchlist entry') as HTMLTextAreaElement
fireEvent.change(input, { target: { value: 'enter add' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(screen.getByText('enter add')).toBeTruthy()
expect(readScratchlist(SID).map((e) => e.text)).toEqual(['enter add'])
// Shift+Enter must not promote to a new entry (it falls through to
// textarea default newline behavior); the stored list stays unchanged.
fireEvent.change(input, { target: { value: 'with newline' } })
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true })
expect(readScratchlist(SID).map((e) => e.text)).toEqual(['enter add'])
})
it('deletes an entry without a confirm prompt for short entries', () => {
persistScratchlist(SID, [makeEntry({ id: 'a', text: 'short' })])
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
renderPanel()
expandPanel()
fireEvent.click(screen.getByRole('button', { name: 'Delete entry' }))
expect(confirmSpy).not.toHaveBeenCalled()
expect(screen.queryByText('short')).toBeNull()
expect(readScratchlist(SID)).toEqual([])
confirmSpy.mockRestore()
})
it('asks for confirmation before deleting long entries (>100 chars)', () => {
const longText = 'x'.repeat(150)
persistScratchlist(SID, [makeEntry({ id: 'a', text: longText })])
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
renderPanel()
expandPanel()
fireEvent.click(screen.getByRole('button', { name: 'Delete entry' }))
expect(confirmSpy).toHaveBeenCalled()
// Confirm rejected — entry stays.
expect(readScratchlist(SID).map((e) => e.id)).toEqual(['a'])
confirmSpy.mockReturnValue(true)
fireEvent.click(screen.getByRole('button', { name: 'Delete entry' }))
expect(readScratchlist(SID)).toEqual([])
confirmSpy.mockRestore()
})
it('reorders entries via the up / down arrow buttons', () => {
persistScratchlist(SID, [
makeEntry({ id: 'top', text: 'top entry' }),
makeEntry({ id: 'bot', text: 'bot entry' }),
])
renderPanel()
expandPanel()
// First entry is at index 0 — its up-button should be disabled.
const upButtons = screen.getAllByRole('button', { name: 'Move entry up' })
const downButtons = screen.getAllByRole('button', { name: 'Move entry down' })
expect(upButtons[0]?.hasAttribute('disabled')).toBe(true)
expect(downButtons[downButtons.length - 1]?.hasAttribute('disabled')).toBe(true)
// Move bottom row up -> swaps order.
fireEvent.click(upButtons[1] as HTMLButtonElement)
const stored = readScratchlist(SID)
expect(stored.map((e) => e.id)).toEqual(['bot', 'top'])
})
it('promote-to-composer copies text via the callback and keeps the entry', () => {
persistScratchlist(SID, [makeEntry({ id: 'a', text: 'compose me' })])
const onPromoteToComposer = vi.fn()
renderPanel({ onPromoteToComposer })
expandPanel()
fireEvent.click(screen.getByRole('button', { name: 'Copy into composer' }))
expect(onPromoteToComposer).toHaveBeenCalledWith('compose me')
// Entry remains: promote-to-composer is a copy, not a move.
expect(readScratchlist(SID).map((e) => e.id)).toEqual(['a'])
})
it('promote-to-queue calls onSend and removes the entry on accepted send', async () => {
persistScratchlist(SID, [makeEntry({ id: 'a', text: 'queue me' })])
const onPromoteToQueue = vi.fn(async () => true)
renderPanel({ onPromoteToQueue })
expandPanel()
fireEvent.click(screen.getByRole('button', { name: 'Send to queue' }))
await waitFor(() => expect(onPromoteToQueue).toHaveBeenCalledWith('queue me'))
await waitFor(() => expect(screen.queryByText('queue me')).toBeNull())
expect(readScratchlist(SID)).toEqual([])
})
it('promote-to-queue keeps the entry when the send is rejected', async () => {
persistScratchlist(SID, [makeEntry({ id: 'a', text: 'queue me' })])
const onPromoteToQueue = vi.fn(async () => false)
renderPanel({ onPromoteToQueue })
expandPanel()
fireEvent.click(screen.getByRole('button', { name: 'Send to queue' }))
await waitFor(() => expect(onPromoteToQueue).toHaveBeenCalledWith('queue me'))
// Entry remains because the queue rejected the promotion.
expect(screen.getByText('queue me')).toBeTruthy()
expect(readScratchlist(SID).map((e) => e.id)).toEqual(['a'])
})
it('copy button writes the entry text to clipboard and shows briefly the "Copied" tooltip', async () => {
// Clipboard API isn't implemented in jsdom; install a mock that
// captures the writeText call. (web/src/lib/clipboard.ts already
// tries navigator.clipboard first, then falls back to execCommand.)
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
})
persistScratchlist(SID, [makeEntry({ id: 'a', text: 'copy me' })])
renderPanel()
expandPanel()
const copyBtn = screen.getByRole('button', { name: 'Copy text to clipboard (not images)' })
fireEvent.click(copyBtn)
await waitFor(() => expect(writeText).toHaveBeenCalledWith('copy me'))
// After the async copy resolves, the same button (it stays in the
// DOM, only its label/icon flip) should advertise the success.
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Copied!' })).toBeTruthy(),
)
// Entry is preserved — copy is non-destructive.
expect(readScratchlist(SID).map((e) => e.id)).toEqual(['a'])
})
it('clipboard write failure leaves the icon in the default state (no false success)', async () => {
// Force navigator.clipboard.writeText to reject AND make the
// execCommand fallback fail too, so safeCopyToClipboard throws.
const writeText = vi.fn().mockRejectedValue(new Error('denied'))
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
})
// jsdom doesn't implement document.execCommand. Define a stub
// that returns false so safeCopyToClipboard's fallback path
// also fails (covering the "everything failed" branch).
Object.defineProperty(document, 'execCommand', {
value: () => false,
configurable: true,
writable: true,
})
persistScratchlist(SID, [makeEntry({ id: 'a', text: 'try copy' })])
renderPanel()
expandPanel()
fireEvent.click(screen.getByRole('button', { name: 'Copy text to clipboard (not images)' }))
await waitFor(() => expect(writeText).toHaveBeenCalled())
// Should NOT flip to "Copied!" because the copy failed.
expect(screen.queryByRole('button', { name: 'Copied!' })).toBeNull()
expect(screen.getByRole('button', { name: 'Copy text to clipboard (not images)' })).toBeTruthy()
})
it('persists collapse state across mounts for the same session', () => {
const { unmount } = renderPanel()
expandPanel()
unmount()
// Re-mount with the same session id; should remain expanded.
const second = renderPanel()
const toggle = second.getByRole('button', { name: /Scratchlist/ })
expect(toggle.getAttribute('aria-expanded')).toBe('true')
})
it('isolates entries between sessions', () => {
persistScratchlist('session-A', [makeEntry({ id: 'a1', text: 'A note' })])
persistScratchlist('session-B', [makeEntry({ id: 'b1', text: 'B note' })])
const a = renderPanel({ sessionId: 'session-A' })
fireEvent.click(a.getByRole('button', { name: /Scratchlist/ }))
expect(a.getByText('A note')).toBeTruthy()
expect(a.queryByText('B note')).toBeNull()
a.unmount()
const b = renderPanel({ sessionId: 'session-B' })
fireEvent.click(b.getByRole('button', { name: /Scratchlist/ }))
expect(b.getByText('B note')).toBeTruthy()
expect(b.queryByText('A note')).toBeNull()
})
it('renders the per-entry age indicator with a tooltip showing the smart-relative time', () => {
// 5 minutes ago, deterministic via fake timers below.
vi.useFakeTimers()
const now = new Date('2026-06-13T17:00:00Z').getTime()
vi.setSystemTime(new Date(now))
try {
persistScratchlist(SID, [
makeEntry({
id: 'aged',
text: 'aged note',
createdAt: now - 10 * 60_000,
updatedAt: now - 5 * 60_000,
}),
])
renderPanel()
expandPanel()
const indicator = screen.getByTestId('scratchlist-entry-age')
// Tooltip carries the smart-relative time + an absolute
// timestamp; aria-label carries the relative time only.
const title = indicator.getAttribute('title') ?? ''
expect(title).toContain('5m ago')
expect(title).toContain('Saved')
const aria = indicator.getAttribute('aria-label') ?? ''
expect(aria).toContain('5m ago')
// data-entry-age mirrors the relative bucket so a future
// assertion can target it without scraping the title.
expect(indicator.getAttribute('data-entry-age')).toBe('5m ago')
} finally {
vi.useRealTimers()
}
})
it('falls back to createdAt when updatedAt is absent (legacy v1 row)', () => {
vi.useFakeTimers()
const now = new Date('2026-06-13T17:00:00Z').getTime()
vi.setSystemTime(new Date(now))
try {
persistScratchlist(SID, [
makeEntry({
id: 'legacy',
text: 'legacy v1 note',
createdAt: now - 2 * 60 * 60_000,
// updatedAt deliberately omitted - simulates a
// localStorage row written by v1 before the v2 hub
// sync work added the column.
}),
])
renderPanel()
expandPanel()
const indicator = screen.getByTestId('scratchlist-entry-age')
expect(indicator.getAttribute('data-entry-age')).toBe('2h ago')
} finally {
vi.useRealTimers()
}
})
it('renders no age indicator when both timestamps are unusable', () => {
// The schema validator (`isEntry`) rejects rows with a
// non-finite `createdAt`, so the only realistic path to a
// missing-stamp entry is rendering an in-memory entry directly.
// We simulate that by writing a row with a sentinel `0`
// createdAt - the indicator returns null per its guard.
persistScratchlist(SID, [makeEntry({ id: 'no-stamp', text: 'note', createdAt: 0 })])
renderPanel()
expandPanel()
expect(screen.queryByTestId('scratchlist-entry-age')).toBeNull()
})
})
describe('ScratchlistDrawer disabled operations', () => {
it('disables and synchronously ignores move, delete, and promote actions while the parent send is pending', async () => {
const { ScratchlistDrawer } = await import('./ScratchlistPanel')
const entry = makeEntry({ id: 'pending-entry', text: 'held message' })
const onMove = vi.fn()
const onDelete = vi.fn()
const onPromoteToComposer = vi.fn()
const onPromoteToQueue = vi.fn(async () => true)
render(
<I18nProvider>
<ScratchlistDrawer
entries={[entry]}
sessionId={SID}
api={{} as never}
disabled
onMove={onMove}
onDelete={onDelete}
onPromoteToComposer={onPromoteToComposer}
onPromoteToQueue={onPromoteToQueue}
/>
</I18nProvider>,
)
const mutationButtons = [
...screen.getAllByRole('button', { name: 'Move entry up' }),
...screen.getAllByRole('button', { name: 'Move entry down' }),
screen.getByRole('button', { name: 'Copy into composer' }),
screen.getByRole('button', { name: 'Send to queue' }),
screen.getByRole('button', { name: 'Delete entry' }),
]
for (const button of mutationButtons) {
expect(button).toBeDisabled()
fireEvent.click(button)
}
// Copy is read-only and remains available while a chat send is pending.
expect(screen.getByRole('button', { name: 'Copy text to clipboard (not images)' })).not.toBeDisabled()
await Promise.resolve()
expect(onMove).not.toHaveBeenCalled()
expect(onDelete).not.toHaveBeenCalled()
expect(onPromoteToComposer).not.toHaveBeenCalled()
expect(onPromoteToQueue).not.toHaveBeenCalled()
})
})