diff --git a/CHANGELOG.md b/CHANGELOG.md index 626da204..0db5de7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ notes. Entries describe what users experience, not internal refactors. ### Fixed +- Editing a document after more than seven idle days keeps the new recovery + bytes instead of leaving an empty history record. +- Turning off recovery, or stopping it after a storage failure, keeps ordinary + saves linked to the document's authorized file. +- Closing or replacing a document cancels recovery startup still waiting for a + browser lock, so late callbacks cannot revive the previous document's session. +- Anthropic requests now time out even when response data stops after headers; + timeouts and service overloads show the corresponding recovery guidance. +- Browser tools report failed or superseded local opens as failures and apply + read-only mode only as part of the document being opened. +- Repeated document opens no longer retain extra Blob URLs for previous source + files. App updates reuse unchanged prepared assistant runtime assets already + held in earlier app caches. - First-visit editor preparation waits for the service worker to control the page, so warmed font resources remain available in its offline cache. - A failed recovery snapshot no longer removes an older document when storage diff --git a/docs/explorations/2026-10-17-document-lifecycle-followups.md b/docs/explorations/2026-10-17-document-lifecycle-followups.md new file mode 100644 index 00000000..45056cc2 --- /dev/null +++ b/docs/explorations/2026-10-17-document-lifecycle-followups.md @@ -0,0 +1,117 @@ +# Document lifecycle follow-ups + +Baseline: `6b7fbdfd`. The user explicitly approved all five demonstrated defects +and both optimizations from the post-PR-279 audit, regression tests, separate +commits, a new PR and following its latest CI head until every check passes. + +## Scope lock and evidence + +| Task | Verified baseline evidence | Change boundary | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Expired active document | Real store/database with fake IndexedDB returns successful metadata but no latest bytes after seven days. | `lib/history/store.ts`, history store/browser regressions. | +| Independent document identity | Real session/autosave/disk routing returns unavailable with an authorized target after autosave is disabled. | `lib/history/{session,index,autosave}.ts`, document/close callers as needed, related tests. | +| Pending lock cancellation | Delayed Web Locks callback recreates a stopped autosave session. | `lib/history/autosave.ts`, scheduler regressions. | +| Anthropic lifetime/errors | Actual SDK with a short timeout stays pending after headers; real SDK timeout/overload errors miss UI classification. | Anthropic provider/factory options and request error normalization, focused tests. | +| Local open error ownership | Actual entry/tool code reports success and applies readonly after injected conversion failure. | `lib/{document,web-mcp}.ts`, entry/recovery callers and tests. | +| Original Blob URLs | Actual entry source opens three files; all abandoned URLs remain readable after creating a blank document. | `lib/{document,embed-api,events}.ts`, URL lifetime tests. | +| Runtime cache reuse | Worker install requests an identical asset held by an owned previous core and fails when its redundant network request fails. | `public/sw.js`, worker/offline regressions. | + +Use existing modules and explicit lifecycle ownership. Keep identity independent +of the recovery timer, invalidate pending asynchronous starts, and reuse only +exact same-origin build-owned URLs from owned caches. Preserve complete runtime +preparation before activation. Remove redundant original-file URL allocation; +the actual editor document URL retains its existing owner. + +No schema migration, new dependency, vendored/runtime edits, immutable archive +changes, model-default changes, arbitrary cache discovery or new data destination. +Keep user cancellation reasons, save target consent, retry ordering, offline +availability and document mutation approval intact. Add an Anthropic-owned total +deadline covering SDK consumption; do not rely on the SDK header-only timeout. + +## Execution and verification + +1. Add failing behavioral regressions before implementation in each owned scope. +2. Implement history, Anthropic and offline work independently; parent owns local + open/URL callers, integration, documentation, Git and PR operations. +3. Review each scope against all consumers, then review the complete branch. +4. Run full unit coverage, repository formatting/lint/types, production build and + targeted real Chromium regressions. Preserve existing test deadlines. +5. Commit by topic, push normally, create a new English PR and attach it to the + task. Preserve multiple commits; no squash or force-push. Follow only the + newest PR head until all shards and required summaries succeed. Do not merge. + +Ruling: reuse this clean checkout on a dedicated `codex/` branch. Existing +unrelated worktrees are untouched. The user's explicit implementation request +approves the seven concrete audit outcomes; no additional approval stage is +needed for routine implementation choices within these boundaries. + +## Implementation evidence + +- Snapshot expiry excludes the document whose fresh bytes are being written. + Its new bytes, revision counters and authorized target survive while other + expired rows still retire atomically. The regression failed before the fix. +- Active document identity now outlives the recovery scheduler. Recovery-only + stops keep it; actual close/replacement clears it. Disk routing and saved + metadata use the active identity without trusting a URL. Real repository and + file-target tests cover disabled-before/after-open and storage-stop behavior. +- A generation invalidates every pending recovery start, including stops before + a session exists. Late lock grants release before timers/listeners or writes. + Both A/B completion orders, disable and explicit close have regressions. +- Anthropic owns a configurable five-minute deadline across SDK request/body + consumption. Controlled cancellation, callback failures and reentrant Stop + settle promptly and clean up timers/listeners/streams. Tests exercise actual + SDK stalled JSON/SSE responses and actual timeout/overload error classes. +- Local opens reject failures and obsolete completions. Picker/bootstrap callers + consume already-reported errors; tool/recovery callers receive the rejection. + Buffer read-only mode travels with the owned open rather than a later global + mutation. Cross-entry replacement and failed-source preservation remain tested. +- Standalone, embedded and desktop opens retain the File without allocating an + unused second original-file URL. The actual editor URL keeps its existing + allocation/revocation owner. A tracked URL registry exposed seven baseline + entry/outcome failures before the fix; all eight new entry cases now pass. +- Runtime preparation copies exact successful same-origin allowlisted responses + from earlier owned core caches before fetching misses. Sixteen regressions + cover headers/bytes, changed hashes, failures, unrelated caches, concurrency + and complete-before-activation. The original avoidable install failure now + succeeds without requesting the already-held runtime again. + +Four independent scoped reviews reported no actionable findings. The full unit +coverage run passes **6,349 tests in 243 files**, with 89.67% lines and 83.19% +branches; every repository coverage gate passes. + +Both new real-editor browser regressions failed against the exact baseline +production Docker image: the expired document's new revision was absent, and +two ordinary saves with recovery disabled opened two pickers. These are actual +IndexedDB/editor/file-system flows, with prior timestamps and a genuine OPFS +handle used as controlled fixtures. + +## Assembled verification + +- All **33 Chromium cases pass** against a fresh production Docker image in + 2.6 minutes, including both new regressions, source entry paths, queue/Stop, + service errors/streaming, offline capability upgrades, actual recovery, + authorized disk writes, save ordering, PDF export and reload target reuse. +- Repository formatting, archive integrity (1,511 immutable artifacts), oxlint, + refreshed workspace declarations and application TypeScript checks pass. +- Production Docker build passes; existing bundler size warnings remain. +- Independent complete-branch review approves all seven changes and their + consumers, with no unresolved actionable findings. + +```sh +pnpm run test:coverage --maxWorkers=2 +pnpm run lint:ts +pnpm run format:check +docker build -t document:lifecycle-followups . +docker run -d --rm --name document-followups-preview -p 127.0.0.1:4202:80 document:lifecycle-followups +E2E_BASE_URL=http://127.0.0.1:4202 E2E_PORT=4202 pnpm exec playwright test \ + test/e2e/history-quota.spec.ts test/e2e/save-to-file.spec.ts \ + test/e2e/entry-paths.spec.ts test/e2e/autosave-recovery.spec.ts \ + test/e2e/agent-error-recovery.spec.ts test/e2e/agent-request-queue.spec.ts \ + test/e2e/assistant-offline-assets.spec.ts test/e2e/agent-cloud-streaming.spec.ts \ + test/e2e/landing-prefetch.spec.ts --workers=1 --reporter=line +docker stop document-followups-preview +``` + +These checks use deterministic failure/storage/lock fixtures and actual +Chromium/IndexedDB/OPFS/worker/editor behavior. They do not certify live provider +accounts, physical disk exhaustion or every browser/runtime inference backend. diff --git a/index.ts b/index.ts index 127e13dc..18bca1c1 100644 --- a/index.ts +++ b/index.ts @@ -167,7 +167,12 @@ void (async () => { // would produce, and it is the copy nobody else has. if (savedParam && !isEmbedded) { const { restoreSavedDocument } = await import('./lib/history/recovery'); - if (await restoreSavedDocument(savedParam, { readonly: isReadonly })) return; + try { + if (await restoreSavedDocument(savedParam, { readonly: isReadonly })) return; + } catch { + // Opening already reported failure, or another entry replaced it. + return; + } // No snapshot yet (nothing was edited before the reload) or it expired: // fall through and open the same document again under the same id. } @@ -206,7 +211,7 @@ void (async () => { cleaned.searchParams.delete('open'); window.history.replaceState(null, '', cleaned); if (file) { - await openLocalFile(file, { historyId: savedParam || undefined }); + await openLocalFile(file, { historyId: savedParam || undefined }).catch(() => {}); return; } // Stale deep link (reload, bookmarked URL): nothing pending -- back to the landing. diff --git a/lib/agent-plugin/ui/request-error.ts b/lib/agent-plugin/ui/request-error.ts index 88705072..a1c9fe30 100644 --- a/lib/agent-plugin/ui/request-error.ts +++ b/lib/agent-plugin/ui/request-error.ts @@ -11,7 +11,7 @@ export function requestErrorKey( if (error instanceof DOMException && error.name === 'TimeoutError') return 'agentServiceTimedOut'; if (!(error instanceof Error) && typeof error !== 'string') return; const message = error instanceof Error ? error.message : error; - if (message === 'AI request timed out') return 'agentServiceTimedOut'; + if (message === 'AI request timed out' || message === 'Request timed out.') return 'agentServiceTimedOut'; if (error instanceof Error && error.name === 'TimeoutError') return 'agentServiceTimedOut'; if ( ['Failed to fetch', 'NetworkError when attempting to fetch resource.', 'Load failed', 'Connection error.'].includes( @@ -23,6 +23,7 @@ export function requestErrorKey( error instanceof Error && 'status' in error && typeof error.status === 'number' ? error.status : Number(/^(?:OpenAI|Ollama|Gemini) request failed: (\d{3})(?:\s|$)/.exec(message)?.[1]); + if (error instanceof Error && 'type' in error && error.type === 'overloaded_error') return 'agentServiceUnavailable'; if (status === 401 || status === 403) return 'agentServiceAuthFailed'; if (status === 429) return 'agentServiceRateLimited'; if (status === 408 || status === 504) return 'agentServiceTimedOut'; diff --git a/lib/document.ts b/lib/document.ts index 54668582..6782681b 100644 --- a/lib/document.ts +++ b/lib/document.ts @@ -1,7 +1,6 @@ import { beginDocumentOpen, commitDocumentOpen, isMountedDocumentOpen } from './document-open'; import { fetchRemoteDocument } from './remote-document'; import { remoteFileName } from './remote-file-name'; -import { createObjectURL } from 'ranuts/utils'; import { View } from 'ranui/builder'; import { getDocmentObj, setDocmentObj } from '@ranuts/shared/store'; import { t } from '@ranuts/shared/i18n'; @@ -9,7 +8,7 @@ import 'ranui/message'; import { handleDocumentOperation, loadEditorApi } from './converter'; import { showLoading } from './loading'; import { startDocumentSession, newDocumentId } from './history/session'; -import { stopAutosaveSession } from './history/autosave'; +import { clearDocumentSession } from './history/autosave'; import { rememberActiveSource, clearActiveSource } from './active-document-source'; // Import UI functions with type-only to avoid circular dependency @@ -58,7 +57,7 @@ export const onCreateNew = async (ext: string, options?: { docId?: string }): Pr const { fileName, file: fileBlob } = getDocmentObj(); await handleDocumentOperation({ file: fileBlob, fileName, isNew: !fileBlob, isCurrent: opening.isCurrent }); if (!commitDocumentOpen(operation)) return; - stopAutosaveSession(); + clearDocumentSession(); await clearActiveSource(); if (!isMountedDocumentOpen(operation)) return; // Recovery points from here on. A blank document is the case with the most @@ -92,9 +91,10 @@ export const openLocalFile = async ( if (hideControlPanelFn) { hideControlPanelFn(); } - const objectUrl = await createObjectURL(file); opening.assertCurrent(); - setDocmentObj({ fileName: file.name, file, url: objectUrl }); + // The editor owns the URL it creates from these bytes. This store needs + // only the File; a second Blob URL would retain every replaced original. + setDocmentObj({ fileName: file.name, file }); const { fileName, file: fileBlob } = getDocmentObj(); await handleDocumentOperation({ file: fileBlob, @@ -103,21 +103,22 @@ export const openLocalFile = async ( readonly: options?.readonly, isCurrent: opening.isCurrent, }); - if (!commitDocumentOpen(operation)) return; + if (!commitDocumentOpen(operation)) throw new DOMException('Document open superseded', 'AbortError'); // The replacement editor now owns exports; retire the previous identity // before asynchronous source storage, while delaying URL stamping until commit. - stopAutosaveSession(); + clearDocumentSession(); const docId = options?.historyId || newDocumentId(); await rememberActiveSource(docId, file); - if (!isMountedDocumentOpen(operation)) return; + if (!isMountedDocumentOpen(operation)) throw new DOMException('Document open superseded', 'AbortError'); startDocumentSession({ title: file.name, origin: 'local', docId }); } catch (error) { - if (!opening.isCurrent()) return; + if (!opening.isCurrent()) throw error; console.error('Error opening document:', error); // Ensure control panel is shown on error if (showControlPanelFn) { showControlPanelFn(); } + throw error; } finally { // Always remove loading, even if there's an error removeLoading(); @@ -139,7 +140,9 @@ export const onOpenDocument = (): void => { // Only process if a file was actually selected // If user cancelled, onchange won't fire, nothing happens if (file) { - await openLocalFile(file); + // The open path already reports failure. A DOM event has no caller to + // receive its rejection, unlike tools and recovery operations. + await openLocalFile(file).catch(() => {}); // Clear file selection so the same file can be selected again fileInput.value = ''; } @@ -209,9 +212,8 @@ export const openDocumentFromUrl = async ( const file = new File([remote.blob], finalFileName, { type: remote.blob.type }); // Set document object - const objectUrl = await createObjectURL(file); opening.assertCurrent(); - setDocmentObj({ fileName: finalFileName, file, url: objectUrl }); + setDocmentObj({ fileName: finalFileName, file }); // Initialize and open document const { fileName: docFileName, file: fileBlob } = getDocmentObj(); @@ -223,7 +225,7 @@ export const openDocumentFromUrl = async ( isCurrent: opening.isCurrent, }); if (!commitDocumentOpen(operation)) throw new DOMException('Document open superseded', 'AbortError'); - stopAutosaveSession(); + clearDocumentSession(); await clearActiveSource(); if (!isMountedDocumentOpen(operation)) throw new DOMException('Document open superseded', 'AbortError'); startDocumentSession({ title: docFileName, origin: 'url', docId: options?.docId }); diff --git a/lib/embed-api.ts b/lib/embed-api.ts index aee6aeb7..fadc730d 100644 --- a/lib/embed-api.ts +++ b/lib/embed-api.ts @@ -1,5 +1,5 @@ import { clearActiveSource } from './active-document-source'; -import { stopAutosaveSession } from './history/autosave'; +import { clearDocumentSession } from './history/autosave'; import { beginDocumentOpen, commitDocumentOpen, isMountedDocumentOpen } from './document-open'; import { getDocmentObj, setDocmentObj } from '@ranuts/shared/store'; import { handleDocumentOperation, loadEditorApi } from './converter'; @@ -134,7 +134,6 @@ async function openFile(file: File, readonly = false): Promise { setDocmentObj({ fileName: file.name, file, - url: URL.createObjectURL(file), }); await handleDocumentOperation({ fileName: file.name, @@ -144,7 +143,7 @@ async function openFile(file: File, readonly = false): Promise { isCurrent: opening.isCurrent, }); if (!commitDocumentOpen(opening.id)) throw new DOMException('Document open superseded', 'AbortError'); - stopAutosaveSession(); + clearDocumentSession(); await clearActiveSource(); if (!isMountedDocumentOpen(opening.id)) throw new DOMException('Document open superseded', 'AbortError'); } diff --git a/lib/events.ts b/lib/events.ts index b04336b3..8b67652e 100644 --- a/lib/events.ts +++ b/lib/events.ts @@ -1,7 +1,7 @@ import { clearActiveSource } from './active-document-source'; import { beginDocumentOpen, cancelDocumentOpen, commitDocumentOpen } from './document-open'; -import { stopAutosaveSession } from './history/autosave'; -import { MessageCodec, Platform, createObjectURL } from 'ranuts/utils'; +import { clearDocumentSession } from './history/autosave'; +import { MessageCodec, Platform } from 'ranuts/utils'; import type { MessageHandler } from 'ranuts/utils'; import { getDocmentObj, setDocmentObj } from '@ranuts/shared/store'; import { handleDocumentOperation } from './converter'; @@ -47,17 +47,14 @@ export const events: Record> = { try { const file = await MessageCodec.decodeFileChunked(chunks); opening.assertCurrent(); - const url = await createObjectURL(file); - opening.assertCurrent(); setDocmentObj({ fileName: file.name, file: file, - url, }); const { fileName, file: fileBlob } = getDocmentObj(); await handleDocumentOperation({ file: fileBlob, fileName, isNew: !fileBlob, isCurrent: opening.isCurrent }); if (commitDocumentOpen(opening.id)) { - stopAutosaveSession(); + clearDocumentSession(); await clearActiveSource(); } } catch (error) { @@ -70,7 +67,7 @@ export const events: Record> = { }, CLOSE_EDITOR: () => { cancelDocumentOpen(); - stopAutosaveSession(); + clearDocumentSession(); chunkOpening = null; fileChunks = []; if (window.editor && typeof window.editor.destroyEditor === 'function') { diff --git a/lib/history/autosave.ts b/lib/history/autosave.ts index 74ef21d4..9326838f 100644 --- a/lib/history/autosave.ts +++ b/lib/history/autosave.ts @@ -146,6 +146,9 @@ interface ActiveSession { } let session: ActiveSession | null = null; +let sessionGeneration = 0; +// Editing identity outlives the optional recovery scheduler. +let activeDocumentId: string | null = null; function notify(kind: 'warning' | 'error', text: string): void { (window as unknown as { message?: Record void) | undefined> }).message?.[kind]?.(text); @@ -287,6 +290,8 @@ function tick(): void { */ export async function beginAutosaveSession(input: AutosaveSessionInput): Promise { stopAutosaveSession(); + activeDocumentId = input.docId; + const generation = sessionGeneration; if (typeof window === 'undefined' || isEmbedMode() || !isAutosaveEnabled()) return; const ext = extensionOf(input.title); @@ -300,6 +305,12 @@ export async function beginAutosaveSession(input: AutosaveSessionInput): Promise // holds the write lock is our bookkeeping, not their business. This page // simply stops writing snapshots -- the other tab is taking them. const { held, release } = await acquireLock(lockName); + // Stopping or replacing a document also cancels starts still waiting for a + // lock. A late grant must release ownership before installing any resources. + if (generation !== sessionGeneration || !isAutosaveEnabled()) { + release?.(); + return; + } const onVisibility = (): void => { // The last chance that reliably gets time to run. beforeunload cannot be @@ -372,6 +383,7 @@ export async function beginAutosaveSession(input: AutosaveSessionInput): Promise } export function stopAutosaveSession(): void { + sessionGeneration += 1; const active = session; session = null; if (!active || active.stopped) return; @@ -394,3 +406,14 @@ export function getAutosaveDocId(): string | null { export function isAutosaveSessionActive(): boolean { return session !== null; } + +/** The currently opened document, including when recovery is disabled. */ +export function getActiveDocumentId(): string | null { + return activeDocumentId; +} + +/** Retire the opened document before closing or replacing its editor. */ +export function clearDocumentSession(): void { + stopAutosaveSession(); + activeDocumentId = null; +} diff --git a/lib/history/index.ts b/lib/history/index.ts index f90dda15..dc6a7d27 100644 --- a/lib/history/index.ts +++ b/lib/history/index.ts @@ -5,7 +5,7 @@ */ import { setDiskWriter, setSavedToDiskListener } from '../onlyoffice/save-stream'; import { saveToDiskFile } from '../save-target'; -import { getAutosaveDocId } from './autosave'; +import { getActiveDocumentId } from './autosave'; import { extensionOf, markSavedToDisk, pruneExpired } from './store'; import { getDocmentObj } from '@ranuts/shared/store'; @@ -19,7 +19,7 @@ export function initDocumentHistory(): void { // pointed at one. The document id is what links the two, which is why this // is wired here rather than inside the save channel. setDiskWriter(async (file) => { - const docId = getAutosaveDocId(); + const docId = getActiveDocumentId(); if (!docId) return 'unavailable'; const sourceName = getDocmentObj()?.fileName || file.name; return saveToDiskFile(docId, file, 'Document', { @@ -31,11 +31,18 @@ export function initDocumentHistory(): void { // The document now exists on disk, so its history row has nothing left to // offer back. Without this the recovery bar would keep flagging a document // the user already saved. - const id = getAutosaveDocId(); + const id = getActiveDocumentId(); if (id) void markSavedToDisk(id); }); } -export { beginAutosaveSession, isAutosaveEnabled, setAutosaveEnabled, stopAutosaveSession } from './autosave'; +export { + beginAutosaveSession, + clearDocumentSession, + getActiveDocumentId, + isAutosaveEnabled, + setAutosaveEnabled, + stopAutosaveSession, +} from './autosave'; export { clearAllHistory, deleteDoc, getLatestSnapshot, listDocs } from './store'; export type { HistoryDoc } from './types'; diff --git a/lib/history/session.ts b/lib/history/session.ts index 2edd88b5..e257666c 100644 --- a/lib/history/session.ts +++ b/lib/history/session.ts @@ -43,3 +43,5 @@ export function startDocumentSession(input: { title: string; origin: HistoryOrig void beginAutosaveSession({ docId, title: input.title, origin: input.origin }); return docId; } + +export { clearDocumentSession, getActiveDocumentId } from './autosave'; diff --git a/lib/history/store.ts b/lib/history/store.ts index 8e9e883e..08b15225 100644 --- a/lib/history/store.ts +++ b/lib/history/store.ts @@ -180,7 +180,7 @@ async function trimRevisions( * caller already has: expiry has to be something the browser does on its own, * not something that waits for the user to visit a page. */ -async function sweepExpired(tx: IDBTransaction, now: number): Promise { +async function sweepExpired(tx: IDBTransaction, now: number, protectedId?: string): Promise { // A recent snapshot cannot expire, regardless of when it was last opened. // Read only old snapshot metadata, then keep the opening-time renewal rule. const docs = (await requestToPromise( @@ -191,7 +191,9 @@ async function sweepExpired(tx: IDBTransaction, now: number): Promise )) as HistoryDoc[]; // The forward index orders timestamp ties by ascending primary key, just // like getAll did. A stable sort preserves the returned expiry ID order. - const expired = docs.filter((doc) => expiresAt(doc) <= now).sort((a, b) => b.updatedAt - a.updatedAt); + const expired = docs + .filter((doc) => doc.id !== protectedId && expiresAt(doc) <= now) + .sort((a, b) => b.updatedAt - a.updatedAt); for (const doc of expired) { await deleteDocumentData(tx, doc.id); } @@ -290,7 +292,8 @@ async function writeSnapshot( // Expiry first: room freed by documents that were due to go anyway is room // the budget does not have to take from documents that are still current. - await sweepExpired(tx, now); + // Its old metadata can be expired while these new bytes are being written. + await sweepExpired(tx, now, doc.id); const trimmed = await trimRevisions(tx, doc.id, budget); doc.totalBytes -= trimmed.freed; diff --git a/lib/web-mcp.ts b/lib/web-mcp.ts index 475d3e7f..c561d4de 100644 --- a/lib/web-mcp.ts +++ b/lib/web-mcp.ts @@ -155,8 +155,7 @@ export function buildTools(): WebMcpTool[] { if (!fileName.includes('.')) return fail('fileName must include an extension'); const bytes = decodeBase64(String(input.base64 || '')); if (!bytes.byteLength) return fail('base64 decoded to zero bytes'); - await openLocalFile(new File([bytes], fileName)); - if (input.readonly) setReadonlyMode(true); + await openLocalFile(new File([bytes], fileName), { readonly: Boolean(input.readonly) }); return ok({ ok: true, fileName, size: bytes.byteLength, readonly: getReadonlyMode() }); }, }, diff --git a/packages/agent-core/README.md b/packages/agent-core/README.md index eee305be..4f4546bf 100644 --- a/packages/agent-core/README.md +++ b/packages/agent-core/README.md @@ -39,9 +39,9 @@ only knows the `LLMProvider` interface and the `AgentTool` registry. The document product uses a default-off assistant switch. Opening the panel never downloads a model or connects a service. Runtime configuration is reached through the usage settings; generated selection edits require explicit review and confirmation. Browser-agent tools are documented separately in the developer reference. -## Fetch provider request bounds +## Remote provider request bounds -OpenAI-compatible, Gemini, Ollama and Loopback providers use a five-minute total deadline +Anthropic, OpenAI-compatible, Gemini, Ollama and Loopback providers use a five-minute total deadline per request, covering headers and complete JSON or incremental response reads. Set `timeoutMs` in their constructor options or `createProvider` options to override it. The duration must be positive, finite and at most 2,147,483,647 milliseconds. @@ -50,13 +50,17 @@ Caller cancellation retains its original reason; deadline expiry rejects with when an injected transport ignores cancellation. A late response cannot become a successful result. Timers and caller subscriptions are released on settlement. -HTTP failures carry a numeric `status` and a safe provider/status message. +Fetch-based HTTP failures carry a numeric `status` and a safe provider/status message. Upstream error bodies are cancelled without being read or included in errors. Hosts can classify structured errors directly; serialized messages retain the -same provider/status category. No request is retried automatically. These bounds -do not alter the Anthropic SDK or browser-local inference lifetimes. +same provider/status category. Fetch-based providers do not retry requests automatically. -`maxResponseBytes` bounds actual response bytes, including streaming frames, to +Anthropic retains the official SDK protocol and retry policy; its provider owns the total deadline across SDK requests +and JSON or streamed message consumption, and stops the SDK stream on cancellation or +consumer failure. Known SDK timeout and overload failures map to safe product categories. +Browser-local inference lifetimes are separate. + +For fetch-based providers, `maxResponseBytes` bounds actual response bytes, including streaming frames, to 8 MiB by default. It must be a positive safe integer. Declared oversized bodies are rejected before reading; actual bytes are checked even if Content-Length is missing or incorrect. Native response readers are cancelled on overflow, Stop, diff --git a/packages/agent-core/src/llm/anthropic.ts b/packages/agent-core/src/llm/anthropic.ts index be6c0570..c76c088a 100644 --- a/packages/agent-core/src/llm/anthropic.ts +++ b/packages/agent-core/src/llm/anthropic.ts @@ -38,6 +38,9 @@ interface AnthropicResponse { export interface AnthropicStream { on(event: 'text', listener: (textDelta: string) => void): unknown; finalMessage(): Promise; + /** Optional SDK lifecycle methods; minimal injected clients need not implement them. */ + abort?(): void; + off?(event: 'text', listener: (textDelta: string) => void): unknown; } /** The slice of the Anthropic client this provider uses (eases test mocking). */ @@ -105,6 +108,8 @@ export interface AnthropicProviderOptions { model?: string; systemPrompt?: string; maxTokens?: number; + /** Total request deadline, including SDK response consumption. Defaults to five minutes. */ + timeoutMs?: number; /** Inject a client (tests); otherwise one is built lazily from the API key. */ client?: AnthropicLike; } @@ -116,6 +121,7 @@ export class AnthropicProvider implements LLMProvider { private readonly model: string; private readonly systemPrompt: string; private readonly maxTokens: number; + private readonly timeoutMs: number; private client?: AnthropicLike; constructor(options: AnthropicProviderOptions = {}) { @@ -123,6 +129,7 @@ export class AnthropicProvider implements LLMProvider { this.model = options.model ?? DEFAULT_MODEL; this.systemPrompt = options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT; this.maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS; + this.timeoutMs = options.timeoutMs ?? 300_000; this.client = options.client; } @@ -149,16 +156,83 @@ export class AnthropicProvider implements LLMProvider { }; } + private async request( + messages: LLMMessage[], + tools: LLMToolDef[], + owner?: AbortSignal, + onDelta?: (textDelta: string) => void, + ): Promise { + owner?.throwIfAborted(); + if (!Number.isFinite(this.timeoutMs) || this.timeoutMs <= 0 || this.timeoutMs > 2_147_483_647) + throw new RangeError('Provider timeout must be a positive timer duration'); + const controller = new AbortController(); + const forwardAbort = () => controller.abort(owner?.reason); + owner?.addEventListener('abort', forwardAbort, { once: true }); + let stream: AnthropicStream | undefined; + let streamStopped = false; + let active = true; + const stopStream = () => { + if (!stream || streamStopped) return; + streamStopped = true; + // Host clients may throw during cleanup; retain the initiating failure. + try { + stream.abort?.(); + } catch {} + }; + let onAbort: () => void = () => {}; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => { + stopStream(); + reject(controller.signal.reason); + }; + controller.signal.addEventListener('abort', onAbort, { once: true }); + }); + const timer = setTimeout( + () => controller.abort(new DOMException('AI request timed out', 'TimeoutError')), + this.timeoutMs, + ); + const listener = (delta: string) => { + if (!active || controller.signal.aborted) return; + try { + onDelta!(delta); + } catch (error) { + controller.abort(error); + } + }; + try { + const pending = (async () => { + const client = this.getClient(); + const body = this.requestBody(messages, tools); + if (!onDelta || !client.messages.stream) return client.messages.create(body, { signal: controller.signal }); + // The SDK's own timeout covers headers, not finalMessage() consumption. + stream = client.messages.stream(body, { signal: controller.signal }); + if (controller.signal.aborted) stopStream(); + controller.signal.throwIfAborted(); + // Observe rejection before attaching callbacks: minimal host clients may + // emit synchronously, and callbacks can stop or throw reentrantly. + const final = stream.finalMessage(); + stream.on('text', listener); + return final; + })(); + const response = await Promise.race([pending, aborted]); + controller.signal.throwIfAborted(); + return parseAnthropicResponse(response); + } catch (error) { + if (!controller.signal.aborted) controller.abort(error); + throw controller.signal.reason; + } finally { + active = false; + clearTimeout(timer); + owner?.removeEventListener('abort', forwardAbort); + controller.signal.removeEventListener('abort', onAbort); + try { + stream?.off?.('text', listener); + } catch {} + } + } + async chat(messages: LLMMessage[], tools: LLMToolDef[], signal?: AbortSignal): Promise { - signal?.throwIfAborted(); - const response = await this.getClient() - .messages.create(this.requestBody(messages, tools), signal ? { signal } : undefined) - .catch((error: unknown) => { - signal?.throwIfAborted(); - throw error; - }); - signal?.throwIfAborted(); - return parseAnthropicResponse(response); + return this.request(messages, tools, signal); } async chatStream( @@ -167,22 +241,6 @@ export class AnthropicProvider implements LLMProvider { onDelta: (textDelta: string) => void, signal?: AbortSignal, ): Promise { - signal?.throwIfAborted(); - const client = this.getClient(); - // The SDK's messages.stream() emits incremental "text" events and resolves - // the full message via finalMessage(), which parses exactly like create(). - if (!client.messages.stream) { - return this.chat(messages, tools, signal); - } - const stream = client.messages.stream(this.requestBody(messages, tools), signal ? { signal } : undefined); - stream.on('text', (delta) => { - if (!signal?.aborted) onDelta(delta); - }); - const response = await stream.finalMessage().catch((error: unknown) => { - signal?.throwIfAborted(); - throw error; - }); - signal?.throwIfAborted(); - return parseAnthropicResponse(response); + return this.request(messages, tools, signal, onDelta); } } diff --git a/public/sw.js b/public/sw.js index 0707a9c1..cf3cf5ac 100644 --- a/public/sw.js +++ b/public/sw.js @@ -315,6 +315,24 @@ async function restorePreparedAssistant() { await Promise.all([...capabilities].map(prepareAssistantAssets)); } +// Only this build's capability allowlist reaches here. An exact same-origin +// URL identifies reusable bytes; never consult provider/model or vendor caches. +async function reuseAssistantAsset(cache, asset, earlierCoreNames) { + if (new URL(asset, self.location.origin).origin !== self.location.origin) return false; + for (const name of earlierCoreNames) { + try { + const response = await (await caches.open(name)).match(asset); + if (response?.status === 200) { + await cache.put(asset, response.clone()); + return true; + } + } catch { + // A retired/unreadable entry is only a missed optimization. Fetch it below. + } + } + return false; +} + function prepareAssistantAssets(capability) { if (!Object.prototype.hasOwnProperty.call(ASSISTANT_CAPABILITY_ASSETS, capability)) return Promise.reject(new Error('Unknown assistant capability')); @@ -323,8 +341,15 @@ function prepareAssistantAssets(capability) { .open(CORE_CACHE) .then(async (cache) => { const missing = []; - for (const asset of ASSISTANT_CAPABILITY_ASSETS[capability]) - if (!(await cache.match(asset))) missing.push(asset); + let earlierCoreNames; + for (const asset of ASSISTANT_CAPABILITY_ASSETS[capability]) { + if (await cache.match(asset)) continue; + earlierCoreNames ??= await caches + .keys() + .then((names) => names.filter((name) => name.startsWith('document-editor-core-') && name !== CORE_CACHE)) + .catch(() => []); + if (!(await reuseAssistantAsset(cache, asset, earlierCoreNames))) missing.push(asset); + } if (missing.length) await cache.addAll(missing); const marker = () => new Response('1', { headers: { 'X-Document-Assistant-Prepared': '1' } }); await cache.put(preparedKey(capability), marker()); diff --git a/test/e2e/history-quota.spec.ts b/test/e2e/history-quota.spec.ts index ad690f43..885ff43e 100644 --- a/test/e2e/history-quota.spec.ts +++ b/test/e2e/history-quota.spec.ts @@ -2,6 +2,115 @@ import { readHistoryDocs } from './lib/history-db'; import { buildDocx } from './lib/ooxml'; import { expect, test } from './lib/l0'; +test('an expired active document keeps the bytes from its next autosave', async ({ page }) => { + test.setTimeout(90_000); + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + const id = new URL(page.url()).searchParams.get('saved')!; + const recovery = Array.from(buildDocx('Previous recovery')); + await page.evaluate( + async ({ id, bytes }) => { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open('document-history', 3); + request.onupgradeneeded = () => { + request.transaction!.abort(); + reject(new Error('The editor must initialize its history database')); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(['docs', 'blobs'], 'readwrite'); + const old = Date.now() - 8 * 24 * 60 * 60 * 1000; + tx.objectStore('docs').put({ + id, + title: 'Untitled.docx', + titleLower: 'untitled.docx', + ext: 'docx', + origin: 'new', + size: bytes.length, + totalBytes: bytes.length, + createdAt: old, + updatedAt: old, + lastOpenedAt: old, + revCount: 1, + nextRev: 1, + }); + tx.objectStore('blobs').put({ + docId: id, + rev: 0, + savedAt: old, + bytes: new Uint8Array(bytes), + byteLength: bytes.length, + }); + tx.oncomplete = () => resolve(); + tx.onabort = () => reject(tx.error); + }); + } finally { + db.close(); + } + }, + { id, bytes: recovery }, + ); + const sdk = page.frameLocator('iframe[name="frameEditor"]').locator('#editor_sdk'); + await sdk.click({ position: { x: 300, y: 200 } }); + await page.keyboard.type('A fresh edit after eight idle days'); + await page.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await expect + .poll( + () => + page.evaluate( + (id) => + new Promise((resolve, reject) => { + const request = indexedDB.open('document-history', 3); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const read = db.transaction('docs').objectStore('docs').get(id); + read.onsuccess = () => { + db.close(); + resolve(read.result?.nextRev ?? 0); + }; + read.onerror = () => { + db.close(); + reject(read.error); + }; + }; + }), + id, + ), + { timeout: 25_000 }, + ) + .toBe(2); + const bytes = await page.evaluate( + (id) => + new Promise((resolve, reject) => { + const request = indexedDB.open('document-history', 3); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const read = db.transaction('blobs').objectStore('blobs').get([id, 1]); + read.onsuccess = () => { + db.close(); + resolve(read.result ? Array.from(read.result.bytes) : null); + }; + read.onerror = () => { + db.close(); + reject(read.error); + }; + }; + }), + id, + ); + expect(bytes).not.toBeNull(); + const { ooxmlText, zipEntryText } = await import('./lib/ooxml'); + expect(ooxmlText((await zipEntryText(new Uint8Array(bytes!), 'word/document.xml')) || '')).toContain('fresh edit'); +}); + test('a failed autosave quota retry preserves the previous recovery document', async ({ page }) => { test.setTimeout(90_000); const recovery = Array.from(buildDocx('The only recovery copy')); diff --git a/test/e2e/save-to-file.spec.ts b/test/e2e/save-to-file.spec.ts index 4d1d3428..81105d59 100644 --- a/test/e2e/save-to-file.spec.ts +++ b/test/e2e/save-to-file.spec.ts @@ -186,6 +186,23 @@ test.describe('saving into the document own file (real editor)', () => { ); }); + test('disabling recovery still reuses the authorized document file', async ({ page }) => { + await page.evaluate(() => localStorage.setItem('document-autosave-enabled', 'false')); + await page.goto('/editor?new=docx'); + await waitForEditorReady(page); + expect(new URL(page.url()).searchParams.get('saved')).toBeTruthy(); + const sdk = page.frameLocator('iframe[name="frameEditor"]').locator('#editor_sdk'); + await sdk.click({ position: { x: 300, y: 200 } }); + await page.keyboard.type('First disk version'); + await page.keyboard.press('Control+s'); + await waitForWrites(page, 1); + await page.keyboard.type(' and newer edits'); + await page.keyboard.press('Control+s'); + await waitForWrites(page, 2); + expect(await pickerCalls(page)).toBe(1); + expect(await savedText(page)).toContain('and newer edits'); + }); + test('another edit after saving rearms the unsaved-changes warning', async ({ page }) => { await page.goto('/editor?new=docx'); await waitForEditorReady(page); diff --git a/test/unit/agent-llm-anthropic-lifetime.test.ts b/test/unit/agent-llm-anthropic-lifetime.test.ts new file mode 100644 index 00000000..281eb502 --- /dev/null +++ b/test/unit/agent-llm-anthropic-lifetime.test.ts @@ -0,0 +1,358 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import Anthropic, { + APIConnectionTimeoutError, + APIError, +} from '../../packages/agent-core/node_modules/@anthropic-ai/sdk'; +import { AnthropicProvider, type AnthropicLike } from '../../packages/agent-core/src/llm/anthropic'; +import { createProvider } from '../../packages/agent-core/src/llm/factory'; +import { requestErrorKey } from '../../lib/agent-plugin/ui/request-error'; + +const response = { content: [{ type: 'text', text: 'done' }], stop_reason: 'end_turn' }; +afterEach(() => vi.useRealTimers()); + +describe('Anthropic total request lifetime', () => { + it.each(['chat', 'stream', 'fallback'] as const)( + 'bounds a noncooperative %s request and releases subscriptions', + async (mode) => { + vi.useFakeTimers(); + const owner = new AbortController(); + const remove = vi.spyOn(owner.signal, 'removeEventListener'); + let transportSignal: AbortSignal | undefined; + let listener: ((text: string) => void) | undefined; + const abort = vi.fn(); + const off = vi.fn(); + const stalled = new Promise(() => {}); + const client: AnthropicLike = { + messages: { + create: (_body, options) => { + transportSignal = options?.signal; + return stalled; + }, + ...(mode === 'stream' + ? { + stream: (_body: Record, options?: { signal?: AbortSignal }) => { + transportSignal = options?.signal; + return { + on: (_event: 'text', next: (text: string) => void) => { + listener = next; + }, + finalMessage: () => stalled, + abort, + off, + }; + }, + } + : {}), + }, + }; + const provider = createProvider('anthropic', { client, timeoutMs: 25 }); + const deltas: string[] = []; + let outcome: unknown; + const pending = ( + mode === 'chat' + ? provider.chat([], [], owner.signal) + : provider.chatStream!([], [], (text) => deltas.push(text), owner.signal) + ).catch((error) => { + outcome = error; + }); + await vi.advanceTimersByTimeAsync(25); + expect(outcome).toMatchObject({ name: 'TimeoutError', message: 'AI request timed out' }); + expect(transportSignal?.aborted).toBe(true); + listener?.('late'); + expect(deltas).toEqual([]); + if (mode === 'stream') { + expect(abort).toHaveBeenCalledOnce(); + expect(off).toHaveBeenCalledWith('text', listener); + } + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)); + expect(vi.getTimerCount()).toBe(0); + await pending; + }, + ); + + it.each(['chat', 'stream'] as const)('retains caller reasons when a %s client ignores cancellation', async (mode) => { + vi.useFakeTimers(); + const owner = new AbortController(); + const reason = new Error('caller stopped'); + const stalled = new Promise(() => {}); + const provider = new AnthropicProvider({ + client: { + messages: { + create: () => stalled, + ...(mode === 'stream' ? { stream: () => ({ on: () => {}, finalMessage: () => stalled }) } : {}), + }, + }, + }); + let outcome: unknown; + const pending = ( + mode === 'chat' ? provider.chat([], [], owner.signal) : provider.chatStream([], [], () => {}, owner.signal) + ).catch((error) => { + outcome = error; + }); + owner.abort(reason); + await vi.advanceTimersByTimeAsync(0); + expect(outcome).toBe(reason); + expect(vi.getTimerCount()).toBe(0); + await pending; + }); + + it.each(['throw', 'stop'] as const)( + 'terminates a stream after consumer %s, including reentrant deltas', + async (mode) => { + vi.useFakeTimers(); + const owner = new AbortController(); + const reason = new Error('consumer stopped'); + const abort = vi.fn(); + const off = vi.fn(); + const provider = new AnthropicProvider({ + client: { + messages: { + create: async () => response, + stream: () => ({ + on: (_event, listener) => { + listener('first'); + listener('late'); + }, + finalMessage: () => new Promise(() => {}), + abort, + off, + }), + }, + }, + }); + const deltas: string[] = []; + let outcome: unknown; + const pending = provider + .chatStream( + [], + [], + (delta) => { + deltas.push(delta); + if (mode === 'throw') throw reason; + owner.abort(reason); + }, + owner.signal, + ) + .catch((error) => { + outcome = error; + }); + await vi.advanceTimersByTimeAsync(0); + expect(outcome).toBe(reason); + expect(deltas).toEqual(['first']); + expect(abort).toHaveBeenCalledOnce(); + expect(off).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + await pending; + }, + ); + + it.each(['chat', 'stream'] as const)('releases its deadline after successful %s completion', async (mode) => { + vi.useFakeTimers(); + const owner = new AbortController(); + const remove = vi.spyOn(owner.signal, 'removeEventListener'); + const abort = vi.fn(); + const off = vi.fn(); + const provider = new AnthropicProvider({ + timeoutMs: 25, + client: { + messages: { + create: async () => response, + stream: () => ({ on: () => {}, finalMessage: async () => response, abort, off }), + }, + }, + }); + const result = await (mode === 'chat' + ? provider.chat([], [], owner.signal) + : provider.chatStream([], [], () => {}, owner.signal)); + expect(result.text).toBe('done'); + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)); + expect(vi.getTimerCount()).toBe(0); + expect(abort).not.toHaveBeenCalled(); + if (mode === 'stream') expect(off).toHaveBeenCalledOnce(); + }); + + it.each([0, -1, Infinity, NaN, 2_147_483_648])( + 'rejects invalid total timeout %s before starting transport', + async (timeoutMs) => { + const create = vi.fn(async () => response); + const provider = new AnthropicProvider({ timeoutMs, client: { messages: { create } } }); + await expect(provider.chat([], [])).rejects.toThrow(RangeError); + expect(create).not.toHaveBeenCalled(); + }, + ); + + it('ignores post-completion text from minimal clients without listener removal', async () => { + let listener!: (text: string) => void; + const deltas: string[] = []; + const provider = new AnthropicProvider({ + client: { + messages: { + create: async () => response, + stream: () => ({ + on: (_event, next) => { + listener = next; + }, + finalMessage: async () => response, + }), + }, + }, + }); + expect((await provider.chatStream([], [], (text) => deltas.push(text))).text).toBe('done'); + listener('late'); + expect(deltas).toEqual([]); + }); + + it('bounds the default lifetime and discards a noncooperative late response', async () => { + vi.useFakeTimers(); + let complete!: (value: typeof response) => void; + const provider = new AnthropicProvider({ + client: { + messages: { + create: () => + new Promise((resolve) => { + complete = resolve; + }), + }, + }, + }); + let outcome: unknown; + const pending = provider.chat([], []).then( + (value) => { + outcome = value; + }, + (error) => { + outcome = error; + }, + ); + await vi.advanceTimersByTimeAsync(299_999); + expect(outcome).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + expect(outcome).toMatchObject({ name: 'TimeoutError' }); + complete(response); + await pending; + expect(outcome).toMatchObject({ name: 'TimeoutError' }); + expect(vi.getTimerCount()).toBe(0); + }); + + it('does not start a request or timer for a pre-aborted caller', async () => { + vi.useFakeTimers(); + const owner = new AbortController(); + owner.abort('already stopped'); + const create = vi.fn(async () => response); + const provider = new AnthropicProvider({ client: { messages: { create } } }); + await expect(provider.chat([], [], owner.signal)).rejects.toBe('already stopped'); + expect(create).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('retains a stream failure when abort and listener cleanup throw', async () => { + vi.useFakeTimers(); + const reason = new Error('upstream failed'); + const owner = new AbortController(); + const remove = vi.spyOn(owner.signal, 'removeEventListener'); + const provider = new AnthropicProvider({ + client: { + messages: { + create: async () => response, + stream: () => ({ + on: () => {}, + finalMessage: async () => { + throw reason; + }, + abort: () => { + throw new Error('cleanup failed'); + }, + off: () => { + throw new Error('cleanup failed'); + }, + }), + }, + }, + }); + await expect(provider.chatStream([], [], () => {}, owner.signal)).rejects.toBe(reason); + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(['chat', 'stream'] as const)( + 'bounds real SDK %s body consumption after headers, beyond its SDK timeout', + async (mode) => { + let fetched = false; + let cancelled = false; + let fetchSignal: AbortSignal | undefined; + const client = new Anthropic({ + apiKey: 'test', + dangerouslyAllowBrowser: true, + maxRetries: 0, + timeout: 10, + fetch: async (_url, init) => { + fetched = true; + fetchSignal = init?.signal ?? undefined; + return new Response( + new ReadableStream({ + start(controller) { + init?.signal?.addEventListener( + 'abort', + () => controller.error(new DOMException('aborted', 'AbortError')), + { once: true }, + ); + if (mode === 'stream') + controller.enqueue( + new TextEncoder().encode( + 'event: message_start\ndata: {"type":"message_start","message":{"id":"test","type":"message","role":"assistant","content":[],"model":"test","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":0}}}\n\n', + ), + ); + }, + cancel() { + cancelled = true; + }, + }), + { headers: { 'content-type': mode === 'stream' ? 'text/event-stream' : 'application/json' } }, + ); + }, + }); + const owner = new AbortController(); + const provider = new AnthropicProvider({ + timeoutMs: 40, + client: client as unknown as AnthropicLike, + model: 'test', + maxTokens: 100, + }); + const pending = ( + mode === 'chat' ? provider.chat([], [], owner.signal) : provider.chatStream([], [], () => {}, owner.signal) + ).catch((error) => error); + let watchdog: ReturnType | undefined; + try { + const result = await Promise.race([ + pending, + new Promise((resolve) => { + watchdog = setTimeout(() => resolve('still pending'), 250); + }), + ]); + expect(fetched).toBe(true); + expect(result).toMatchObject({ name: 'TimeoutError' }); + expect(fetchSignal?.aborted || cancelled).toBe(true); + } finally { + clearTimeout(watchdog); + owner.abort(); + await pending; + } + }, + ); +}); + +it('classifies actual SDK timeout and SSE overload errors without reading private response text', () => { + expect(requestErrorKey(new APIConnectionTimeoutError({}))).toBe('agentServiceTimedOut'); +}); + +it('classifies actual SDK SSE overload errors without exposing private response text', () => { + const overloaded = new APIError( + undefined, + { error: { type: 'overloaded_error', message: 'private key and destination' } }, + undefined, + new Headers(), + 'overloaded_error', + ); + expect(requestErrorKey(overloaded)).toBe('agentServiceUnavailable'); + expect(requestErrorKey(new Error('private overloaded_error destination'))).toBeUndefined(); +}); diff --git a/test/unit/agent-llm-anthropic.test.ts b/test/unit/agent-llm-anthropic.test.ts index 339ee82d..152312c0 100644 --- a/test/unit/agent-llm-anthropic.test.ts +++ b/test/unit/agent-llm-anthropic.test.ts @@ -155,8 +155,9 @@ it.each(['chat', 'stream', 'fallback'] as const)( 'cancels a pending Anthropic %s request through the SDK signal', async (mode) => { const abort = new AbortController(); + let transportSignal: AbortSignal | undefined; const request = (_body: Record, options?: { signal?: AbortSignal }) => { - expect(options?.signal).toBe(abort.signal); + transportSignal = options?.signal; return new Promise((_resolve, reject) => { options!.signal!.addEventListener('abort', () => reject(new APIUserAbortError()), { once: true }); }); @@ -179,7 +180,10 @@ it.each(['chat', 'stream', 'fallback'] as const)( const typed = provider as import('@ranuts/agent-core/llm/types').LLMProvider; const pending = mode === 'chat' ? typed.chat([], [], abort.signal) : typed.chatStream!([], [], () => {}, abort.signal); + expect(transportSignal).toBeInstanceOf(AbortSignal); + expect(transportSignal?.aborted).toBe(false); abort.abort(); + expect(transportSignal?.aborted).toBe(true); await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); }, ); diff --git a/test/unit/document-entry-contracts.test.ts b/test/unit/document-entry-contracts.test.ts new file mode 100644 index 00000000..965a9149 --- /dev/null +++ b/test/unit/document-entry-contracts.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const native = vi.hoisted(() => ({ + convert: vi.fn(async (_options: unknown) => {}), + remember: vi.fn(async () => true), + readonly: vi.fn(), + start: vi.fn(), + decode: vi.fn(async () => new File(['desktop'], 'Desktop.docx')), +})); +vi.mock('../../lib/converter', () => ({ + handleDocumentOperation: native.convert, + loadEditorApi: async () => {}, +})); +vi.mock('../../lib/history/session', () => ({ startDocumentSession: native.start, newDocumentId: () => 'local-id' })); +vi.mock('../../lib/history/autosave', () => ({ stopAutosaveSession: vi.fn(), clearDocumentSession: vi.fn() })); +vi.mock('../../lib/active-document-source', () => ({ + rememberActiveSource: native.remember, + clearActiveSource: async () => {}, +})); +vi.mock('../../lib/onlyoffice-editor', () => ({ + getReadonlyMode: () => false, + setReadonlyMode: native.readonly, + requestSaveDocument: vi.fn(), +})); +vi.mock('../../lib/agent-plugin/tools', () => ({ getDocumentTextTool: {} })); +vi.mock('ranuts/utils', async (original) => ({ + ...(await original()), + MessageCodec: { decodeFileChunked: native.decode }, +})); +import { onCreateNew, onOpenDocument, openDocumentFromUrl, openLocalFile } from '../../lib/document'; +import { initWebMcp, disposeWebMcp, type WebMcpTool } from '../../lib/web-mcp'; +import { events } from '../../lib/events'; +import { initEmbedApi } from '../../lib/embed-api'; + +const chunk = { chunkIndex: 0, data: '', lastModified: 0, name: 'Desktop.docx', size: 7, totalChunks: 1, type: '' }; +const held = new Map(); +let listeners: ReturnType; +beforeEach(() => { + native.convert.mockReset().mockResolvedValue(undefined); + native.remember.mockReset().mockResolvedValue(true); + native.start.mockClear(); + native.readonly.mockClear(); + held.clear(); + let sequence = 0; + vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => { + const url = `blob:source-${++sequence}`; + held.set(url, blob); + return url; + }); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation((url) => { + held.delete(url); + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + listeners = vi.spyOn(window, 'addEventListener'); + history.replaceState(null, '', '/editor'); +}); +afterEach(() => { + disposeWebMcp(); + delete (document as Document & { modelContext?: unknown }).modelContext; + for (const [type, listener, options] of listeners.mock.calls) window.removeEventListener(type, listener, options); + document.body.classList.remove('embed-mode'); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function registeredBufferTool(): WebMcpTool { + const tools = new Map(); + Object.assign(document, { + modelContext: { registerTool: (tool: WebMcpTool) => tools.set(tool.name, tool), unregisterTool: vi.fn() }, + }); + initWebMcp(); + return tools.get('open_document_buffer')!; +} + +describe('local open outcome ownership', () => { + it('rejects a failed native open so its caller cannot report success', async () => { + native.convert.mockRejectedValueOnce(new Error('conversion failed')); + await expect(openLocalFile(new File(['broken'], 'Broken.docx'))).rejects.toThrow('conversion failed'); + expect(native.start).not.toHaveBeenCalled(); + }); + + it('reports a registered buffer tool failure without changing the old editor readonly state', async () => { + native.convert.mockRejectedValueOnce(new Error('conversion failed')); + const result = await registeredBufferTool().execute({ + fileName: 'Broken.docx', + base64: btoa('broken'), + readonly: true, + }); + expect(result.isError).toBe(true); + expect(JSON.parse(result.content[0].text)).toMatchObject({ ok: false, error: 'conversion failed' }); + expect(native.readonly).not.toHaveBeenCalled(); + }); + + it('applies buffer readonly as part of the owned open rather than a later global mutation', async () => { + const result = await registeredBufferTool().execute({ + fileName: 'Read.docx', + base64: btoa('read'), + readonly: true, + }); + expect(result.isError).not.toBe(true); + expect(native.convert).toHaveBeenCalledWith(expect.objectContaining({ readonly: true })); + expect(native.readonly).not.toHaveBeenCalled(); + }); + + it('reports a superseded tool open as cancelled after a replacement commits', async () => { + let finish!: (saved: boolean) => void; + native.remember.mockImplementationOnce(() => new Promise((resolve) => (finish = resolve))); + const result = registeredBufferTool().execute({ fileName: 'Old.docx', base64: btoa('old'), readonly: true }); + await vi.waitFor(() => expect(native.remember).toHaveBeenCalled()); + await onCreateNew('.xlsx'); + finish(true); + expect((await result).isError).toBe(true); + expect(native.readonly).not.toHaveBeenCalled(); + expect(native.start).toHaveBeenCalledWith(expect.objectContaining({ title: 'Untitled.xlsx' })); + }); + + it('consumes an already-reported file picker failure at the event boundary', async () => { + native.convert.mockRejectedValueOnce(new Error('picker conversion failed')); + onOpenDocument(); + const input = document.querySelector('input[type="file"]')!; + Object.defineProperty(input, 'files', { configurable: true, value: [new File(['broken'], 'Broken.docx')] }); + input.dispatchEvent(new Event('change')); + await vi.waitFor(() => expect(native.convert).toHaveBeenCalled()); + await vi.waitFor(() => expect(input.value).toBe('')); + }); +}); + +describe('original document Blob ownership', () => { + it('leaves no original-file URLs behind across local opens and a blank replacement', async () => { + for (let i = 0; i < 3; i++) await openLocalFile(new File([`original-${i}`], `${i}.docx`)); + await onCreateNew('.docx'); + expect(held.size).toBe(0); + }); + + it('leaves no original URL behind after a remote or desktop replacement', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('remote')), + ); + await openDocumentFromUrl('https://files.example/Remote.docx'); + await events.RENDER_OFFICE(chunk); + events.CLOSE_EDITOR(undefined); + expect(held.size).toBe(0); + }); + + it('hands embedded bytes to the editor without retaining an extra source URL', async () => { + history.replaceState(null, '', '/editor?embed=1'); + const posted = vi.spyOn(window.parent, 'postMessage'); + initEmbedApi(); + window.dispatchEvent( + new MessageEvent('message', { + source: window.parent, + origin: 'https://host.example', + data: { id: 'source-url', type: 'document:open-file', payload: { file: new File(['host'], 'Host.docx') } }, + }), + ); + await vi.waitFor(() => + expect(posted).toHaveBeenCalledWith( + expect.objectContaining({ id: 'source-url', type: 'document:opened' }), + 'https://host.example', + ), + ); + expect(held.size).toBe(0); + }); +}); diff --git a/test/unit/document-open.test.ts b/test/unit/document-open.test.ts index b7f01f22..a28cb2be 100644 --- a/test/unit/document-open.test.ts +++ b/test/unit/document-open.test.ts @@ -7,7 +7,7 @@ const { convert, start, clear, remember } = vi.hoisted(() => ({ })); vi.mock('../../lib/converter', () => ({ handleDocumentOperation: convert, loadEditorApi: async () => {} })); vi.mock('../../lib/history/session', () => ({ startDocumentSession: start, newDocumentId: () => 'new-id' })); -vi.mock('../../lib/history/autosave', () => ({ stopAutosaveSession: vi.fn() })); +vi.mock('../../lib/history/autosave', () => ({ clearDocumentSession: vi.fn() })); vi.mock('../../lib/active-document-source', () => ({ clearActiveSource: clear, rememberActiveSource: remember })); vi.mock('../../lib/onlyoffice-editor', () => ({ getReadonlyMode: () => false })); import { openDocumentFromUrl, openLocalFile, onCreateNew } from '../../lib/document'; diff --git a/test/unit/history-autosave-start.test.ts b/test/unit/history-autosave-start.test.ts new file mode 100644 index 00000000..e9e98f01 --- /dev/null +++ b/test/unit/history-autosave-start.test.ts @@ -0,0 +1,119 @@ +import 'fake-indexeddb/auto'; +import { IDBFactory } from 'fake-indexeddb'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +const { requestSaveDocument } = vi.hoisted(() => ({ requestSaveDocument: vi.fn() })); +vi.mock('../../lib/onlyoffice/save-stream', () => ({ requestSaveDocument })); +vi.mock('../../lib/onlyoffice/readonly', () => ({ getReadonlyMode: () => false })); + +import { + beginAutosaveSession, + clearDocumentSession, + getActiveDocumentId, + getAutosaveDocId, + isAutosaveSessionActive, + setAutosaveEnabled, + stopAutosaveSession, + takeSnapshot, +} from '../../lib/history/autosave'; +import { resetHistoryDbForTests } from '../../lib/history/db'; +import { getLatestSnapshot, resetHistoryClockForTests } from '../../lib/history/store'; +import { markDocumentDirty, resetUnsavedGuardForTests } from '../../lib/unsaved-guard'; + +interface LockRequest { + name: string; + callback: (lock: unknown) => unknown; + resolve: () => void; +} +let requests: LockRequest[]; +let released: string[]; + +function grant(index: number): void { + const request = requests[index]; + const held = request.callback({ name: request.name, mode: 'exclusive' }); + void Promise.resolve(held).then(() => { + released.push(request.name); + request.resolve(); + }); +} + +beforeEach(() => { + clearDocumentSession(); + resetHistoryDbForTests(); + resetHistoryClockForTests(); + resetUnsavedGuardForTests(); + requests = []; + released = []; + vi.stubGlobal('indexedDB', new IDBFactory()); + vi.stubGlobal('navigator', { + locks: { + request: (name: string, _options: unknown, callback: (lock: unknown) => unknown) => + new Promise((resolve) => { + requests.push({ name, callback, resolve }); + }), + }, + }); + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); + setAutosaveEnabled(true); + window.history.replaceState(null, '', '/editor'); + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' }); + requestSaveDocument.mockReset().mockResolvedValue(new File([new Uint8Array([7, 8])], 'Report.docx')); +}); +afterEach(() => { + clearDocumentSession(); + resetHistoryDbForTests(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +it.each(['stop', 'clear', 'disable'])( + 'releases a late document lock after %s without reinstalling recovery', + async (mode) => { + const pending = beginAutosaveSession({ docId: 'old', title: 'Old.docx', origin: 'local' }); + if (mode === 'stop') stopAutosaveSession(); + if (mode === 'clear') clearDocumentSession(); + if (mode === 'disable') setAutosaveEnabled(false); + const listener = vi.spyOn(document, 'addEventListener'); + grant(0); + await pending; + + expect(isAutosaveSessionActive()).toBe(false); + expect(getActiveDocumentId()).toBe(mode === 'clear' ? null : 'old'); + expect(released).toEqual(['document-history:old']); + expect(vi.getTimerCount()).toBe(0); + expect(listener.mock.calls.filter(([type]) => type === 'visibilitychange')).toEqual([]); + markDocumentDirty(); + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' }); + document.dispatchEvent(new Event('visibilitychange')); + expect(await takeSnapshot()).toBeNull(); + expect(requestSaveDocument).not.toHaveBeenCalled(); + expect(await getLatestSnapshot('old')).toBeNull(); + }, +); + +it.each([ + [0, 1], + [1, 0], +])('keeps replacement B when pending locks resolve in order %i then %i', async (first, second) => { + const old = beginAutosaveSession({ docId: 'A', title: 'A.docx', origin: 'local' }); + const current = beginAutosaveSession({ docId: 'B', title: 'B.docx', origin: 'local' }); + grant(first); + await (first === 0 ? old : current); + expect(getActiveDocumentId()).toBe('B'); + if (first === 0) expect(isAutosaveSessionActive()).toBe(false); + grant(second); + await Promise.all([old, current]); + + expect(getActiveDocumentId()).toBe('B'); + expect(getAutosaveDocId()).toBe('B'); + expect(released).toEqual(['document-history:A']); + expect(vi.getTimerCount()).toBe(1); + await takeSnapshot(); + expect(await getLatestSnapshot('A')).toBeNull(); + expect(Array.from((await getLatestSnapshot('B'))!.bytes)).toEqual([7, 8]); + clearDocumentSession(); + await Promise.resolve(); + expect(released).toEqual(['document-history:A', 'document-history:B']); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/test/unit/history-handle-cleanup.test.ts b/test/unit/history-handle-cleanup.test.ts index b4b1e554..0f859142 100644 --- a/test/unit/history-handle-cleanup.test.ts +++ b/test/unit/history-handle-cleanup.test.ts @@ -6,6 +6,8 @@ import { clearAllHistory, deleteDoc, getDoc, + getLatestSnapshot, + historyUsage, pruneExpired, putSnapshot, resetHistoryClockForTests, @@ -62,3 +64,26 @@ it('also clears targets that have never had a recovery snapshot', async () => { await clearAllHistory(); expect(await targetIds()).toEqual([]); }); + +it('keeps an actively updated expired document and its file target while sweeping other expired documents', async () => { + await putSnapshot({ id: 'active', title: 'Active.docx', origin: 'local', bytes: new Uint8Array([1, 2]) }); + await putSnapshot({ id: 'stale', title: 'Stale.docx', origin: 'local', bytes: new Uint8Array([9]) }); + await addTarget('active'); + await addTarget('stale'); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + MAX_AGE_MS + 10); + + const updated = await putSnapshot({ + id: 'active', + title: 'Active.docx', + origin: 'local', + bytes: new Uint8Array([3, 4, 5]), + }); + + expect(Array.from((await getLatestSnapshot('active'))?.bytes ?? [])).toEqual([3, 4, 5]); + expect(updated).toMatchObject({ id: 'active', revCount: 2, totalBytes: 5, nextRev: 2 }); + expect(await getDoc('active')).toEqual(updated); + expect(await historyUsage()).toBe(5); + expect(await targetIds()).toEqual(['active']); + expect(await getDoc('stale')).toBeNull(); + expect(await getLatestSnapshot('stale')).toBeNull(); +}); diff --git a/test/unit/history-session-lifecycle.test.ts b/test/unit/history-session-lifecycle.test.ts new file mode 100644 index 00000000..405e8ca3 --- /dev/null +++ b/test/unit/history-session-lifecycle.test.ts @@ -0,0 +1,134 @@ +import 'fake-indexeddb/auto'; +import { IDBFactory } from 'fake-indexeddb'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +const stream = vi.hoisted(() => ({ + requestSaveDocument: vi.fn(), + diskWriter: null as null | ((file: File) => Promise), + saved: null as null | (() => void), +})); +vi.mock('../../lib/onlyoffice/save-stream', () => ({ + requestSaveDocument: stream.requestSaveDocument, + setDiskWriter: (writer: (file: File) => Promise) => { + stream.diskWriter = writer; + }, + setSavedToDiskListener: (listener: () => void) => { + stream.saved = listener; + }, +})); +vi.mock('../../lib/onlyoffice/readonly', () => ({ getReadonlyMode: () => false })); + +import { + beginAutosaveSession, + clearDocumentSession, + getActiveDocumentId, + stopAutosaveSession, + setAutosaveEnabled, + takeSnapshot, + isAutosaveSessionActive, +} from '../../lib/history/autosave'; +import { initDocumentHistory } from '../../lib/history'; +import { resetHistoryDbForTests } from '../../lib/history/db'; +import { getDoc, putSnapshot, resetHistoryClockForTests } from '../../lib/history/store'; +import { resetSaveTargetsForTests, saveToDiskFile } from '../../lib/save-target'; +import { startDocumentSession } from '../../lib/history/session'; + +const file = () => new File([new Uint8Array([1, 2, 3])], 'Report.docx'); + +beforeEach(() => { + clearDocumentSession(); + resetHistoryDbForTests(); + resetHistoryClockForTests(); + resetSaveTargetsForTests(); + vi.stubGlobal('indexedDB', new IDBFactory()); + stream.requestSaveDocument.mockReset(); + setAutosaveEnabled(true); + window.history.replaceState(null, '', '/editor'); + initDocumentHistory(); +}); +afterEach(() => { + clearDocumentSession(); + resetHistoryDbForTests(); + resetSaveTargetsForTests(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +async function authorizeTarget(docId: string): Promise { + const writes: Blob[] = []; + vi.stubGlobal('showSaveFilePicker', async () => ({ + name: 'Report.docx', + createWritable: async () => ({ + write: async (data: Blob) => { + writes.push(data); + }, + close: async () => {}, + }), + })); + expect(await saveToDiskFile(docId, file(), 'Document')).toBe('written'); + vi.stubGlobal('showSaveFilePicker', () => { + throw new Error('An existing authorized target should be reused'); + }); + return writes; +} + +it.each(['disabled-before-open', 'disabled-after-open', 'quota-stopped'])( + 'writes the authorized active file when recovery is %s', + async (mode) => { + if (mode === 'disabled-before-open') setAutosaveEnabled(false); + startDocumentSession({ docId: 'active', title: 'Report.docx', origin: 'local' }); + const writes = await authorizeTarget('active'); + if (mode === 'disabled-after-open') setAutosaveEnabled(false); + if (mode === 'quota-stopped') { + vi.stubGlobal('navigator', { storage: { estimate: async () => ({ quota: 2 }) } }); + stream.requestSaveDocument.mockResolvedValue(file()); + expect(await takeSnapshot()).toBeNull(); + } + expect(isAutosaveSessionActive()).toBe(false); + + const updated = file(); + expect(await stream.diskWriter!(updated)).toBe('written'); + expect(writes).toEqual([expect.any(Blob), updated]); + }, +); + +it('routes direct autosave callers to the newest identity while recovery is disabled', async () => { + setAutosaveEnabled(false); + await beginAutosaveSession({ docId: 'old', title: 'Report.docx', origin: 'local' }); + const oldWrites = await authorizeTarget('old'); + await beginAutosaveSession({ docId: 'new', title: 'Report.docx', origin: 'local' }); + const newWrites = await authorizeTarget('new'); + expect(await stream.diskWriter!(file())).toBe('written'); + expect(oldWrites).toHaveLength(1); + expect(newWrites).toHaveLength(2); +}); + +it('marks the active recovery row saved after autosave is stopped', async () => { + await beginAutosaveSession({ docId: 'active', title: 'Report.docx', origin: 'local' }); + await putSnapshot({ id: 'active', title: 'Report.docx', origin: 'local', bytes: new Uint8Array([1]) }); + setAutosaveEnabled(false); + stream.saved!(); + await vi.waitFor(async () => expect((await getDoc('active'))?.savedToDiskAt).toEqual(expect.any(Number))); +}); + +it('stops recovery without losing file identity until the document is explicitly closed', async () => { + await beginAutosaveSession({ docId: 'active', title: 'Report.docx', origin: 'local' }); + const writes = await authorizeTarget('active'); + stopAutosaveSession(); + expect(getActiveDocumentId()).toBe('active'); + expect(await stream.diskWriter!(file())).toBe('written'); + expect(writes).toHaveLength(2); + + clearDocumentSession(); + window.history.replaceState(null, '', '/editor?saved=active'); + expect(getActiveDocumentId()).toBeNull(); + expect(await stream.diskWriter!(file())).toBe('unavailable'); + expect(writes).toHaveLength(2); +}); + +it('does not authorize disk routing from an unvalidated saved URL', async () => { + window.history.replaceState(null, '', '/editor?saved=untrusted'); + const writes = await authorizeTarget('untrusted'); + expect(await stream.diskWriter!(file())).toBe('unavailable'); + expect(writes).toHaveLength(1); +}); diff --git a/test/unit/local-source-open.test.ts b/test/unit/local-source-open.test.ts index 390231fc..0899261f 100644 --- a/test/unit/local-source-open.test.ts +++ b/test/unit/local-source-open.test.ts @@ -94,7 +94,7 @@ describe('local open retains its reload source', () => { window.history.replaceState(null, '', '/editor?saved=old'); convert.mockRejectedValueOnce(new Error('conversion failed')); vi.spyOn(console, 'error').mockImplementation(() => {}); - await openLocalFile(new File(['other'], 'Other.docx')); + await expect(openLocalFile(new File(['other'], 'Other.docx'))).rejects.toThrow('conversion failed'); expect(await (await readActiveSource('old'))?.text()).toBe('original bytes'); expect(new URL(location.href).searchParams.get('saved')).toBe('old'); }); @@ -112,7 +112,7 @@ describe('local open retains its reload source', () => { await vi.waitFor(() => expect(read).toHaveBeenCalled()); convert.mockRejectedValueOnce(new Error('conversion failed')); vi.spyOn(console, 'error').mockImplementation(() => {}); - await openLocalFile(new File(['failed'], 'Failed.docx')); + await expect(openLocalFile(new File(['failed'], 'Failed.docx'))).rejects.toThrow('conversion failed'); finish(new Uint8Array([4, 5]).buffer); await first; const id = new URL(location.href).searchParams.get('saved'); @@ -150,7 +150,7 @@ describe('local open retains its reload source', () => { ), ); finish(new TextEncoder().encode('original bytes').buffer); - await first; + await expect(first).rejects.toMatchObject({ name: 'AbortError' }); expect(sessionStorage.getItem('document-active-source')).toBeNull(); } finally { for (const [type, listener, options] of listeners.mock.calls) window.removeEventListener(type, listener, options); @@ -172,7 +172,7 @@ describe('local open retains its reload source', () => { await openLocalFile(new File(['newer'], 'Newer.docx')); const currentId = new URL(location.href).searchParams.get('saved')!; finish(new TextEncoder().encode('original bytes').buffer); - await first; + await expect(first).rejects.toMatchObject({ name: 'AbortError' }); expect(new URL(location.href).searchParams.get('saved')).toBe(currentId); expect(await (await readActiveSource(currentId))?.text()).toBe('newer'); }); diff --git a/test/unit/open-ownership.test.ts b/test/unit/open-ownership.test.ts index c4e60be4..9e61a066 100644 --- a/test/unit/open-ownership.test.ts +++ b/test/unit/open-ownership.test.ts @@ -11,7 +11,7 @@ vi.mock('ranuts/utils', async (original) => ({ })); vi.mock('../../lib/converter', () => ({ handleDocumentOperation: convert, loadEditorApi: async () => {} })); vi.mock('../../lib/history/session', () => ({ startDocumentSession: start, newDocumentId: () => 'source-id' })); -vi.mock('../../lib/history/autosave', () => ({ stopAutosaveSession: vi.fn() })); +vi.mock('../../lib/history/autosave', () => ({ clearDocumentSession: vi.fn() })); vi.mock('../../lib/active-document-source', () => ({ rememberActiveSource: remember, clearActiveSource: async () => {}, @@ -63,7 +63,7 @@ describe('open ownership across entry points', () => { ), ); finish(true); - await local; + await expect(local).rejects.toMatchObject({ name: 'AbortError' }); expect(start).not.toHaveBeenCalled(); } finally { for (const [type, listener, options] of listeners.mock.calls) window.removeEventListener(type, listener, options); @@ -83,7 +83,7 @@ describe('open ownership across entry points', () => { await vi.waitFor(() => expect(remember).toHaveBeenCalled()); await events.RENDER_OFFICE(chunk); finish(true); - await local; + await expect(local).rejects.toMatchObject({ name: 'AbortError' }); expect(start).not.toHaveBeenCalled(); }); it.each(['open', 'close'])('cancels a remote fetch on desktop %s', async (action) => { diff --git a/test/unit/save-stream-lifecycle.test.ts b/test/unit/save-stream-lifecycle.test.ts index 862cfb64..b7c50b92 100644 --- a/test/unit/save-stream-lifecycle.test.ts +++ b/test/unit/save-stream-lifecycle.test.ts @@ -2,7 +2,8 @@ import 'fake-indexeddb/auto'; import { IDBFactory } from 'fake-indexeddb'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; vi.mock('../../lib/history/autosave', () => ({ - getAutosaveDocId: () => 'save-document', + getActiveDocumentId: () => 'save-document', + clearDocumentSession: vi.fn(), beginAutosaveSession: vi.fn(), isAutosaveEnabled: () => true, setAutosaveEnabled: vi.fn(), diff --git a/test/unit/sw-assistant-reuse.test.ts b/test/unit/sw-assistant-reuse.test.ts new file mode 100644 index 00000000..421a0d0f --- /dev/null +++ b/test/unit/sw-assistant-reuse.test.ts @@ -0,0 +1,285 @@ +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import { expect, it, vi } from 'vitest'; + +const origin = 'https://editor.example'; +const currentCore = 'document-editor-core-200'; +const oldCore = 'document-editor-core-100'; +const runtimeAsset = '/assets/cpu-abc123.wasm'; +const helperAsset = '/assets/cpu-helper-def456.js'; +const markerKey = '/__document-assistant-prepared__/cpu'; +const stateCache = 'document-assistant-capabilities'; +const url = (path: string) => new URL(path, origin).href; +const prepared = () => new Response('1', { headers: { 'X-Document-Assistant-Prepared': '1' } }); + +function worker( + initial: Record = {}, + options: { + fetchAssets?: (assets: string[]) => Promise; + unreadable?: 'open' | 'match' | 'clone'; + } = {}, +) { + const stores = new Map( + Object.entries(initial).map(([name, entries]) => [ + name, + new Map(entries.map(([path, response]) => [url(path), response])), + ]), + ); + const listeners = new Map void>(); + const downloaded: string[] = []; + const matched: Response[] = []; + const claim = vi.fn(); + const open = async (name: string) => { + if (name === oldCore && options.unreadable === 'open') throw Error('cache unavailable'); + if (!stores.has(name)) stores.set(name, new Map()); + const store = stores.get(name)!; + return { + match: async (path: string) => { + if (name === oldCore && path.includes('/assets/')) { + if (options.unreadable === 'match') throw Error('entry unavailable'); + if (options.unreadable === 'clone') + return { + status: 200, + clone: () => { + throw Error('unreadable body'); + }, + }; + } + const response = store.get(url(path))?.clone(); + if (response) matched.push(response); + return response; + }, + put: async (path: string, response: Response) => { + store.set(url(path), response); + }, + addAll: async (paths: string[]) => { + const assets = paths.filter((path) => path.startsWith('/assets/')); + downloaded.push(...assets); + if (assets.length) await options.fetchAssets?.(assets); + for (const path of paths) + store.set(url(path), new Response(path.includes('/assets/') ? 'network bytes' : '')); + }, + keys: async () => [...store.keys()].map((path) => new Request(path)), + delete: async (request: Request) => store.delete(request.url), + }; + }; + const source = readFileSync('public/sw.js', 'utf8') + .replaceAll('SW_VERSION_PLACEHOLDER', '200') + .replaceAll('VENDOR_VERSION_PLACEHOLDER', 'same') + .replace( + 'const ASSISTANT_CAPABILITY_ASSETS = {};', + `const ASSISTANT_CAPABILITY_ASSETS = ${JSON.stringify({ cpu: [runtimeAsset, helperAsset] })};`, + ); + runInNewContext(source, { + URL, + Promise, + Date, + Response, + Request, + Headers, + Set, + Map, + self: { + location: { origin }, + addEventListener: (type: string, listener: (event: any) => void) => listeners.set(type, listener), + skipWaiting: () => {}, + clients: { matchAll: async () => [], claim }, + }, + caches: { + open, + keys: async () => [...stores.keys()], + has: async (name: string) => stores.has(name), + delete: async (name: string) => stores.delete(name), + }, + }); + async function event(type: string, data?: unknown) { + let job: Promise | undefined; + const reply = vi.fn(); + listeners.get(type)!({ + data, + ports: [{ postMessage: reply }], + waitUntil: (promise: Promise) => { + job = promise; + }, + }); + await job; + return reply; + } + return { event, stores, downloaded, matched, claim }; +} + +it('reuses identical build assets from an earlier owned core when the network is unavailable', async () => { + const w = worker( + { + [stateCache]: [[markerKey, prepared()]], + [oldCore]: [ + [ + runtimeAsset, + new Response(new Uint8Array([0, 97, 115, 109]), { + headers: { 'Content-Type': 'application/wasm', 'Cross-Origin-Resource-Policy': 'same-origin' }, + }), + ], + [helperAsset, new Response('cached helper')], + ], + }, + { + fetchAssets: async () => { + throw Error('offline'); + }, + }, + ); + await w.event('install'); + const copied = w.stores.get(currentCore)!.get(url(runtimeAsset))!; + expect([...new Uint8Array(await copied.clone().arrayBuffer())]).toEqual([0, 97, 115, 109]); + expect(copied.headers.get('Content-Type')).toBe('application/wasm'); + expect(copied.headers.get('Cross-Origin-Resource-Policy')).toBe('same-origin'); + expect(w.matched).not.toContain(copied); + expect(w.stores.get(oldCore)!.get(url(runtimeAsset))!.bodyUsed).toBe(false); + expect(w.downloaded).toEqual([]); + expect(w.stores.get(stateCache)!.has(url(markerKey))).toBe(true); +}); + +it('keeps current-core hits without downloading or reading an earlier core', async () => { + const w = worker( + { + [currentCore]: [ + [runtimeAsset, new Response('current runtime')], + [helperAsset, new Response('current helper')], + ], + }, + { unreadable: 'open' }, + ); + const reply = await w.event('message', { type: 'PREPARE_ASSISTANT', capability: 'cpu' }); + expect(reply).toHaveBeenCalledWith({ type: 'ASSISTANT_PREPARED', ok: true }); + expect(w.downloaded).toEqual([]); + expect(await w.stores.get(currentCore)!.get(url(runtimeAsset))!.text()).toBe('current runtime'); +}); + +it.each([ + ['a changed content hash', oldCore, '/assets/cpu-oldhash.wasm'], + ['a different query', oldCore, `${runtimeAsset}?old=1`], + ['another origin', oldCore, `https://outside.example${runtimeAsset}`], + ['an unrelated cache', 'model-provider-cache', runtimeAsset], + ['a vendor runtime cache', 'document-editor-runtime-same', runtimeAsset], +])('downloads the exact allowlisted URL instead of trusting %s', async (_label, name, path) => { + const w = worker({ [name]: [[path, new Response('untrusted or stale bytes')]] }); + await w.event('message', { type: 'PREPARE_ASSISTANT', capability: 'cpu' }); + expect(w.downloaded).toEqual([runtimeAsset, helperAsset]); + expect(await w.stores.get(currentCore)!.get(url(runtimeAsset))!.text()).toBe('network bytes'); +}); + +it.each([undefined, 'open', 'match', 'clone'] as const)( + 'fetches safely when an earlier cache is missing or unreadable: %s', + async (unreadable) => { + const w = worker({ [oldCore]: [] }, { unreadable }); + const reply = await w.event('message', { type: 'PREPARE_ASSISTANT', capability: 'cpu' }); + expect(reply).toHaveBeenCalledWith({ type: 'ASSISTANT_PREPARED', ok: true }); + expect(w.downloaded).toEqual([runtimeAsset, helperAsset]); + }, +); + +it('does not let a caller add URLs to the build allowlist', async () => { + const w = worker(); + await w.event('message', { + type: 'PREPARE_ASSISTANT', + capability: 'cpu', + urls: ['/private/file', 'https://outside.example/weights'], + }); + const rejected = await w.event('message', { type: 'PREPARE_ASSISTANT', capability: '/private/file' }); + expect(rejected).toHaveBeenCalledWith({ type: 'ASSISTANT_PREPARED', ok: false }); + expect(w.downloaded).toEqual([runtimeAsset, helperAsset]); +}); + +it('shares concurrent preparation and waits for every asset before committing markers', async () => { + let release!: () => void; + let started!: () => void; + const fetching = new Promise((resolve) => { + started = resolve; + }); + const pending = new Promise((resolve) => { + release = resolve; + }); + const w = worker( + { [oldCore]: [[runtimeAsset, new Response('cached runtime')]] }, + { + fetchAssets: async () => { + started(); + await pending; + }, + }, + ); + const first = w.event('message', { type: 'PREPARE_ASSISTANT', capability: 'cpu' }); + const second = w.event('message', { type: 'PREPARE_ASSISTANT', capability: 'cpu' }); + await fetching; + expect(w.downloaded).toEqual([helperAsset]); + expect(w.stores.get(currentCore)!.has(url(markerKey))).toBe(false); + expect(w.stores.get(stateCache)?.has(url(markerKey)) ?? false).toBe(false); + release(); + const replies = await Promise.all([first, second]); + for (const reply of replies) expect(reply).toHaveBeenCalledWith({ type: 'ASSISTANT_PREPARED', ok: true }); + expect(w.stores.get(currentCore)!.has(url(markerKey))).toBe(true); +}); + +it('preserves earlier caches and rejects activation when any prepared asset is unavailable', async () => { + const w = worker( + { + [oldCore]: [ + [markerKey, prepared()], + [runtimeAsset, new Response('cached runtime')], + ], + }, + { + fetchAssets: async () => { + throw Error('offline'); + }, + }, + ); + await expect(w.event('activate')).rejects.toThrow('offline'); + expect(w.stores.has(oldCore)).toBe(true); + expect(w.stores.get(currentCore)!.has(url(markerKey))).toBe(false); + expect(w.claim).not.toHaveBeenCalled(); + expect(w.downloaded).toEqual([helperAsset]); +}); + +it('does not reuse an unsuccessful response from an earlier core', async () => { + const w = worker({ [oldCore]: [[runtimeAsset, new Response('not available', { status: 404 })]] }); + await w.event('message', { type: 'PREPARE_ASSISTANT', capability: 'cpu' }); + expect(w.downloaded).toEqual([runtimeAsset, helperAsset]); + expect(await w.stores.get(currentCore)!.get(url(runtimeAsset))!.text()).toBe('network bytes'); +}); + +it('finishes the prepared graph before retiring an earlier core and claiming clients', async () => { + let release!: () => void; + let started!: () => void; + const fetching = new Promise((resolve) => { + started = resolve; + }); + const pending = new Promise((resolve) => { + release = resolve; + }); + const w = worker( + { + [oldCore]: [ + [markerKey, prepared()], + [runtimeAsset, new Response('cached runtime')], + ], + }, + { + fetchAssets: async () => { + started(); + await pending; + }, + }, + ); + const activation = w.event('activate'); + await fetching; + expect(w.stores.has(oldCore)).toBe(true); + expect(w.stores.get(currentCore)!.has(url(markerKey))).toBe(false); + expect(w.claim).not.toHaveBeenCalled(); + release(); + await activation; + expect(w.downloaded).toEqual([helperAsset]); + expect(w.stores.has(oldCore)).toBe(false); + expect(w.stores.get(currentCore)!.has(url(markerKey))).toBe(true); + expect(w.claim).toHaveBeenCalledOnce(); +});