mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-11 02:11:37 +00:00
* fix(web): preserve drafts across inactive session resume * fix(web): address resumed draft review findings * fix(web): preserve drafts during hydration resume * fix(web): reset resume cache when session lifecycle changes Clear resolvedSessionRef when session id or active flag changes so a same-id resume that later archives again cannot skip a required new resume. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): gate inactive attachments on resumability Skip the attachment adapter and disable drops when an inactive session cannot resume, so file picks do not become stuck error attachments. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): avoid source-session 404 during reopen handoff When reopen merges into a different id, skip invalidating the source session detail so draft transfer can finish before navigation without flashing Session unavailable. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): include in-flight attachments in inactive resume handoff Pass the selected file into draft transfer and coalesce concurrent multi-file drops so resume navigation cannot drop attachments that were never published to the live snapshot. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): harden inactive draft handoff edge cases Re-check attachment cancellation after resume, always copy from the source draft instead of a stale target snapshot, and force a fresh getSession after optimistic seeding. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): prevent passive resume and preserve staggered handoffs Restore attachments only on active sessions so draft hydration cannot resume an archived session, and remember completed handoff targets so slower multi-file adds append instead of overwriting. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): avoid empty inactive snapshots shadowing attachment drafts When attachment restoration is disabled, persist composer text without publishing an empty live snapshot so reopen can still transfer IndexedDB attachments. Co-authored-by: Cursor <cursoragent@cursor.com> * test(web): cover inactive draft archive-switch-reopen lifecycle Add a component-level harness that exercises HappyComposer snapshot rules with useComposerDraft and transferComposerDraft across archive, session switch, and reopen to a new id. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): resolve SessionChat conflict markers for draft handoff Keep upstream send-message types and the inactive-resume handoffComposerDraft import so typecheck no longer dies on committed merge markers. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: retrigger CI after conflict-marker fix Co-authored-by: Cursor <cursoragent@cursor.com> * chore: retry CI after Actions outage (Meta daily) Co-authored-by: Cursor <cursoragent@cursor.com> * chore: retrigger CI and PR review on current tip Re-queue Test and Codex PR Review after the conflict-marker fix and Actions outage. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): do not overwrite stored attachments from inactive composer Skip attachment draft persistence on unmount when restoration is disabled, so a failed inactive file pick cannot replace hidden IndexedDB attachments with a partial visible list. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): merge inactive picks into stored attachment drafts When restoration is disabled, persist visible pending attachments by merging them with IndexedDB instead of clearing the live snapshot alone, so a failed resume still keeps newly selected files for the next reopen. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): harden inactive draft persist for failed picks Keep pathless attachment ids stable, serialize inactive merge/writes, drop removed visible picks from IndexedDB, and await pending persist before transfer/reopen. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): navigate after cancel during inactive resume handoff If resume already merged into a new session id, still call onSessionResolved without the cancelled file so draft transfer + navigation leave the deleted source route. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): harden inactive draft handoff cancel and persist Carry a live isCancelled predicate through resume handoff so mid-transfer removes drop the file from the source snapshot, persist inactive blobs only on attachment revision (not keystrokes), and keep upload metadata on same-target staggered appends. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): await durable draft move and tombstone source Cross-session transfer now awaits an atomic IndexedDB put+delete via moveDraftAttachments, clears the source text draft, and skips unmount re-persist once the source id is marked handed off. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): re-sample cancel after draft drain; fail closed on move moveDraftAttachments resolves attachments only after awaiting queued writes, and rolls cache back + throws when a real IndexedDB transaction fails so transfer cannot clear the source or mark a handoff on loss. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): re-sample composer text after awaited draft move Keystrokes during the IndexedDB drain keep updating the source draft; re-read live/persisted text after the move resolves before clearing the source and writing the target snapshot. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): navigate after resume even if draft move fails Hub reopen can delete the source row before local IndexedDB finishes. Catch transfer failures, still copy text best-effort, and always navigate via transferComposerDraftThenNavigate / handoff onNavigable. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): harden draft move prune and in-flight persist Delete the source row before put/prune so a full retention store cannot evict an unrelated draft, and buffer inactive persist edits during an in-flight transfer onto the target instead of recreating the source. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): close inactive draft remount and transfer races Reset visibility bookkeeping on inactive remount, install the transfer barrier before the first await, and await a durable corrective target write when late edits land during an in-flight move. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): unbreak draft transfer typecheck and remaining races Re-read pendingTransfers.latest after awaits (TS2339), propagate real IndexedDB write failures on corrective same-target moves, and restore only missing stored attachment ids when same-id resume already has a visible pick. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): stabilize late draft edits and multi-file resume Repeat corrective target writes until pending.latest is unchanged across the await, sync keystrokes into the transfer barrier, and keep one resume promise per inactive adapter instance so staggered files join the same handoff after navigation. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): keep IDB attachments when typing during transfer Track inactive keystrokes on pending.latestText instead of inventing pending.latest with an empty attachment list that would overwrite hidden stored files during a cross-session move. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): fail closed when draft attachment reads fail Cross-session transfer now requests throwOnError for IndexedDB reads so a transient empty result cannot delete the source draft during the durable move. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): fail closed on inactive merge reads and keep target text Strict-read every inactive attachment RMW merge so a transient IDB error cannot publish a partial live snapshot, and copy composer text to the resumed id before aborting when attachment transfer reads fail. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): keep resumed session active after stale detail fetch Reapply optimistic active after background getSession settles so a lagging inactive REST snapshot cannot flip the target composer back, and catch strict inactive persist rejections at fire-and-forget call sites. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): prune attachments cancelled during durable draft move Always rewrite the target after the cross-session move so isCancelled flips during the IndexedDB commit window still drop the file, and prune again in handoff before navigation when the batch reports cancellation. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): stabilize multi-file cancel across draft corrections Treat the pending cancellation set as part of post-move stability so a sibling remove mid-corrective rewrite is re-sampled before navigation, and loop handoff same-target rewrites until that set stops changing. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): transfer inactive drafts before text-only resume send Await onSessionResolved so composer draft handoff completes before the send mutation, and route text-only auto-resume through transferComposerDraftThenNavigate like upload/reopen paths. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): transfer Files reopen drafts and respect SSE active Wire the Files-page reopen path through transferComposerDraftThenNavigate, and refresh resumed session detail only when the cached active flag still matches so a mid-flight inactive SSE transition is not resurrected. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): defer send until hidden inactive drafts hydrate After text-only resume transfers stored attachments, skip the mutation when the target draft still has files the inactive composer never exposed, so clearDraftsAfterSend cannot wipe them after a text-only POST. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): defer any attachment resume and expose stored drafts Always defer cross-session send when visible or stored attachments exist so source-scoped upload paths are not POSTed, and surface IndexedDB attachment presence to disable scheduling on inactive composers. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): resume same-id drafts and block pending schedule races Return resumed independently of session id so same-row PTY/Pi resumes still defer for hidden files, route attachment-only Send through an explicit resume callback, and treat incomplete inactive hydration as schedule-blocking. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): preserve send text during deferred draft handoff Pass the immutable submitted text into draft transfer so assistant-ui clearing the composer while resume is in flight cannot drop text from deferred hidden-attachment sends. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: HeavyGee <133152184+heavygee@users.noreply.github.com>
439 lines
18 KiB
TypeScript
439 lines
18 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
describe('composer-attachment-drafts', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('indexedDB', undefined)
|
|
vi.resetModules()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
it('keeps files available in memory when IndexedDB is unavailable', async () => {
|
|
const mod = await import('./composer-attachment-drafts')
|
|
const file = new File(['image bytes'], 'pasted.png', {
|
|
type: 'image/png',
|
|
lastModified: 123,
|
|
})
|
|
|
|
mod.saveDraftAttachments('session-1', [{ id: 'attachment-1', file }])
|
|
const restored = await mod.getDraftAttachments('session-1')
|
|
|
|
expect(restored).toHaveLength(1)
|
|
expect(restored[0]).not.toBe(file)
|
|
expect(restored[0]?.name).toBe('pasted.png')
|
|
expect(restored[0]?.type).toBe('image/png')
|
|
expect(restored[0]?.lastModified).toBe(123)
|
|
expect(restored[0]?.size).toBe(file.size)
|
|
})
|
|
|
|
it('isolates attachment drafts by session', async () => {
|
|
const mod = await import('./composer-attachment-drafts')
|
|
mod.saveDraftAttachments('session-a', [{ id: 'a', file: new File(['a'], 'a.txt') }])
|
|
mod.saveDraftAttachments('session-b', [{ id: 'b', file: new File(['b'], 'b.txt') }])
|
|
|
|
expect((await mod.getDraftAttachments('session-a'))[0]?.name).toBe('a.txt')
|
|
expect((await mod.getDraftAttachments('session-b'))[0]?.name).toBe('b.txt')
|
|
})
|
|
|
|
it('clears cached attachment drafts', async () => {
|
|
const mod = await import('./composer-attachment-drafts')
|
|
mod.saveDraftAttachments('session-1', [{ id: 'x', file: new File(['x'], 'x.txt') }])
|
|
|
|
mod.clearDraftAttachments('session-1')
|
|
|
|
expect(await mod.getDraftAttachments('session-1')).toEqual([])
|
|
})
|
|
|
|
it('does not read stale IndexedDB data while a clear is being persisted', async () => {
|
|
const mod = await import('./composer-attachment-drafts')
|
|
mod.saveDraftAttachments('session-1', [{ id: 'x', file: new File(['x'], 'x.txt') }])
|
|
mod.clearDraftAttachments('session-1')
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
const open = vi.fn(() => {
|
|
throw new Error('cleared drafts must be served from the cache tombstone')
|
|
})
|
|
vi.stubGlobal('indexedDB', { open })
|
|
|
|
expect(await mod.getDraftAttachments('session-1')).toEqual([])
|
|
expect(open).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('retains completed upload metadata on restored files', async () => {
|
|
const mod = await import('./composer-attachment-drafts')
|
|
const file = new File(['image'], 'ready.png', { type: 'image/png' })
|
|
mod.saveDraftAttachments('session-1', [{
|
|
id: 'attachment-ready',
|
|
file,
|
|
path: '/uploads/ready.png',
|
|
previewUrl: 'data:image/png;base64,aW1hZ2U=',
|
|
}])
|
|
|
|
const [restored] = await mod.getDraftAttachments('session-1')
|
|
|
|
expect(restored && mod.getRestoredUploadMetadata(restored)).toEqual({
|
|
id: 'attachment-ready',
|
|
path: '/uploads/ready.png',
|
|
previewUrl: 'data:image/png;base64,aW1hZ2U=',
|
|
})
|
|
})
|
|
|
|
it('retains stable ids for pathless pending files across persist passes', async () => {
|
|
const mod = await import('./composer-attachment-drafts')
|
|
const file = new File(['pending'], 'pending.txt')
|
|
mod.saveDraftAttachments('session-1', [{ id: 'pending-1', file }])
|
|
|
|
const [first] = await mod.getDraftAttachments('session-1')
|
|
expect(first && mod.getRestoredUploadMetadata(first)?.id).toBe('pending-1')
|
|
|
|
mod.saveDraftAttachments('session-1', [{
|
|
id: mod.getRestoredUploadMetadata(first!)!.id,
|
|
file: first!,
|
|
}])
|
|
const secondPass = await mod.getDraftAttachments('session-1')
|
|
|
|
expect(secondPass).toHaveLength(1)
|
|
expect(secondPass[0] && mod.getRestoredUploadMetadata(secondPass[0])?.id).toBe('pending-1')
|
|
})
|
|
|
|
it('moves attachments to the target and tombstones the source in cache', async () => {
|
|
const mod = await import('./composer-attachment-drafts')
|
|
const file = new File(['payload'], 'notes.txt')
|
|
mod.saveDraftAttachments('session-source', [{ id: 'a1', file }])
|
|
|
|
await mod.moveDraftAttachments('session-source', 'session-target', () => [{ id: 'a1', file }])
|
|
|
|
expect((await mod.getDraftAttachments('session-target')).map((item) => item.name)).toEqual(['notes.txt'])
|
|
expect(await mod.getDraftAttachments('session-source')).toEqual([])
|
|
})
|
|
|
|
it('can surface IndexedDB read failures when throwOnError is set', async () => {
|
|
vi.stubGlobal('indexedDB', {
|
|
open: () => {
|
|
const request: {
|
|
result: unknown
|
|
onsuccess: ((ev: unknown) => void) | null
|
|
onerror: ((ev: unknown) => void) | null
|
|
onupgradeneeded: ((ev: unknown) => void) | null
|
|
error: Error
|
|
} = {
|
|
result: undefined,
|
|
onsuccess: null,
|
|
onerror: null,
|
|
onupgradeneeded: null,
|
|
error: new Error('open failed'),
|
|
}
|
|
queueMicrotask(() => request.onerror?.({}))
|
|
return request
|
|
},
|
|
})
|
|
vi.resetModules()
|
|
const mod = await import('./composer-attachment-drafts')
|
|
|
|
await expect(mod.getDraftAttachments('session-1')).resolves.toEqual([])
|
|
await expect(mod.getDraftAttachments('session-1', { throwOnError: true })).rejects.toThrow()
|
|
})
|
|
|
|
it('commits a put+delete move when IndexedDB is available', async () => {
|
|
const store = new Map<string, unknown>()
|
|
const fakeDb = {
|
|
transaction(_name: string, _mode: string) {
|
|
let pendingCallbacks = 0
|
|
const finishLater = (cb: () => void) => {
|
|
pendingCallbacks += 1
|
|
queueMicrotask(() => {
|
|
cb()
|
|
pendingCallbacks -= 1
|
|
if (pendingCallbacks === 0) {
|
|
transaction.oncomplete?.({})
|
|
}
|
|
})
|
|
}
|
|
const objectStore = () => ({
|
|
put: (record: { sessionId: string }) => {
|
|
store.set(record.sessionId, record)
|
|
},
|
|
delete: (sessionId: string) => {
|
|
store.delete(sessionId)
|
|
},
|
|
getAll: () => {
|
|
const request: { onsuccess: ((ev: unknown) => void) | null; result?: unknown[] } = {
|
|
onsuccess: null,
|
|
}
|
|
finishLater(() => {
|
|
request.result = [...store.values()]
|
|
request.onsuccess?.({})
|
|
})
|
|
return request
|
|
},
|
|
})
|
|
const transaction = {
|
|
objectStore,
|
|
oncomplete: null as ((ev: unknown) => void) | null,
|
|
onerror: null as ((ev: unknown) => void) | null,
|
|
onabort: null as ((ev: unknown) => void) | null,
|
|
}
|
|
// If no async requests were scheduled, complete on next tick.
|
|
queueMicrotask(() => {
|
|
if (pendingCallbacks === 0) transaction.oncomplete?.({})
|
|
})
|
|
return transaction
|
|
},
|
|
close() {},
|
|
}
|
|
vi.stubGlobal('indexedDB', {
|
|
open: () => {
|
|
const request: {
|
|
result: typeof fakeDb
|
|
onsuccess: ((ev: unknown) => void) | null
|
|
onerror: ((ev: unknown) => void) | null
|
|
onupgradeneeded: ((ev: unknown) => void) | null
|
|
} = {
|
|
result: fakeDb,
|
|
onsuccess: null,
|
|
onerror: null,
|
|
onupgradeneeded: null,
|
|
}
|
|
queueMicrotask(() => request.onsuccess?.({}))
|
|
return request
|
|
},
|
|
})
|
|
vi.resetModules()
|
|
const mod = await import('./composer-attachment-drafts')
|
|
const file = new File(['payload'], 'notes.txt')
|
|
mod.saveDraftAttachments('session-source', [{ id: 'a1', file }])
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
await mod.moveDraftAttachments('session-source', 'session-target', () => [{ id: 'a1', file }])
|
|
|
|
expect(store.has('session-target')).toBe(true)
|
|
expect(store.has('session-source')).toBe(false)
|
|
expect((await mod.getDraftAttachments('session-target')).map((item) => item.name)).toEqual(['notes.txt'])
|
|
expect(await mod.getDraftAttachments('session-source')).toEqual([])
|
|
})
|
|
|
|
it('does not prune an unrelated draft when moving at the retention cap', async () => {
|
|
const store = new Map<string, { sessionId: string; updatedAt: number; files: unknown[] }>()
|
|
const fakeDb = {
|
|
transaction(_name: string, _mode: string) {
|
|
let pendingCallbacks = 0
|
|
const finishLater = (cb: () => void) => {
|
|
pendingCallbacks += 1
|
|
queueMicrotask(() => {
|
|
cb()
|
|
pendingCallbacks -= 1
|
|
if (pendingCallbacks === 0) transaction.oncomplete?.({})
|
|
})
|
|
}
|
|
const objectStore = () => ({
|
|
put: (record: { sessionId: string; updatedAt: number; files: unknown[] }) => {
|
|
store.set(record.sessionId, record)
|
|
},
|
|
delete: (sessionId: string) => {
|
|
store.delete(sessionId)
|
|
},
|
|
getAll: () => {
|
|
const request: { onsuccess: ((ev: unknown) => void) | null; result?: unknown[] } = {
|
|
onsuccess: null,
|
|
}
|
|
finishLater(() => {
|
|
request.result = [...store.values()]
|
|
request.onsuccess?.({})
|
|
})
|
|
return request
|
|
},
|
|
})
|
|
const transaction = {
|
|
objectStore,
|
|
oncomplete: null as ((ev: unknown) => void) | null,
|
|
onerror: null as ((ev: unknown) => void) | null,
|
|
onabort: null as ((ev: unknown) => void) | null,
|
|
}
|
|
queueMicrotask(() => {
|
|
if (pendingCallbacks === 0) transaction.oncomplete?.({})
|
|
})
|
|
return transaction
|
|
},
|
|
close() {},
|
|
}
|
|
vi.stubGlobal('indexedDB', {
|
|
open: () => {
|
|
const request: {
|
|
result: typeof fakeDb
|
|
onsuccess: ((ev: unknown) => void) | null
|
|
onerror: ((ev: unknown) => void) | null
|
|
onupgradeneeded: ((ev: unknown) => void) | null
|
|
} = {
|
|
result: fakeDb,
|
|
onsuccess: null,
|
|
onerror: null,
|
|
onupgradeneeded: null,
|
|
}
|
|
queueMicrotask(() => request.onsuccess?.({}))
|
|
return request
|
|
},
|
|
})
|
|
vi.resetModules()
|
|
const mod = await import('./composer-attachment-drafts')
|
|
|
|
// Fill the retention cap with 49 unrelated drafts + the source (50 total).
|
|
// Source is mid-range by updatedAt so it is not the oldest entry.
|
|
for (let i = 0; i < 49; i++) {
|
|
store.set(`keep-${i}`, {
|
|
sessionId: `keep-${i}`,
|
|
updatedAt: i + 1,
|
|
files: [],
|
|
})
|
|
}
|
|
const file = new File(['payload'], 'notes.txt')
|
|
mod.saveDraftAttachments('session-source', [{ id: 'a1', file }])
|
|
store.set('session-source', {
|
|
sessionId: 'session-source',
|
|
updatedAt: 25,
|
|
files: [{ id: 'a1', name: 'notes.txt', type: '', lastModified: 0, blob: file }],
|
|
})
|
|
expect(store.size).toBe(50)
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
await mod.moveDraftAttachments('session-source', 'session-target', () => [{ id: 'a1', file }])
|
|
|
|
expect(store.has('session-target')).toBe(true)
|
|
expect(store.has('session-source')).toBe(false)
|
|
expect(store.has('keep-0')).toBe(true)
|
|
expect(store.size).toBe(50)
|
|
})
|
|
|
|
it('rolls cache back and throws when the IndexedDB move transaction fails', async () => {
|
|
vi.stubGlobal('indexedDB', {
|
|
open: () => {
|
|
const request: {
|
|
result: {
|
|
transaction: () => {
|
|
objectStore: () => {
|
|
put: () => void
|
|
delete: () => void
|
|
getAll: () => { onsuccess: null }
|
|
}
|
|
oncomplete: null
|
|
onerror: ((ev: unknown) => void) | null
|
|
onabort: null
|
|
error: Error
|
|
}
|
|
close: () => void
|
|
}
|
|
onsuccess: ((ev: unknown) => void) | null
|
|
onerror: ((ev: unknown) => void) | null
|
|
onupgradeneeded: ((ev: unknown) => void) | null
|
|
} = {
|
|
result: {
|
|
transaction: () => {
|
|
const transaction = {
|
|
objectStore: () => ({
|
|
put: () => {},
|
|
delete: () => {},
|
|
getAll: () => ({ onsuccess: null }),
|
|
}),
|
|
oncomplete: null,
|
|
onerror: null as ((ev: unknown) => void) | null,
|
|
onabort: null,
|
|
error: new Error('quota exceeded'),
|
|
}
|
|
queueMicrotask(() => transaction.onerror?.({}))
|
|
return transaction
|
|
},
|
|
close: () => {},
|
|
},
|
|
onsuccess: null,
|
|
onerror: null,
|
|
onupgradeneeded: null,
|
|
}
|
|
queueMicrotask(() => request.onsuccess?.({}))
|
|
return request
|
|
},
|
|
})
|
|
vi.resetModules()
|
|
const mod = await import('./composer-attachment-drafts')
|
|
const file = new File(['payload'], 'notes.txt')
|
|
mod.saveDraftAttachments('session-source', [{ id: 'a1', file }])
|
|
|
|
await expect(mod.moveDraftAttachments(
|
|
'session-source',
|
|
'session-target',
|
|
() => [{ id: 'a1', file }],
|
|
)).rejects.toThrow(/quota exceeded|Composer draft move failed/)
|
|
|
|
expect((await mod.getDraftAttachments('session-source')).map((item) => item.name)).toEqual(['notes.txt'])
|
|
expect(await mod.getDraftAttachments('session-target')).toEqual([])
|
|
})
|
|
|
|
it('propagates a rejected same-target queued write instead of treating it as durable', async () => {
|
|
let failNextWrite = false
|
|
vi.stubGlobal('indexedDB', {
|
|
open: () => {
|
|
const request: {
|
|
result: {
|
|
transaction: () => {
|
|
objectStore: () => {
|
|
put: (record: { sessionId: string }) => void
|
|
delete: (sessionId: string) => void
|
|
getAll: () => { onsuccess: null; result?: unknown[] }
|
|
}
|
|
oncomplete: ((ev: unknown) => void) | null
|
|
onerror: ((ev: unknown) => void) | null
|
|
onabort: null
|
|
error: Error | null
|
|
}
|
|
close: () => void
|
|
}
|
|
onsuccess: ((ev: unknown) => void) | null
|
|
onerror: ((ev: unknown) => void) | null
|
|
onupgradeneeded: ((ev: unknown) => void) | null
|
|
} = {
|
|
result: {
|
|
transaction: () => {
|
|
const transaction = {
|
|
objectStore: () => ({
|
|
put: () => {},
|
|
delete: () => {},
|
|
getAll: () => ({ onsuccess: null, result: [] as unknown[] }),
|
|
}),
|
|
oncomplete: null as ((ev: unknown) => void) | null,
|
|
onerror: null as ((ev: unknown) => void) | null,
|
|
onabort: null,
|
|
error: null as Error | null,
|
|
}
|
|
queueMicrotask(() => {
|
|
if (failNextWrite) {
|
|
transaction.error = new Error('quota exceeded')
|
|
transaction.onerror?.({})
|
|
return
|
|
}
|
|
transaction.oncomplete?.({})
|
|
})
|
|
return transaction
|
|
},
|
|
close: () => {},
|
|
},
|
|
onsuccess: null,
|
|
onerror: null,
|
|
onupgradeneeded: null,
|
|
}
|
|
queueMicrotask(() => request.onsuccess?.({}))
|
|
return request
|
|
},
|
|
})
|
|
vi.resetModules()
|
|
const mod = await import('./composer-attachment-drafts')
|
|
const file = new File(['payload'], 'notes.txt')
|
|
failNextWrite = true
|
|
|
|
await expect(mod.moveDraftAttachments(
|
|
'session-target',
|
|
'session-target',
|
|
() => [{ id: 'a1', file }],
|
|
)).rejects.toThrow(/quota exceeded|Composer draft/)
|
|
})
|
|
})
|