Files
hapi/web/src/lib/composer-drafts.test.ts
T
Junmo KimandGitHub c32378b3ba feat(web): persist composer draft across session switches (#438)
* feat(web): persist composer draft across session switches

Switching between sessions now preserves the text typed in the
composer. Drafts are stored per-session in sessionStorage and
restored when the user navigates back.

- Add composer-drafts utility (sessionStorage, in-memory cache)
- Restore draft on HappyComposer mount, save on unmount
- Clear draft on message send
- Evict oldest drafts when exceeding 50 entries
- Add unit tests for composer-drafts

Fixes #231

* fix(web): add key prop to HappyComposer for explicit remount on session switch

* fix(web): move clearDraft to SessionChat after send validation

Prevents draft loss when Codex rejects an unsupported slash command.

* fix(web): remove explicit clearDraft, rely on unmount save

Successful sends clear the composer text, so the unmount save
naturally persists an empty string which deletes the draft entry.
This avoids clearing the draft when the send is blocked or fails.

* fix(web): clear draft on successful send via onSuccess callback

Move draft clearing to the send-success path so drafts are only
removed after the message is actually accepted by the server.

* fix(web): pass session ID to onSuccess to clear correct draft

The previous version used the current route's sessionId, which could
clear the wrong draft if the user switched sessions before the send
completed.

* test(web): add useSendMessage onSuccess callback tests

Verify that onSuccess receives the correct session ID (including
resolved IDs), and is not called on send failure or block.

* refactor(web): extract useComposerDraft hook with unit tests

Extract the draft save/restore logic from HappyComposer into a
dedicated useComposerDraft hook. Adds 6 unit tests covering:
- mount: restores saved draft via rAF
- mount: skips restore if composer already has text
- mount: skips restore if no saved draft
- unmount: saves current text after rAF has fired
- unmount: skips save before rAF (draftReady guard)
- no-op when sessionId is undefined

* fix(web): clear both route and resolved session drafts after send

When resolveSessionId swaps the session (e.g. inactive → resumed),
the sent ID differs from the route's session ID. Extract
clearDraftsAfterSend so both are cleared and unit-testable.

* fix(web): refresh eviction order when updating an existing draft

Delete the key before re-inserting so Object.keys() reflects the
most recent write, preventing a recently edited draft from being
evicted first.
2026-04-12 11:04:52 +08:00

137 lines
5.1 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('composer-drafts', () => {
let storage: Record<string, string>
beforeEach(() => {
storage = {}
vi.stubGlobal('sessionStorage', {
getItem: vi.fn((key: string) => storage[key] ?? null),
setItem: vi.fn((key: string, value: string) => { storage[key] = value }),
removeItem: vi.fn((key: string) => { delete storage[key] }),
})
// Force re-hydration by clearing the module's internal cache
// Re-import to reset the lazy-loaded cache
vi.resetModules()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns empty string for unknown session', async () => {
const mod = await import('./composer-drafts')
expect(mod.getDraft('unknown-session')).toBe('')
})
it('saves and retrieves a draft', async () => {
const mod = await import('./composer-drafts')
mod.saveDraft('session-1', 'hello world')
expect(mod.getDraft('session-1')).toBe('hello world')
})
it('persists drafts to sessionStorage', async () => {
const mod = await import('./composer-drafts')
mod.saveDraft('session-1', 'test')
const stored = JSON.parse(storage['hapi:composer-drafts'] ?? '{}')
expect(stored['session-1']).toBe('test')
})
it('clears a draft', async () => {
const mod = await import('./composer-drafts')
mod.saveDraft('session-1', 'hello')
mod.clearDraft('session-1')
expect(mod.getDraft('session-1')).toBe('')
})
it('deletes entry when saving empty or whitespace-only text', async () => {
const mod = await import('./composer-drafts')
mod.saveDraft('session-1', 'hello')
expect(mod.getDraft('session-1')).toBe('hello')
mod.saveDraft('session-1', ' ')
expect(mod.getDraft('session-1')).toBe('')
const stored = JSON.parse(storage['hapi:composer-drafts'] ?? '{}')
expect(stored).not.toHaveProperty('session-1')
})
it('preserves untrimmed text when saving non-empty draft', async () => {
const mod = await import('./composer-drafts')
mod.saveDraft('session-1', ' hello ')
expect(mod.getDraft('session-1')).toBe(' hello ')
})
it('handles multiple sessions independently', async () => {
const mod = await import('./composer-drafts')
mod.saveDraft('session-a', 'text A')
mod.saveDraft('session-b', 'text B')
expect(mod.getDraft('session-a')).toBe('text A')
expect(mod.getDraft('session-b')).toBe('text B')
mod.clearDraft('session-a')
expect(mod.getDraft('session-a')).toBe('')
expect(mod.getDraft('session-b')).toBe('text B')
})
it('hydrates from existing sessionStorage data', async () => {
storage['hapi:composer-drafts'] = JSON.stringify({ 'existing': 'draft text' })
const mod = await import('./composer-drafts')
expect(mod.getDraft('existing')).toBe('draft text')
})
it('recovers from invalid sessionStorage data', async () => {
storage['hapi:composer-drafts'] = 'not valid json'
const mod = await import('./composer-drafts')
expect(mod.getDraft('any')).toBe('')
// Should still be able to save
mod.saveDraft('any', 'recovered')
expect(mod.getDraft('any')).toBe('recovered')
})
it('ignores non-string values during hydration', async () => {
storage['hapi:composer-drafts'] = JSON.stringify({
'valid': 'text',
'invalid-number': 42,
'invalid-null': null,
})
const mod = await import('./composer-drafts')
expect(mod.getDraft('valid')).toBe('text')
expect(mod.getDraft('invalid-number')).toBe('')
expect(mod.getDraft('invalid-null')).toBe('')
})
it('refreshes eviction order when updating an existing draft', async () => {
const mod = await import('./composer-drafts')
// Save 50 drafts (at capacity)
for (let i = 0; i < 50; i++) {
mod.saveDraft(`session-${i}`, `text-${i}`)
}
// Update the oldest one (session-0) — should move to end of eviction queue
mod.saveDraft('session-0', 'updated')
// Add one more to trigger eviction
mod.saveDraft('session-50', 'new')
// session-1 (the new oldest) should be evicted, not session-0
expect(mod.getDraft('session-0')).toBe('updated')
expect(mod.getDraft('session-1')).toBe('')
expect(mod.getDraft('session-50')).toBe('new')
})
it('evicts oldest entries when exceeding MAX_DRAFTS', async () => {
const mod = await import('./composer-drafts')
// Save 55 drafts (MAX_DRAFTS is 50)
for (let i = 0; i < 55; i++) {
mod.saveDraft(`session-${i}`, `text-${i}`)
}
// Oldest 5 should be evicted
for (let i = 0; i < 5; i++) {
expect(mod.getDraft(`session-${i}`)).toBe('')
}
// Remaining 50 should still exist
for (let i = 5; i < 55; i++) {
expect(mod.getDraft(`session-${i}`)).toBe(`text-${i}`)
}
})
})