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 sessionId?: string }) { const onPromoteToComposer = props?.onPromoteToComposer ?? vi.fn() const onPromoteToQueue = props?.onPromoteToQueue ?? vi.fn(async () => true) return { onPromoteToComposer, onPromoteToQueue, ...render( ), } } function makeEntry(overrides: Partial & { 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( , ) 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() }) })