From ec34e8ebd9986ac546f2d9ee7b34cb17c45b6eaf Mon Sep 17 00:00:00 2001 From: Hsukqi Lee Date: Sat, 14 Nov 2026 19:34:55 +0800 Subject: [PATCH] fix: preserve text contracts and newer editor drafts --- apps/gateway/tests/app.test.ts | 21 ++ apps/web/src/stores/files.ts | 56 +++- apps/web/tests/stores/files.test.ts | 314 ++++++++++++++++++ packages/workspace/src/workspace-service.ts | 7 + .../workspace/tests/workspace-service.test.ts | 38 +++ 5 files changed, 426 insertions(+), 10 deletions(-) create mode 100644 apps/web/tests/stores/files.test.ts diff --git a/apps/gateway/tests/app.test.ts b/apps/gateway/tests/app.test.ts index e612a56f..c0468233 100644 --- a/apps/gateway/tests/app.test.ts +++ b/apps/gateway/tests/app.test.ts @@ -941,6 +941,27 @@ describe('gateway', () => { headers, }) const before = FileContentSchema.parse(read.json()) + for (const path of ['slides.md', 'new/deck/slides.md']) { + const rejected = await app.inject({ + method: 'PUT', + url: '/api/v1/workspace/files/content', + headers, + payload: { path, content: 'bad\0text' }, + }) + expect(rejected.statusCode).toBe(400) + expect(rejected.json()).toMatchObject({ + error: { code: 'INVALID_REQUEST', retryable: false }, + }) + } + expect(readFileSync(join(root, 'slides.md'), 'utf8')).toBe(before.content) + expect(existsSync(join(root, 'new'))).toBe(false) + const unchanged = await app.inject({ + method: 'GET', + url: '/api/v1/workspace/files/content?path=slides.md', + headers, + }) + expect(FileContentSchema.parse(unchanged.json())).toEqual(before) + const write = await app.inject({ method: 'PUT', url: '/api/v1/workspace/files/content', diff --git a/apps/web/src/stores/files.ts b/apps/web/src/stores/files.ts index 94261b8a..0ae428e4 100644 --- a/apps/web/src/stores/files.ts +++ b/apps/web/src/stores/files.ts @@ -27,6 +27,10 @@ export const useFilesStore = defineStore('files', () => { const latestEvent = shallowRef() const client = useGatewayClient() const retry = createConnectivityRetry() + // A reopen starts a new session even when its path is unchanged. + let session = { opening: false } + let readVersion = 0 + let draftVersion = 0 const dirty = computed( () => @@ -57,35 +61,57 @@ export const useFilesStore = defineStore('files', () => { } async function openFile(path: string): Promise { + const openedSession = { opening: true } + session = openedSession + saving.value = false error.value = undefined externalChange.value = false + if (selectedFile.value?.path !== path) { + selectedFile.value = undefined + draft.value = '' + } + const edits = draftVersion try { const content = await client.readFile(path) + if (session !== openedSession) return selectedFile.value = content - draft.value = content.content + if (draftVersion === edits) draft.value = content.content } catch (cause) { + if (session !== openedSession) return error.value = cause instanceof Error ? cause.message : '无法读取文件' + } finally { + openedSession.opening = false } } function updateDraft(content: string): void { + if (draft.value !== content) draftVersion++ draft.value = content } async function save(): Promise { - if (!selectedFile.value || !dirty.value || saving.value) return + if (!selectedFile.value || session.opening || !dirty.value || saving.value) + return + const savedSession = session + const selected = selectedFile.value + const content = draft.value + const edits = draftVersion + // Reads begun before this write cannot replace its acknowledged baseline. + readVersion++ saving.value = true error.value = undefined try { const updated = await client.writeFile({ - path: selectedFile.value.path, - content: draft.value, - expectedRevision: selectedFile.value.revision, + path: selected.path, + content, + expectedRevision: selected.revision, }) + if (session !== savedSession) return selectedFile.value = updated - draft.value = updated.content + if (draftVersion === edits) draft.value = updated.content externalChange.value = false } catch (cause) { + if (session !== savedSession) return if (isGatewayError(cause) && cause.status === 409) { externalChange.value = true error.value = @@ -94,7 +120,7 @@ export const useFilesStore = defineStore('files', () => { error.value = cause instanceof Error ? cause.message : '保存失败' } } finally { - saving.value = false + if (session === savedSession) saving.value = false } } @@ -107,7 +133,7 @@ export const useFilesStore = defineStore('files', () => { ? String(event.data.path) : undefined if (!currentPath || eventPath !== currentPath) return - if (dirty.value) { + if (dirty.value || saving.value) { externalChange.value = true return } @@ -115,11 +141,20 @@ export const useFilesStore = defineStore('files', () => { } async function resyncWorkspace(): Promise { - await loadTree() + const syncedSession = session const selected = selectedFile.value - if (!selected) return + const read = ++readVersion + const ownsRead = () => + session === syncedSession && + !session.opening && + readVersion === read && + selectedFile.value === selected && + !saving.value + await loadTree() + if (!selected || !ownsRead()) return try { const current = await client.readFile(selected.path) + if (!ownsRead()) return if (current.revision === selected.revision) return if (dirty.value) { externalChange.value = true @@ -129,6 +164,7 @@ export const useFilesStore = defineStore('files', () => { draft.value = current.content externalChange.value = false } catch (cause) { + if (!ownsRead()) return error.value = cause instanceof Error ? cause.message : '无法重新同步文件' } } diff --git a/apps/web/tests/stores/files.test.ts b/apps/web/tests/stores/files.test.ts new file mode 100644 index 00000000..d9a6f854 --- /dev/null +++ b/apps/web/tests/stores/files.test.ts @@ -0,0 +1,314 @@ +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useFilesStore } from '../../src/stores/files.js' + +import type { ApiErrorBody, FileContent } from '@fastppt/protocol' + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + +// Only the browser's transport boundaries are replaced; client/schema/event handling stay real. +class Socket extends EventTarget { + static readonly OPEN = 1 + static current: Socket + readyState = 0 + + constructor() { + super() + Socket.current = this + } + + send(): void {} + close(): void {} + + acknowledge(): void { + this.readyState = Socket.OPEN + this.dispatchEvent(new Event('open')) + this.dispatchEvent(new MessageEvent('message', { + data: JSON.stringify({ type: 'subscribed', topics: ['workspace'], sequence: 0 }), + })) + } +} + +interface PendingRequest { + url: URL + init: RequestInit | undefined + reply: (body: unknown, status?: number) => void +} + +let pending: PendingRequest[] +let tree: ReturnType> | undefined +let store: ReturnType + +function file(path = 'a.md', content = 'original', revision = 'r1'): FileContent { + return { path, content, revision, size: content.length, modifiedAt: '2026-11-10T00:00:00.000Z' } +} + +function failure(status: number): ApiErrorBody { + return { error: { code: status === 409 ? 'FILE_REVISION_CONFLICT' : 'INTERNAL_ERROR', message: 'request failed', retryable: false, requestId: 'test-request' } } +} + +async function open(path = 'a.md', content = 'original', revision = 'r1'): Promise { + const opening = store.openFile(path) + const request = pending.shift()! + expect(request.url.searchParams.get('path')).toBe(path) + request.reply(file(path, content, revision)) + await opening +} + +async function reconnect(): Promise { + store.stop() + const starting = store.start() + Socket.current.acknowledge() + await starting +} + +beforeEach(() => { + setActivePinia(createPinia()) + pending = [] + tree = undefined + vi.stubGlobal('WebSocket', Socket) + vi.stubGlobal('fetch', (input: string, init?: RequestInit) => { + const url = new URL(input) + if (url.pathname === '/api/v1/workspace/files') { + return tree?.promise ?? Promise.resolve(Response.json([])) + } + const response = deferred() + pending.push({ url, init, reply: (body, status = 200) => response.resolve(Response.json(body, { status })) }) + return response.promise + }) + store = useFilesStore() +}) + +afterEach(() => { + store.stop() + vi.unstubAllGlobals() +}) + +describe('file response ownership', () => { + it('preserves typing during save and uses the acknowledged revision on the next save', async () => { + await open() + store.updateDraft('submitted') + const saving = store.save() + const first = pending.shift()! + expect(typeof first.init?.body).toBe('string') + expect(JSON.parse(first.init?.body as string)).toEqual({ path: 'a.md', content: 'submitted', expectedRevision: 'r1' }) + store.updateDraft('newer draft') + first.reply(file('a.md', 'submitted', 'r2')) + await saving + expect(store.selectedFile?.revision).toBe('r2') + expect(store.draft).toBe('newer draft') + expect(store.dirty).toBe(true) + + const next = store.save() + const second = pending.shift()! + expect(typeof second.init?.body).toBe('string') + expect(JSON.parse(second.init?.body as string)).toEqual({ path: 'a.md', content: 'newer draft', expectedRevision: 'r2' }) + second.reply(file('a.md', 'newer draft', 'r3')) + await next + expect(store.dirty).toBe(false) + expect(store.saving).toBe(false) + }) + + for (const destination of ['b.md', 'a-b-a', 'reload']) { + it.each([200, 409, 500])(`ignores an old save after ${destination} (status %s)`, async (status) => { + await open() + store.updateDraft('submitted') + const oldSave = store.save() + const oldRequest = pending.shift()! + if (destination !== 'reload') await open('b.md') + if (destination !== 'b.md') await open('a.md', 'reloaded', 'reload-revision') + store.updateDraft('current draft') + const selected = store.selectedFile + oldRequest.reply(status === 200 ? file('a.md', 'submitted', 'r2') : failure(status), status) + await oldSave + expect(store.selectedFile).toBe(selected) + expect(store.draft).toBe('current draft') + expect(store.dirty).toBe(true) + expect(store.externalChange).toBe(false) + expect(store.error).toBeUndefined() + }) + } + + it('lets a new session save while an old save is pending and keeps its saving state', async () => { + await open() + store.updateDraft('old draft') + const oldSave = store.save() + const oldRequest = pending.shift()! + await open('b.md') + store.updateDraft('B draft') + const currentSave = store.save() + expect(pending).toHaveLength(1) + const currentRequest = pending.shift()! + oldRequest.reply(failure(409), 409) + await oldSave + expect(store.saving).toBe(true) + expect(store.error).toBeUndefined() + currentRequest.reply(file('b.md', 'B draft', 'b2')) + await currentSave + expect(store.selectedFile?.path).toBe('b.md') + expect(store.dirty).toBe(false) + expect(store.saving).toBe(false) + }) + + it('keeps the current draft and reports a current-session conflict', async () => { + await open() + store.updateDraft('submitted') + const saving = store.save() + store.updateDraft('newer') + pending.shift()!.reply(failure(409), 409) + await saving + expect(store.draft).toBe('newer') + expect(store.selectedFile?.revision).toBe('r1') + expect(store.externalChange).toBe(true) + expect(store.error).toBeDefined() + }) + + it.each([200, 500])('ignores a superseded open response (status %s)', async (status) => { + const oldOpen = store.openFile('a.md') + const oldRequest = pending.shift()! + await open('b.md') + store.updateDraft('B draft') + oldRequest.reply(status === 200 ? file() : failure(status), status) + await oldOpen + expect(store.selectedFile?.path).toBe('b.md') + expect(store.draft).toBe('B draft') + expect(store.error).toBeUndefined() + }) + + it('preserves input made while a reload is pending', async () => { + await open() + const reloading = store.openFile('a.md') + store.updateDraft('typed during reload') + pending.shift()!.reply(file('a.md', 'disk content', 'r2')) + await reloading + expect(store.selectedFile?.revision).toBe('r2') + expect(store.draft).toBe('typed during reload') + expect(store.dirty).toBe(true) + }) + + it.each(['switch', 'reload', 'typing', 'already-dirty', 'saved', 'newer-resync', 'error'])('guards reconnect resync after %s', async (action) => { + await open() + await store.start() + Socket.current.acknowledge() + if (action === 'already-dirty') store.updateDraft('unsaved') + await reconnect() + await vi.waitFor(() => expect(pending).toHaveLength(1)) + const oldRead = pending.shift()! + if (action === 'switch' || action === 'error') await open('b.md') + if (action === 'reload') await open('a.md', 'reloaded', 'reload-revision') + if (action === 'typing') store.updateDraft('typed during resync') + if (action === 'saved') { + store.updateDraft('saved content') + const saving = store.save() + pending.shift()!.reply(file('a.md', 'saved content', 'saved-revision')) + await saving + } + if (action === 'newer-resync') { + await reconnect() + await vi.waitFor(() => expect(pending).toHaveLength(1)) + pending.shift()!.reply(file('a.md', 'newer resync', 'newer-revision')) + await vi.waitFor(() => expect(store.selectedFile?.revision).toBe('newer-revision')) + } + const selected = store.selectedFile + const draft = store.draft + oldRead.reply(action === 'error' ? failure(500) : file('a.md', 'stale disk', 'stale-revision'), action === 'error' ? 500 : 200) + await new Promise((resolve) => setTimeout(resolve, 0)) + await vi.waitFor(() => { + if (action === 'typing' || action === 'already-dirty') expect(store.externalChange).toBe(true) + else expect(store.externalChange).toBe(false) + expect(store.selectedFile).toBe(selected) + expect(store.draft).toBe(draft) + expect(store.error).toBeUndefined() + }) + }) + + it('does not acquire a new selection after waiting for the resync tree', async () => { + await open() + await store.start() + Socket.current.acknowledge() + tree = deferred() + store.stop() + const starting = store.start() + Socket.current.acknowledge() + await open('b.md') + tree.resolve(Response.json([])) + await starting + // Flush the real client's JSON/async continuation before checking for a stale read. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(pending).toHaveLength(0) + expect(store.selectedFile?.path).toBe('b.md') + }) + + it('refreshes a clean current session on reconnect', async () => { + await open() + await store.start() + Socket.current.acknowledge() + await reconnect() + await vi.waitFor(() => expect(pending).toHaveLength(1)) + pending.shift()!.reply(file('a.md', 'external edit', 'r2')) + await vi.waitFor(() => expect(store.draft).toBe('external edit')) + expect(store.selectedFile?.revision).toBe('r2') + expect(store.dirty).toBe(false) + }) + + it.each(['a.md', 'b.md'])('finishes opening %s when reconnecting during its read', async (path) => { + await open() + await store.start() + Socket.current.acknowledge() + const opening = store.openFile(path) + const request = pending.shift()! + await reconnect() + expect(pending).toHaveLength(0) + request.reply(file(path, 'opened content', 'r2')) + await opening + expect(store.selectedFile?.path).toBe(path) + expect(store.draft).toBe('opened content') + expect(store.dirty).toBe(false) + }) + + it('waits for a reload baseline before allowing a save', async () => { + await open() + const opening = store.openFile('a.md') + const request = pending.shift()! + store.updateDraft('typed during reload') + await store.save() + expect(pending).toHaveLength(0) + request.reply(file('a.md', 'reloaded', 'r2')) + await opening + const saving = store.save() + const write = pending.shift()! + expect(JSON.parse(write.init?.body as string)).toEqual({ path: 'a.md', content: 'typed during reload', expectedRevision: 'r2' }) + write.reply(file('a.md', 'typed during reload', 'r3')) + await saving + expect(store.dirty).toBe(false) + }) + + it('keeps the save session when a file event arrives after reverting the draft', async () => { + await open() + await store.start() + Socket.current.acknowledge() + store.updateDraft('submitted') + const saving = store.save() + const request = pending.shift()! + store.updateDraft('original') + expect(store.dirty).toBe(false) + Socket.current.dispatchEvent(new MessageEvent('message', { + data: JSON.stringify({ + id: 'file-event', sequence: 1, timestamp: '2026-11-10T00:00:00.000Z', + topic: 'workspace', type: 'file.changed', data: { path: 'a.md' }, + }), + })) + expect(pending).toHaveLength(0) + expect(store.saving).toBe(true) + request.reply(file('a.md', 'submitted', 'r2')) + await saving + expect(store.selectedFile?.revision).toBe('r2') + expect(store.draft).toBe('original') + expect(store.dirty).toBe(true) + }) +}) diff --git a/packages/workspace/src/workspace-service.ts b/packages/workspace/src/workspace-service.ts index 91b4c018..ab4c5902 100644 --- a/packages/workspace/src/workspace-service.ts +++ b/packages/workspace/src/workspace-service.ts @@ -448,6 +448,13 @@ export class WorkspaceService { } const bytes = Buffer.from(input.content, 'utf8') + if (isBinaryContent(bytes)) { + throw new WorkspaceError( + 'INVALID_REQUEST', + 'Binary content cannot be written as text.', + 400, + ) + } if (bytes.byteLength > this.maxReadBytes) { throw new WorkspaceError( 'FILE_TOO_LARGE', diff --git a/packages/workspace/tests/workspace-service.test.ts b/packages/workspace/tests/workspace-service.test.ts index 69535fa4..48945049 100644 --- a/packages/workspace/tests/workspace-service.test.ts +++ b/packages/workspace/tests/workspace-service.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + readdirSync, rmSync, symlinkSync, writeFileSync, @@ -80,6 +81,43 @@ describe('workspace path policy', () => { }) describe('workspace files', () => { + it('rejects NUL overwrites without changing bytes, revision or directory entries', async () => { + const root = temporaryDirectory('fastppt-workspace-') + const original = Buffer.from('# 原文 🌍\n') + writeFileSync(join(root, 'slides.md'), original) + const workspace = await WorkspaceService.create(root) + const before = await workspace.readTextFile('slides.md') + + await expect( + workspace.writeTextFile({ + path: 'slides.md', + content: 'bad\0text', + expectedRevision: before.revision, + }), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST', statusCode: 400 }) + + expect(readFileSync(join(root, 'slides.md'))).toEqual(original) + expect(await workspace.readTextFile('slides.md')).toEqual(before) + expect(readdirSync(root)).toEqual(['slides.md']) + }) + + it('rejects NUL before creating parents and still round-trips Unicode text', async () => { + const root = temporaryDirectory('fastppt-workspace-') + const workspace = await WorkspaceService.create(root) + await expect( + workspace.writeTextFile({ path: 'new/deck/slides.md', content: '\0' }), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST', statusCode: 400 }) + expect(readdirSync(root)).toEqual([]) + + const content = '# 你好 🌍\n' + const saved = await workspace.writeTextFile({ + path: 'new/deck/slides.md', + content, + }) + expect(readFileSync(join(root, 'new/deck/slides.md'), 'utf8')).toBe(content) + expect(await workspace.readTextFile(saved.path)).toEqual(saved) + }) + it('reads, atomically writes and enforces revisions', async () => { const root = temporaryDirectory('fastppt-workspace-') mkdirSync(join(root, 'deck'))