diff --git a/web/e2e/document-revision-runtime.spec.ts b/web/e2e/document-revision-runtime.spec.ts index b0ad0562..34298fc8 100644 --- a/web/e2e/document-revision-runtime.spec.ts +++ b/web/e2e/document-revision-runtime.spec.ts @@ -28,6 +28,7 @@ import { SHARED_PHASE_STATE_FILE, playwrightResultsFileName, } from '../src/lib/playwrightPhaseArtifacts'; +import { nextDurableBodySnippet } from '../src/lib/durableBodySnippet'; /** * Document revision runtime — real UI acceptance (UI-01..UI-16). @@ -670,6 +671,8 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { let secondDocTitle = ''; let secondDocId = ''; let uploadedDocId = ''; + /** Latest server-durable body marker; UI-15-pre writes this as bodySnippet. */ + let latestDurableBodySnippet = ''; test.beforeAll(async ({ browser }) => { context = await browser.newContext(); @@ -1047,8 +1050,12 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { timeout: 30_000, }); await shot(page, 'UI-06-after-refresh.png'); + latestDurableBodySnippet = nextDurableBodySnippet(latestDurableBodySnippet, { + kind: 'persisted', + snippet: marker, + }) || marker; const st = readPhaseState(); - writePhaseState({ ...st, bodySnippet: marker, revisionText: r1 }); + writePhaseState({ ...st, bodySnippet: latestDurableBodySnippet, revisionText: r1 }); recordPass({ id: 'UI-06', name: 'save + hard refresh', @@ -1386,6 +1393,15 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { await expect(page.getByText(markerA2)).toHaveCount(0); await shot(pageA2, 'UI-13b-conflict-A2.png'); await ctxA2.close(); + latestDurableBodySnippet = nextDurableBodySnippet(latestDurableBodySnippet, { + kind: 'persisted', + snippet: markerA1, + }) || markerA1; + latestDurableBodySnippet = nextDurableBodySnippet(latestDurableBodySnippet, { + kind: 'conflict_draft', + snippet: markerA2, + }) || latestDurableBodySnippet; + writePhaseState({ ...readPhaseState(), bodySnippet: latestDurableBodySnippet }); recordPass({ id: 'UI-13b', name: 'author A/A head conflict', @@ -1448,6 +1464,7 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { controlledDocId, versionText: v, revisionText: r, + bodySnippet: latestDurableBodySnippet || st.bodySnippet, userA: USER_A, userB: USER_B, userC: USER_C, diff --git a/web/src/lib/durableBodySnippet.test.ts b/web/src/lib/durableBodySnippet.test.ts new file mode 100644 index 00000000..4a8d4226 --- /dev/null +++ b/web/src/lib/durableBodySnippet.test.ts @@ -0,0 +1,33 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { nextDurableBodySnippet, requireDurableSnippet } from './durableBodySnippet'; + +describe('durableBodySnippet', () => { + it('rejects empty persisted snippets', () => { + assert.throws(() => requireDurableSnippet(' '), /empty_durable_body_snippet/); + assert.throws( + () => nextDurableBodySnippet('UI06-old', { kind: 'persisted', snippet: ' ' }), + /empty_durable_body_snippet/, + ); + }); + + it('lets a later successful revision replace the old snippet', () => { + const afterUi06 = nextDurableBodySnippet(undefined, { kind: 'persisted', snippet: ' UI06-mu0eq020 ' }); + assert.equal(afterUi06, 'UI06-mu0eq020'); + const afterA1 = nextDurableBodySnippet(afterUi06, { kind: 'persisted', snippet: 'A1-win-mu0eq020' }); + assert.equal(afterA1, 'A1-win-mu0eq020'); + assert.notEqual(afterA1, afterUi06); + }); + + it('does not promote a conflict-failed draft to the expected body', () => { + const afterUi06 = nextDurableBodySnippet(undefined, { kind: 'persisted', snippet: 'UI06-mu0eq020' }); + const afterA1 = nextDurableBodySnippet(afterUi06, { kind: 'persisted', snippet: 'A1-win-mu0eq020' }); + const afterA2Conflict = nextDurableBodySnippet(afterA1, { + kind: 'conflict_draft', + snippet: 'A2-conflict-mu0eq020', + }); + assert.equal(afterA2Conflict, 'A1-win-mu0eq020'); + assert.notEqual(afterA2Conflict, 'A2-conflict-mu0eq020'); + assert.notEqual(afterA2Conflict, 'UI06-mu0eq020'); + }); +}); diff --git a/web/src/lib/durableBodySnippet.ts b/web/src/lib/durableBodySnippet.ts new file mode 100644 index 00000000..caeaeba7 --- /dev/null +++ b/web/src/lib/durableBodySnippet.ts @@ -0,0 +1,26 @@ +export type DurableBodyEvent = + | { kind: 'persisted'; snippet: string } + | { kind: 'conflict_draft'; snippet: string }; + +export function requireDurableSnippet(snippet: string): string { + const next = snippet.trim(); + if (!next) { + throw new Error('empty_durable_body_snippet'); + } + return next; +} + +/** + * Latest server-durable body snippet for post-restart. + * A later successful save replaces the previous snippet. + * A conflict-failed editor draft never becomes the expected body. + */ +export function nextDurableBodySnippet( + current: string | undefined, + event: DurableBodyEvent, +): string | undefined { + if (event.kind === 'conflict_draft') { + return current; + } + return requireDurableSnippet(event.snippet); +}