From f0cfcc164be7f1cc42205db6ac3077ccf9a0c01c Mon Sep 17 00:00:00 2001 From: chaxus Date: Tue, 13 Oct 2026 21:50:52 +0800 Subject: [PATCH] fix(editor): protect saves and recovery, refresh workspace checks --- CHANGELOG.md | 9 ++ CLAUDE.md | 10 ++- bin/workspace-aliases.d.mts | 2 + bin/workspace-aliases.mjs | 27 ++++++ .../2026-10-13-save-recovery-test-workflow.md | 55 ++++++++++++ lib/history/autosave.ts | 27 ++++++ lib/history/index.ts | 14 ++-- lib/history/store.ts | 37 ++++---- lib/onlyoffice/save-stream.ts | 16 ++-- lib/save-target.ts | 56 +++++++++---- packages/converter/src/file-meta.ts | 5 +- test/e2e/save-to-file.spec.ts | 56 +++++++++++++ test/unit/history-autosave.test.ts | 43 ++++++++++ test/unit/history-handle-cleanup.test.ts | 64 ++++++++++++++ test/unit/save-stream-lifecycle.test.ts | 81 ++++++++++++++++++ test/unit/save-stream-origin.test.ts | 2 +- test/unit/save-target.test.ts | 84 ++++++++++++++++++- test/unit/workspace-aliases.test.ts | 55 ++++++++++++ vite.config.ts | 8 +- vitest.config.ts | 25 +++--- 20 files changed, 607 insertions(+), 69 deletions(-) create mode 100644 bin/workspace-aliases.d.mts create mode 100644 bin/workspace-aliases.mjs create mode 100644 docs/explorations/2026-10-13-save-recovery-test-workflow.md create mode 100644 test/unit/history-handle-cleanup.test.ts create mode 100644 test/unit/save-stream-lifecycle.test.ts create mode 100644 test/unit/workspace-aliases.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 983c8e6c4..53f5dfe57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ notes. Entries describe what users experience, not internal refactors. ## [Unreleased] +### Fixed + +- Cancelling a save keeps the unsaved-changes warning and recovery status intact. +- Exporting another format, such as PDF, preserves the original document file + and its ordinary save target, including when the export is cancelled or fails. +- Deleting, clearing or expiring local recovery records also forgets their saved + file associations. Unchanged documents no longer create duplicate recovery + versions or repeatedly run a full export. + ### Added - **Keep document structure when writing an assistant draft.** Word headings, diff --git a/CLAUDE.md b/CLAUDE.md index 4284a0201..b2150e025 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -203,9 +203,13 @@ test/unit/ test/setup/vitest.ts # 全局 mock:matchMedia、URL.createObjectURL、localStorage ``` -覆盖率阈值(全局):语句 34%、分支 25%、函数 35%、行 35%(当前实际约 -45%/49%/49%,具体以 `pnpm run test:coverage` 输出为准,不要在本文件里维护 -逐文件数字——它们过期得很快)。 +覆盖率统计 `lib/**/*.ts` 与 `packages/*/src/**/*.ts`,排除类型声明。 +全局最低门槛保留在 `vitest.config.ts`,历史、shared、converter、agent-core 与 chat-ui +另有独立模块门槛。实际数字以 `pnpm run test:coverage` 为准。 + +开发与 Vitest 通过 `bin/workspace-aliases.mjs` 从 workspace 的 `exports` 派生源码别名, +避免读取旧 `dist`。生产构建继续使用编译包,并由 `bin/build.sh` 先重建。源码别名 +必须精确匹配包入口与子路径;WebLLM 的开发 Worker 响应策略同时覆盖源码路径。 **注意事项:** diff --git a/bin/workspace-aliases.d.mts b/bin/workspace-aliases.d.mts new file mode 100644 index 000000000..26a7bd3db --- /dev/null +++ b/bin/workspace-aliases.d.mts @@ -0,0 +1,2 @@ +import type { Alias } from 'vite'; +export function workspaceSourceAliases(root: string): Alias[]; diff --git a/bin/workspace-aliases.mjs b/bin/workspace-aliases.mjs new file mode 100644 index 000000000..04216d2e1 --- /dev/null +++ b/bin/workspace-aliases.mjs @@ -0,0 +1,27 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { resolve } from 'node:path'; + +/** + * Resolve local exports to source in dev/tests while production uses built packages. + * @param {string} root + * @returns {import('vite').Alias[]} + */ +export function workspaceSourceAliases(root) { + /** @type {import('vite').Alias[]} */ + const aliases = []; + for (const dir of readdirSync(resolve(root, 'packages'), { withFileTypes: true })) { + if (!dir.isDirectory()) continue; + const packageRoot = resolve(root, 'packages', dir.name); + const manifest = JSON.parse(readFileSync(resolve(packageRoot, 'package.json'), 'utf8')); + for (const [subpath, entry] of Object.entries(manifest.exports)) { + const specifier = manifest.name + (subpath === '.' ? '' : subpath.slice(1)); + const source = resolve(packageRoot, entry.import.replace('./dist/', './src/').replace(/\.js$/, '.ts')); + const escape = (part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + aliases.push({ + find: new RegExp('^' + specifier.split('*').map(escape).join('(.+)') + '$'), + replacement: source.replace('*', '$1'), + }); + } + } + return aliases; +} diff --git a/docs/explorations/2026-10-13-save-recovery-test-workflow.md b/docs/explorations/2026-10-13-save-recovery-test-workflow.md new file mode 100644 index 000000000..c017c6669 --- /dev/null +++ b/docs/explorations/2026-10-13-save-recovery-test-workflow.md @@ -0,0 +1,55 @@ +# Save, recovery and workspace test repairs + +Scope: preserve cancellation outcomes, isolate cross-format exports, retire +history file links, avoid duplicate recovery exports, use source in development +and tests, and measure all application/workspace code with module gates. + +## Behavior and ownership + +- Disk writers return `written`, `cancelled` or `unavailable`. The converter's + download adapter preserves its underlying boolean outcome. Only a committed + write/download clears dirty state or updates the recovery row's disk-save time. +- Another format uses a separate picker and never replaces the ordinary document + target. An initial export is identified by the document store's original format. + Cancelling or failing an export preserves the original target. +- Document metadata, revisions and handles are deleted in one IndexedDB + transaction. In-memory links retire on commit, not on an aborted deletion. + Persisted targets are re-read before saving so another tab's deletion is honored. + Chosen targets register before asynchronous disk I/O; finishing a write never + recreates a link deleted while that write was pending. When persistence is + unavailable, the session-only fallback still works. +- Recovery compares editor frame, undo index, point identity and a captured copy + of undo-item references. SDK changes can append or replace items without moving + the point; item count alone is insufficient (for example spreadsheet sliders). + The signature is captured before export, so changes made while exporting still + need another recovery point. Unknown SDK shapes retain the conservative cadence. +- Development and Vitest aliases derive from workspace exports, including exact + and wildcard subpaths. Production still builds and consumes package output. + Source Worker URLs retain the model Worker response policy. +- Coverage includes `lib` and every workspace `src`, with separate module gates. + Existing global gates and the seven-day recovery retention remain unchanged. + +## Regression evidence + +Before production fixes, 12 new assertions failed for cancelled-save dirty state, +format overwrite, persisted/session handle cleanup and duplicate recovery. +A further failing export-write test demonstrated loss of the original link. +Two SDK-history cases demonstrated missed appended/replaced undo items. + +Automated checks exercise committed bytes, dirty state, saved timestamps, +IndexedDB keys and recovery revisions rather than mirroring implementation text. +The source-resolution fixture contains deliberately stale compiled exports and +loads current source, exact/wildcard subpaths and an edited source module through +Vite. Chromium tests use real OPFS handles and actual editor exports to verify +cancelled-save warnings, PDF/DOCX isolation, subsequent saves and recovery. + +Disabling the workspace source aliases made its Vite fixture load the stale +compiled values and fail. Disabling cancellation and export-target guards made +both new Chromium save cases fail on their intended dirty-state and original-file +assertions; restoring the guards passed both against the production build. + +Final validation: 176 unit files / 5323 tests passed; expanded line coverage +85.06%, with all module gates passed. Production build and 10 Chromium cases +passed. Lint, TypeScript and formatting passed. Development Worker responses +returned the model policy on their source URL. The full Docker, +Cloudflare Pages and cross-browser suites remain CI responsibilities. diff --git a/lib/history/autosave.ts b/lib/history/autosave.ts index c97088509..d65892bc2 100644 --- a/lib/history/autosave.ts +++ b/lib/history/autosave.ts @@ -24,6 +24,7 @@ import { requestSaveDocument } from '../onlyoffice/save-stream'; import { getReadonlyMode } from '../onlyoffice/readonly'; import { getLastEditAt, hasUnsavedChanges } from '../unsaved-guard'; import { isEmbedMode } from '../embed-mode'; +import { captureSavePoint, type SavePoint } from '../onlyoffice/save-point'; import { extensionOf, markOpened, putSnapshot } from './store'; import type { HistoryDoc, HistoryOrigin } from './types'; @@ -130,6 +131,8 @@ interface ActiveSession { lastSnapshotAt: number; exporting: boolean; lastExportMs: number | null; + lastSnapshotPoint: SavePoint | null; + lastSnapshotItems: unknown[] | null; failures: number; holdsLock: boolean; releaseLock: (() => void) | null; @@ -187,6 +190,24 @@ export async function takeSnapshot(): Promise { const active = session; if (!active || active.cached || active.exporting || !active.ext) return null; + const point = captureSavePoint(); + const pointItems = (point?.head as { Items?: unknown[] } | undefined)?.Items; + const items = Array.isArray(pointItems) ? [...pointItems] : null; + const previous = active.lastSnapshotPoint; + const previousItems = active.lastSnapshotItems; + if ( + point && + previous && + point.frame === previous.frame && + point.index === previous.index && + point.head === previous.head && + items && + previousItems && + items.length === previousItems.length && + items.every((item, index) => item === previousItems[index]) + ) + return null; + active.exporting = true; const startedAt = Date.now(); try { @@ -219,6 +240,10 @@ export async function takeSnapshot(): Promise { // What this export cost sets the next interval: a slow device asks for // snapshots less often without anyone having to identify it as slow. active.lastExportMs = active.lastSnapshotAt - startedAt; + // Capture before export, so an edit made during conversion still needs a + // later recovery point. With no SDK history, retain the conservative cadence. + active.lastSnapshotPoint = point; + active.lastSnapshotItems = items; active.failures = 0; return doc; } catch (error) { @@ -324,6 +349,8 @@ export async function beginAutosaveSession(input: AutosaveSessionInput): Promise lastSnapshotAt: Date.now(), exporting: false, lastExportMs: null, + lastSnapshotPoint: null, + lastSnapshotItems: null, failures: 0, holdsLock: held, releaseLock: release, diff --git a/lib/history/index.ts b/lib/history/index.ts index 042c89514..f90dda158 100644 --- a/lib/history/index.ts +++ b/lib/history/index.ts @@ -6,7 +6,8 @@ import { setDiskWriter, setSavedToDiskListener } from '../onlyoffice/save-stream'; import { saveToDiskFile } from '../save-target'; import { getAutosaveDocId } from './autosave'; -import { markSavedToDisk, pruneExpired } from './store'; +import { extensionOf, markSavedToDisk, pruneExpired } from './store'; +import { getDocmentObj } from '@ranuts/shared/store'; export function initDocumentHistory(): void { // Expiry has to happen on its own, not when someone thinks to visit the @@ -19,12 +20,11 @@ export function initDocumentHistory(): void { // is wired here rather than inside the save channel. setDiskWriter(async (file) => { const docId = getAutosaveDocId(); - if (!docId) return false; - const outcome = await saveToDiskFile(docId, file, 'Document'); - // 'cancelled' means the user closed the picker: they decided not to save, - // so downloading a copy anyway would be answering a question they declined. - if (outcome === 'cancelled') return true; - return outcome === 'written'; + if (!docId) return 'unavailable'; + const sourceName = getDocmentObj()?.fileName || file.name; + return saveToDiskFile(docId, file, 'Document', { + rememberTarget: extensionOf(sourceName) === extensionOf(file.name), + }); }); setSavedToDiskListener(() => { diff --git a/lib/history/store.ts b/lib/history/store.ts index c0eae7f6f..41a0af43e 100644 --- a/lib/history/store.ts +++ b/lib/history/store.ts @@ -17,10 +17,11 @@ * Everything here resolves to a null/empty result when IndexedDB is missing; * see lib/history/db.ts for why. */ -import { BLOBS_BY_DOC, BLOBS_STORE, DOCS_STORE, requestToPromise, withStores } from './db'; +import { BLOBS_BY_DOC, BLOBS_STORE, DOCS_STORE, HANDLES_STORE, requestToPromise, withStores } from './db'; import type { HistoryDoc, HistoryOrigin, HistorySnapshot } from './types'; import { MAX_AGE_MS, expiresAt, hasUnsavedWork } from './types'; import { deleteDocumentSources } from '../active-document-source'; +import { clearSessionSaveTargets } from '../save-target'; /** Revisions kept per document: the newest plus two recovery points behind it. */ export const MAX_REVS_PER_DOC = 3; @@ -112,11 +113,14 @@ export async function storageBudget(): Promise { return MAX_TOTAL_BYTES; } -/** Delete every revision of one document. Caller owns the transaction. */ -async function deleteBlobsOf(tx: IDBTransaction, docId: string): Promise { +/** Delete all history data and its disk link atomically. Caller owns the transaction. */ +async function deleteDocumentData(tx: IDBTransaction, docId: string): Promise { const store = tx.objectStore(BLOBS_STORE); const keys = await requestToPromise(store.index(BLOBS_BY_DOC).getAllKeys(IDBKeyRange.only(docId))); for (const key of keys) store.delete(key); + tx.objectStore(DOCS_STORE).delete(docId); + tx.objectStore(HANDLES_STORE).delete(docId); + tx.addEventListener('complete', () => clearSessionSaveTargets(docId), { once: true }); } /** Drop revisions past MAX_REVS_PER_DOC, oldest first. Returns bytes freed. */ @@ -144,8 +148,7 @@ async function sweepExpired(tx: IDBTransaction, now: number): Promise const docs = await readAllDocs(tx); const expired = docs.filter((doc) => expiresAt(doc) <= now); for (const doc of expired) { - await deleteBlobsOf(tx, doc.id); - tx.objectStore(DOCS_STORE).delete(doc.id); + await deleteDocumentData(tx, doc.id); } return expired.map((doc) => doc.id); } @@ -164,8 +167,7 @@ async function enforceBudget(tx: IDBTransaction, budget: number, protectedId: st for (const doc of candidates) { if (total <= budget) break; - await deleteBlobsOf(tx, doc.id); - tx.objectStore(DOCS_STORE).delete(doc.id); + await deleteDocumentData(tx, doc.id); total -= doc.totalBytes || 0; } } @@ -201,7 +203,7 @@ async function writeSnapshot( const now = stamp(); const title = input.title.trim() || 'Untitled'; - return withStores([DOCS_STORE, BLOBS_STORE], 'readwrite', async (tx) => { + return withStores([DOCS_STORE, BLOBS_STORE, HANDLES_STORE], 'readwrite', async (tx) => { if (!watchSnapshotCancellation(tx, signal)) return null; const docs = tx.objectStore(DOCS_STORE); const existing = input.id ? ((await requestToPromise(docs.get(input.id))) as HistoryDoc | undefined) : undefined; @@ -287,13 +289,12 @@ export async function putSnapshot(input: SnapshotInput, signal?: AbortSignal): P async function evictColdest(protectedId?: string, signal?: AbortSignal): Promise { try { return ( - (await withStores([DOCS_STORE, BLOBS_STORE], 'readwrite', async (tx) => { + (await withStores([DOCS_STORE, BLOBS_STORE, HANDLES_STORE], 'readwrite', async (tx) => { if (!watchSnapshotCancellation(tx, signal)) return false; const docs = (await readAllDocs(tx)).filter((doc) => doc.id !== protectedId); if (!docs.length) return false; const coldest = docs.sort((a, b) => a.lastOpenedAt - b.lastOpenedAt)[0]; - await deleteBlobsOf(tx, coldest.id); - tx.objectStore(DOCS_STORE).delete(coldest.id); + await deleteDocumentData(tx, coldest.id); return true; })) ?? false ); @@ -380,9 +381,8 @@ export async function deleteDoc(id: string): Promise { try { const originalsDeleted = await deleteDocumentSources(id); const historyDeleted = - (await withStores([DOCS_STORE, BLOBS_STORE], 'readwrite', async (tx) => { - await deleteBlobsOf(tx, id); - tx.objectStore(DOCS_STORE).delete(id); + (await withStores([DOCS_STORE, BLOBS_STORE, HANDLES_STORE], 'readwrite', async (tx) => { + await deleteDocumentData(tx, id); return true; })) ?? false; return originalsDeleted && historyDeleted; @@ -400,9 +400,11 @@ export async function clearAllHistory(): Promise { try { const originalsDeleted = await deleteDocumentSources(); const historyDeleted = - (await withStores([DOCS_STORE, BLOBS_STORE], 'readwrite', async (tx) => { + (await withStores([DOCS_STORE, BLOBS_STORE, HANDLES_STORE], 'readwrite', async (tx) => { tx.objectStore(DOCS_STORE).clear(); tx.objectStore(BLOBS_STORE).clear(); + tx.objectStore(HANDLES_STORE).clear(); + tx.addEventListener('complete', () => clearSessionSaveTargets(), { once: true }); return true; })) ?? false; return originalsDeleted && historyDeleted; @@ -455,7 +457,10 @@ export function resetHistoryClockForTests(): void { export async function pruneExpired(): Promise { try { - return (await withStores([DOCS_STORE, BLOBS_STORE], 'readwrite', (tx) => sweepExpired(tx, Date.now()))) ?? []; + return ( + (await withStores([DOCS_STORE, BLOBS_STORE, HANDLES_STORE], 'readwrite', (tx) => sweepExpired(tx, Date.now()))) ?? + [] + ); } catch { return []; } diff --git a/lib/onlyoffice/save-stream.ts b/lib/onlyoffice/save-stream.ts index f3c725ee4..c167a97d8 100644 --- a/lib/onlyoffice/save-stream.ts +++ b/lib/onlyoffice/save-stream.ts @@ -7,6 +7,7 @@ import { getDocumentOpenError, waitForDocumentContentReady } from './open-state' import { getReadonlyMode } from './readonly'; import { isEmbedMode } from '../embed-mode'; import { markDocumentSaved } from '../unsaved-guard'; +import type { SaveOutcome } from '../save-target'; /** * Notified when bytes actually reach the user's disk. @@ -27,12 +28,12 @@ export function setSavedToDiskListener(listener: (() => void) | null): void { * * Injected for the same reason as the listener above -- it needs the document's * identity, which lives in the history layer, and that layer exports through - * this module. Returns true when the user's document is saved and there is - * nothing left to do; false means "carry on with the download". + * this module. Returns the write outcome: cancellation stops without clearing dirty state, + * and unavailable writers fall back to the download. */ -let diskWriter: ((file: File) => Promise) | null = null; +let diskWriter: ((file: File) => Promise) | null = null; -export function setDiskWriter(writer: ((file: File) => Promise) | null): void { +export function setDiskWriter(writer: ((file: File) => Promise) | null): void { diskWriter = writer; } @@ -139,12 +140,13 @@ function routeSavedFile(file: File): void { // view: the bytes are on the user's disk. void (async () => { try { - if (await diskWriter?.(file)) { + const outcome = await diskWriter?.(file); + if (outcome === 'cancelled') return; + if (outcome === 'written') { reachedDisk(); return; } - await saveFileToDisk(file, file.name); - reachedDisk(); + if (await saveFileToDisk(file, file.name)) reachedDisk(); } catch (error) { notifyOperationFailed(error); } diff --git a/lib/save-target.ts b/lib/save-target.ts index 9db7b70f9..304df1fda 100644 --- a/lib/save-target.ts +++ b/lib/save-target.ts @@ -61,35 +61,50 @@ export function canWriteToDisk(): boolean { * exactly the session the user is in the middle of. */ const sessionTargets = new Map(); +const persistedTargets = new Set(); + +/** Retire cached links after their IndexedDB deletion has committed. */ +export function clearSessionSaveTargets(docId?: string): void { + if (docId === undefined) { + sessionTargets.clear(); + persistedTargets.clear(); + } else { + sessionTargets.delete(docId); + persistedTargets.delete(docId); + } +} async function readTarget(docId: string): Promise { const held = sessionTargets.get(docId); - if (held) return held; + if (held && !persistedTargets.has(docId)) return held; try { - return ( - (await withStores([HANDLES_STORE], 'readonly', async (tx) => { - return ((await requestToPromise(tx.objectStore(HANDLES_STORE).get(docId))) as StoredTarget | undefined) ?? null; - })) ?? null - ); + const record = await withStores([HANDLES_STORE], 'readonly', async (tx) => ({ + target: ((await requestToPromise(tx.objectStore(HANDLES_STORE).get(docId))) as StoredTarget | undefined) ?? null, + })); + if (!record) return held ?? null; + // A different tab may have deleted this link since our last save. + if (!record.target) clearSessionSaveTargets(docId); + return record.target; } catch { - return null; + return held ?? null; } } async function writeTarget(target: StoredTarget): Promise { sessionTargets.set(target.docId, target); try { - await withStores([HANDLES_STORE], 'readwrite', async (tx) => { + const stored = await withStores([HANDLES_STORE], 'readwrite', async (tx) => { tx.objectStore(HANDLES_STORE).put(target); return true; }); + if (stored) persistedTargets.add(target.docId); } catch { // Not being able to remember the file costs a picker next time, nothing more. } } export async function forgetSaveTarget(docId: string): Promise { - sessionTargets.delete(docId); + clearSessionSaveTargets(docId); try { await withStores([HANDLES_STORE], 'readwrite', async (tx) => { tx.objectStore(HANDLES_STORE).delete(docId); @@ -135,12 +150,20 @@ function notifySaved(name: string): void { * offers the picker instead of failing the same way again; that doubles as the * way to save somewhere else. */ -export async function saveToDiskFile(docId: string, file: File, description: string): Promise { +export async function saveToDiskFile( + docId: string, + file: File, + description: string, + options: { rememberTarget?: boolean } = {}, +): Promise { const picker = (globalThis as { showSaveFilePicker?: SavePicker }).showSaveFilePicker; if (typeof picker !== 'function') return 'unavailable'; const existing = await readTarget(docId); - let handle = existing?.handle ?? null; + const extensionOf = (name: string) => /\.([a-z0-9]+)$/i.exec(name)?.[1]?.toLowerCase(); + const isExport = + options.rememberTarget === false || (!!existing && extensionOf(existing.name) !== extensionOf(file.name)); + let handle = isExport ? null : (existing?.handle ?? null); if (handle && !(await ensureWritable(handle))) { await forgetSaveTarget(docId); @@ -166,23 +189,28 @@ export async function saveToDiskFile(docId: string, file: File, description: str } } + // Establish the chosen link before asynchronous I/O. History deletion can + // then retire it while the write runs, without a late commit reviving it. + if (!isExport && handle !== existing?.handle) { + await writeTarget({ docId, handle, name: handle.name || file.name, linkedAt: Date.now() }); + } + try { const writable = await handle.createWritable(); await writable.write(file); await writable.close(); } catch { - await forgetSaveTarget(docId); + if (!isExport) await forgetSaveTarget(docId); return 'unavailable'; } - await writeTarget({ docId, handle, name: handle.name || file.name, linkedAt: Date.now() }); notifySaved(handle.name || file.name); return 'written'; } /** Test seam: forget every handle this page is holding. */ export function resetSaveTargetsForTests(): void { - sessionTargets.clear(); + clearSessionSaveTargets(); } /** The file name this document writes to, if it is linked to one. */ diff --git a/packages/converter/src/file-meta.ts b/packages/converter/src/file-meta.ts index 1e230ebbe..33300e4e0 100644 --- a/packages/converter/src/file-meta.ts +++ b/packages/converter/src/file-meta.ts @@ -48,17 +48,18 @@ const FILE_DESCRIPTION_MAP: Record = { * failure rejects so the caller can surface it. Shared by the convert-and- * download path and the v9 file-stream save path (lib/onlyoffice-editor.ts). */ -export async function saveFileToDisk(data: Blob | Uint8Array, fileName: string, mimeType?: string): Promise { +export async function saveFileToDisk(data: Blob | Uint8Array, fileName: string, mimeType?: string): Promise { const extension = fileName.split('.').pop()?.toLowerCase() || ''; const written = await ranutsSaveFileToDisk(data, fileName, { mimeType: mimeType || getDocumentMimeType(fileName), description: FILE_DESCRIPTION_MAP[extension] || 'Document', }); - if (!written) return; + if (!written) return false; // ranui/message registers a global `window.message` toast API (untyped). (window as unknown as { message?: { success?: (msg: string) => void } }).message?.success?.( `${t('fileSavedSuccess')}${fileName}`, ); + return true; } export const MIME_MAP: Record = { diff --git a/test/e2e/save-to-file.spec.ts b/test/e2e/save-to-file.spec.ts index 8a3e393c1..9f408f60d 100644 --- a/test/e2e/save-to-file.spec.ts +++ b/test/e2e/save-to-file.spec.ts @@ -160,6 +160,62 @@ test.describe('saving into the document own file (real editor)', () => { test.skip(!writableStreams, 'this engine has no origin-private writable streams to stub the picker with'); }); + test('cancelling a save keeps the unsaved-changes warning armed', async ({ page }) => { + await page.goto('/editor?new=docx'); + await waitForEditorReady(page); + const sdk = page.frameLocator('iframe[name="frameEditor"]').locator('#editor_sdk'); + await sdk.click({ position: { x: 300, y: 200 } }); + await page.keyboard.type('work that has not reached disk', { delay: 30 }); + await page.evaluate(() => { + (window as unknown as { showSaveFilePicker: () => Promise }).showSaveFilePicker = + async () => { + (window as any).__pickerCalls += 1; + throw new DOMException('Cancelled', 'AbortError'); + }; + }); + await page.keyboard.press('Control+s'); + await expect.poll(() => pickerCalls(page), { timeout: 120_000 }).toBe(1); + expect(await writesCommitted(page)).toBe(0); + expect(await page.evaluate(() => !window.dispatchEvent(new Event('beforeunload', { cancelable: true })))).toBe( + true, + ); + }); + + test('exporting PDF preserves the DOCX file and its next save target', async ({ page }) => { + await page.goto('/editor?new=docx'); + await waitForEditorReady(page); + const sdk = page.frameLocator('iframe[name="frameEditor"]').locator('#editor_sdk'); + await sdk.click({ position: { x: 300, y: 200 } }); + await page.keyboard.type('content saved in the original document', { delay: 30 }); + await page.keyboard.press('Control+s'); + await waitForWrites(page, 1); + const original = await savedBytes(page); + await page.evaluate(() => { + (window as unknown as { showSaveFilePicker: () => Promise }).showSaveFilePicker = + async () => { + (window as any).__pickerCalls += 1; + return (await navigator.storage.getDirectory()).getFileHandle('saved-export.pdf', { create: true }); + }; + const frame = document.querySelector('iframe[name="frameEditor"]')!; + const win = frame.contentWindow as any; + win.Asc.editor.asc_DownloadAs(new win.Asc.asc_CDownloadOptions(513)); + }); + await waitForWrites(page, 2); + expect(await savedBytes(page)).toEqual(original); + expect(await pickerCalls(page)).toBe(2); + expect( + await page.evaluate(async () => { + const handle = await (await navigator.storage.getDirectory()).getFileHandle('saved-export.pdf'); + return (await handle.getFile()).slice(0, 4).text(); + }), + ).toBe('%PDF'); + await sdk.click({ position: { x: 300, y: 200 } }); + await page.keyboard.press('Control+s'); + await waitForWrites(page, 3); + expect(await pickerCalls(page)).toBe(2); + expect(await savedText(page)).toMatch(/content saved in the original document/i); + }); + test('picks a file once, then writes to it on every later save', async ({ page }) => { const dir = join('test-results', 'save-to-file'); mkdirSync(dir, { recursive: true }); diff --git a/test/unit/history-autosave.test.ts b/test/unit/history-autosave.test.ts index e08afb330..58ec49220 100644 --- a/test/unit/history-autosave.test.ts +++ b/test/unit/history-autosave.test.ts @@ -149,6 +149,49 @@ describe('autosave session', () => { expect((await listDocs()).total).toBe(1); }); + it('keeps distinct recovery points and skips exports of an unchanged SDK history', async () => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const history = { Index: 0, Points: [{ Items: [{}] }] }; + Object.assign(frame.contentWindow!, { AscCommon: { History: history }, Asc: { editor: {} } }); + try { + requestSaveDocument.mockResolvedValue(fileOf([1])); + await beginAutosaveSession({ docId: 'report', title: 'Report.docx', origin: 'local' }); + await takeSnapshot(); + expect(await takeSnapshot()).toBeNull(); + expect((await listDocs()).items[0].revCount).toBe(1); + // Undo and a new edit can reuse the same index with a different point. + history.Points[0] = { Items: [{}] }; + requestSaveDocument.mockResolvedValue(fileOf([2])); + await takeSnapshot(); + expect((await listDocs()).items[0].revCount).toBe(2); + expect((await getLatestSnapshot('report'))?.bytes[0]).toBe(2); + expect(requestSaveDocument).toHaveBeenCalledTimes(2); + } finally { + frame.remove(); + } + }); + + it.each(['append', 'replace'])('recovers %s changes inside the same undo point', async (mode) => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const history = { Index: 0, Points: [{ Items: [{}] }] }; + Object.assign(frame.contentWindow!, { AscCommon: { History: history }, Asc: { editor: {} } }); + try { + requestSaveDocument.mockResolvedValue(fileOf([1])); + await beginAutosaveSession({ docId: 'report', title: 'Report.docx', origin: 'local' }); + await takeSnapshot(); + if (mode === 'append') history.Points[0].Items.push({}); + else history.Points[0].Items[0] = {}; + requestSaveDocument.mockResolvedValue(fileOf([2])); + await takeSnapshot(); + expect((await getLatestSnapshot('report'))?.bytes[0]).toBe(2); + expect((await listDocs()).items[0].revCount).toBe(2); + } finally { + frame.remove(); + } + }); + it('does not start in embed mode', async () => { window.history.replaceState(null, '', '/editor?embed=1'); diff --git a/test/unit/history-handle-cleanup.test.ts b/test/unit/history-handle-cleanup.test.ts new file mode 100644 index 000000000..b4b1e5545 --- /dev/null +++ b/test/unit/history-handle-cleanup.test.ts @@ -0,0 +1,64 @@ +import 'fake-indexeddb/auto'; +import { IDBFactory } from 'fake-indexeddb'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { HANDLES_STORE, requestToPromise, withStores, resetHistoryDbForTests } from '../../lib/history/db'; +import { + clearAllHistory, + deleteDoc, + getDoc, + pruneExpired, + putSnapshot, + resetHistoryClockForTests, +} from '../../lib/history/store'; +import { MAX_AGE_MS } from '../../lib/history/types'; +beforeEach(() => { + resetHistoryDbForTests(); + resetHistoryClockForTests(); + vi.stubGlobal('indexedDB', new IDBFactory()); +}); +afterEach(() => { + resetHistoryDbForTests(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); +async function addTarget(id: string) { + await withStores([HANDLES_STORE], 'readwrite', async (tx) => { + tx.objectStore(HANDLES_STORE).put({ + docId: id, + name: 'Report.docx', + linkedAt: Date.now(), + handle: { name: 'Report.docx' }, + }); + return true; + }); +} +async function targetIds() { + return withStores([HANDLES_STORE], 'readonly', async (tx) => + requestToPromise(tx.objectStore(HANDLES_STORE).getAllKeys()), + ); +} +it.each(['delete', 'clear', 'expire', 'budget'])( + 'removes stored file targets when documents leave history by %s', + async (mode) => { + await putSnapshot({ id: 'old', title: 'Old.docx', origin: 'local', bytes: new Uint8Array(100) }); + await addTarget('old'); + if (mode === 'delete') await deleteDoc('old'); + if (mode === 'clear') await clearAllHistory(); + if (mode === 'expire') { + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + MAX_AGE_MS + 10); + await pruneExpired(); + } + if (mode === 'budget') { + vi.stubGlobal('navigator', { storage: { estimate: async () => ({ quota: 400 }) } }); + await putSnapshot({ id: 'new', title: 'New.docx', origin: 'local', bytes: new Uint8Array(150) }); + expect(await getDoc('new')).not.toBeNull(); + } + expect(await getDoc('old')).toBeNull(); + expect(await targetIds()).toEqual([]); + }, +); +it('also clears targets that have never had a recovery snapshot', async () => { + await addTarget('saved-without-snapshot'); + await clearAllHistory(); + expect(await targetIds()).toEqual([]); +}); diff --git a/test/unit/save-stream-lifecycle.test.ts b/test/unit/save-stream-lifecycle.test.ts new file mode 100644 index 000000000..f701777ff --- /dev/null +++ b/test/unit/save-stream-lifecycle.test.ts @@ -0,0 +1,81 @@ +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', + beginAutosaveSession: vi.fn(), + isAutosaveEnabled: () => true, + setAutosaveEnabled: vi.fn(), + stopAutosaveSession: vi.fn(), +})); +import { initDocumentHistory } from '../../lib/history/index'; +import { getDoc, putSnapshot } from '../../lib/history/store'; +import { resetHistoryDbForTests } from '../../lib/history/db'; +import { resetSaveTargetsForTests } from '../../lib/save-target'; +import { markDocumentDirty, hasUnsavedChanges, resetUnsavedGuardForTests } from '../../lib/unsaved-guard'; +import { setDiskWriter, setSavedToDiskListener } from '../../lib/onlyoffice/save-stream'; +let frame: HTMLIFrameElement; +beforeEach(async () => { + resetHistoryDbForTests(); + resetSaveTargetsForTests(); + resetUnsavedGuardForTests(); + vi.stubGlobal('indexedDB', new IDBFactory()); + window.history.replaceState(null, '', '/editor'); + await putSnapshot({ id: 'save-document', title: 'Report.docx', origin: 'local', bytes: new Uint8Array([1]) }); + frame = document.createElement('iframe'); + frame.name = 'frameEditor'; + document.body.append(frame); + initDocumentHistory(); + markDocumentDirty(); +}); +afterEach(() => { + frame.remove(); + setDiskWriter(null); + setSavedToDiskListener(null); + resetHistoryDbForTests(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); +function sendFile() { + window.dispatchEvent( + new MessageEvent('message', { + source: frame.contentWindow, + origin: location.origin, + data: { + type: 'onlyoffice-file-stream', + fileName: 'Report.docx', + fileType: 'docx', + buffer: new Uint8Array([1, 2, 3]).buffer, + }, + }), + ); +} +it.each(['linked writer', 'fallback'])('retains unsaved work after cancelling the %s picker', async (path) => { + const picker = vi.fn(async () => { + throw new DOMException('Cancelled', 'AbortError'); + }); + vi.stubGlobal('showSaveFilePicker', picker); + if (path === 'fallback') setDiskWriter(null); + sendFile(); + await vi.waitFor(() => expect(picker).toHaveBeenCalledOnce()); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(hasUnsavedChanges()).toBe(true); + expect((await getDoc('save-document'))?.savedToDiskAt).toBeUndefined(); +}); +it('clears unsaved work only after the write has committed', async () => { + let finish!: () => void; + const closed = new Promise((resolve) => { + finish = resolve; + }); + const picker = vi.fn(async () => ({ + name: 'Report.docx', + createWritable: async () => ({ write: async () => undefined, close: () => closed }), + })); + vi.stubGlobal('showSaveFilePicker', picker); + sendFile(); + await vi.waitFor(() => expect(picker).toHaveBeenCalledOnce()); + expect(hasUnsavedChanges()).toBe(true); + finish(); + await vi.waitFor(() => expect(hasUnsavedChanges()).toBe(false)); + await vi.waitFor(async () => expect((await getDoc('save-document'))?.savedToDiskAt).toBeTypeOf('number')); +}); diff --git a/test/unit/save-stream-origin.test.ts b/test/unit/save-stream-origin.test.ts index 4517bc102..4463c106f 100644 --- a/test/unit/save-stream-origin.test.ts +++ b/test/unit/save-stream-origin.test.ts @@ -17,7 +17,7 @@ describe('editor save message boundary', () => { document.body.appendChild(frame); setDiskWriter(async (file) => { files.push(file); - return true; + return 'written'; }); }); diff --git a/test/unit/save-target.test.ts b/test/unit/save-target.test.ts index 77a35b347..a68446e7e 100644 --- a/test/unit/save-target.test.ts +++ b/test/unit/save-target.test.ts @@ -1,5 +1,6 @@ import 'fake-indexeddb/auto'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { clearAllHistory, deleteDoc } from '../../lib/history/store'; import { DB_NAME, resetHistoryDbForTests } from '../../lib/history/db'; import { canWriteToDisk, @@ -18,7 +19,7 @@ function fakeHandle(name = 'Report.docx', permission: PermissionState = 'granted name, createWritable: vi.fn(async () => ({ write: async (data: Blob) => void written.push(data), - close: async () => undefined, + close: async (): Promise => undefined, })), queryPermission: vi.fn(async () => permission), requestPermission: vi.fn(async () => permission), @@ -141,6 +142,87 @@ describe("saving into the document's own file", () => { expect(await getSaveTargetName('doc-1')).toBeNull(); }); + it('exports another format without overwriting or replacing the original target', async () => { + const original = fakeHandle('Report.docx'); + const picker = usePicker(original.handle); + await saveToDiskFile('doc-1', file(), 'Document'); + const exported = fakeHandle('Report.pdf'); + picker.mockResolvedValue(exported.handle); + const pdf = new File(['%PDF-1.7'], 'Report.pdf', { type: 'application/pdf' }); + expect(await saveToDiskFile('doc-1', pdf, 'Document')).toBe('written'); + expect(original.written).toHaveLength(1); + expect(exported.written).toEqual([pdf]); + expect(await getSaveTargetName('doc-1')).toBe('Report.docx'); + await saveToDiskFile('doc-1', file(), 'Document'); + expect(original.written).toHaveLength(2); + expect(picker).toHaveBeenCalledTimes(2); + }); + + it('keeps the original target after cancelling an export to another format', async () => { + const original = fakeHandle(); + const picker = usePicker(original.handle); + await saveToDiskFile('doc-1', file(), 'Document'); + picker.mockRejectedValue(new DOMException('Cancelled', 'AbortError')); + expect(await saveToDiskFile('doc-1', new File(['%PDF'], 'Report.pdf'), 'Document')).toBe('cancelled'); + expect(original.written).toHaveLength(1); + expect(await getSaveTargetName('doc-1')).toBe('Report.docx'); + }); + + it('does not associate an initial export with later ordinary saves', async () => { + const pdfTarget = fakeHandle('Report.pdf'); + const picker = usePicker(pdfTarget.handle); + await saveToDiskFile('doc-1', new File(['%PDF'], 'Report.pdf'), 'Document', { rememberTarget: false }); + expect(await getSaveTargetName('doc-1')).toBeNull(); + const original = fakeHandle('Report.docx'); + picker.mockResolvedValue(original.handle); + await saveToDiskFile('doc-1', file(), 'Document'); + expect(original.written).toHaveLength(1); + expect(pdfTarget.written).toHaveLength(1); + expect(await getSaveTargetName('doc-1')).toBe('Report.docx'); + }); + + it('keeps the original target when writing an export fails', async () => { + const original = fakeHandle(); + const picker = usePicker(original.handle); + await saveToDiskFile('doc-1', file(), 'Document'); + const exported = fakeHandle('Report.pdf'); + exported.handle.createWritable.mockRejectedValue(new Error('disk full')); + picker.mockResolvedValue(exported.handle); + expect(await saveToDiskFile('doc-1', new File(['%PDF'], 'Report.pdf'), 'Document')).toBe('unavailable'); + expect(await getSaveTargetName('doc-1')).toBe('Report.docx'); + }); + + it.each(['first save', 'existing target'])( + 'does not revive a cleared link after a pending %s commits', + async (mode) => { + const target = fakeHandle(); + usePicker(target.handle); + if (mode === 'existing target') await saveToDiskFile('doc-1', file(), 'Document'); + let finish!: () => void; + const pendingClose = new Promise((resolve) => { + finish = resolve; + }); + target.handle.createWritable.mockImplementation(async () => ({ + write: async (data: Blob) => void target.written.push(data), + close: () => pendingClose, + })); + const saving = saveToDiskFile('doc-1', file(), 'Document'); + await vi.waitFor(() => expect(target.written.length).toBe(mode === 'existing target' ? 2 : 1)); + await clearAllHistory(); + finish(); + expect(await saving).toBe('written'); + expect(await getSaveTargetName('doc-1')).toBeNull(); + }, + ); + + it.each(['delete', 'clear'])('forgets in-memory targets when history is removed by %s', async (mode) => { + usePicker(fakeHandle().handle); + await saveToDiskFile('doc-1', file(), 'Document'); + expect(await getSaveTargetName('doc-1')).toBe('Report.docx'); + expect(await (mode === 'delete' ? deleteDoc('doc-1') : clearAllHistory())).toBe(true); + expect(await getSaveTargetName('doc-1')).toBeNull(); + }); + it('can be unlinked deliberately', async () => { usePicker(fakeHandle().handle); await saveToDiskFile('doc-1', file(), 'Document'); diff --git a/test/unit/workspace-aliases.test.ts b/test/unit/workspace-aliases.test.ts new file mode 100644 index 000000000..46036dc46 --- /dev/null +++ b/test/unit/workspace-aliases.test.ts @@ -0,0 +1,55 @@ +import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +import { createServer } from 'vite'; +import { workspaceSourceAliases } from '../../bin/workspace-aliases.mjs'; +it('loads current source instead of stale package exports, including subpaths and live edits', async () => { + const root = await mkdtemp(join(tmpdir(), 'workspace-source-')); + const pkg = join(root, 'packages/local'); + await mkdir(join(pkg, 'src/feature'), { recursive: true }); + await mkdir(join(pkg, 'dist/feature'), { recursive: true }); + await mkdir(join(root, 'node_modules/@test'), { recursive: true }); + await symlink(pkg, join(root, 'node_modules/@test/local')); + await writeFile( + join(pkg, 'package.json'), + JSON.stringify({ + name: '@test/local', + type: 'module', + exports: { + '.': { import: './dist/index.js' }, + './feature': { import: './dist/feature/index.js' }, + './feature/*': { import: './dist/feature/*.js' }, + }, + }), + ); + for (const file of ['index', 'feature/index', 'feature/item']) { + await writeFile(join(pkg, 'src', file + '.ts'), 'export const value = "source";'); + await writeFile(join(pkg, 'dist', file + '.js'), 'export const value = "stale";'); + } + await writeFile( + join(root, 'entry.ts'), + ` + import { value as rootValue } from '@test/local'; + import { value as feature } from '@test/local/feature'; + import { value as item } from '@test/local/feature/item'; + export const values = [rootValue, feature, item]; + `, + ); + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + server: { watch: null }, + resolve: { alias: workspaceSourceAliases(root) }, + }); + try { + expect((await server.ssrLoadModule('/entry.ts')).values).toEqual(['source', 'source', 'source']); + await writeFile(join(pkg, 'src/index.ts'), 'export const value = "updated";'); + server.environments.ssr.moduleGraph.invalidateAll(); + expect((await server.ssrLoadModule('/entry.ts')).values).toEqual(['updated', 'source', 'source']); + } finally { + await server.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/vite.config.ts b/vite.config.ts index 0acd3deb7..da3d8f2b6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path, { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig, type Plugin } from 'vite'; +import { workspaceSourceAliases } from './bin/workspace-aliases.mjs'; import { PAGES, generate as generatePages } from './bin/build-pages.mjs'; import { generate as generateLlmsFull } from './bin/llms-full.mjs'; @@ -122,7 +123,8 @@ const modelWorkerCsp = (): Plugin => { const pathname = (req.url ?? '/').split('?')[0]; if ( /^\/assets\/webllm\.worker-[A-Za-z0-9_-]+\.js$/.test(pathname) || - pathname.endsWith('/packages/agent-core/dist/llm/webllm.worker.js') + pathname.endsWith('/packages/agent-core/dist/llm/webllm.worker.js') || + pathname.endsWith('/packages/agent-core/src/llm/webllm.worker.ts') ) { res.setHeader('Content-Security-Policy', MODEL_WORKER_CSP); } @@ -176,7 +178,7 @@ const generatedPages = (): Plugin => { }; }; -export default defineConfig(() => { +export default defineConfig(({ command }) => { const publicDirName = 'public'; return { @@ -233,7 +235,7 @@ export default defineConfig(() => { // resolves the same way for tsc and for the bundler. The five // directory-specific aliases that used to live here pointed at // `store/` and `assets/`, which do not exist in this repo. - alias: { '@': __dirname }, + alias: [{ find: '@', replacement: __dirname }, ...(command === 'serve' ? workspaceSourceAliases(__dirname) : [])], extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json'], }, }; diff --git a/vitest.config.ts b/vitest.config.ts index ef7b79fbf..a11c6b1aa 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,19 +1,14 @@ -import path, { resolve } from 'node:path'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; +import { workspaceSourceAliases } from './bin/workspace-aliases.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); export default defineConfig({ resolve: { - alias: { - '@/lib': resolve(__dirname, 'lib'), - '@/store': resolve(__dirname, 'store'), - '@/assets': resolve(__dirname, 'assets'), - '@/types': resolve(__dirname, 'types'), - '@/styles': resolve(__dirname, 'styles'), - }, + alias: [{ find: '@', replacement: __dirname }, ...workspaceSourceAliases(__dirname)], }, test: { environment: 'jsdom', @@ -34,14 +29,14 @@ export default defineConfig({ // iframe and isn't meaningfully unit-testable (see the "为什么 // onlyoffice-editor.ts 覆盖率低" section in CLAUDE.md). statements: 34, + 'lib/history/**/*.ts': { lines: 85, branches: 75, functions: 85, statements: 85 }, + 'packages/shared/src/**/*.ts': { lines: 90, branches: 80, functions: 90, statements: 90 }, + 'packages/converter/src/**/*.ts': { lines: 60, branches: 55, functions: 65, statements: 60 }, + 'packages/agent-core/src/**/*.ts': { lines: 80, branches: 75, functions: 75, statements: 75 }, + 'packages/chat-ui/src/**/*.ts': { lines: 90, branches: 80, functions: 90, statements: 90 }, }, - include: [ - 'lib/document-utils.ts', - 'lib/i18n.ts', - 'lib/embed-api.ts', - 'lib/onlyoffice-editor.ts', - 'lib/agent-plugin/**/*.ts', - ], + include: ['lib/**/*.ts', 'packages/*/src/**/*.ts'], + exclude: ['**/*.d.ts'], }, }, });