From 9d7c97f7288e269a457fda344608510459a596e6 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Wed, 23 Sep 2026 01:29:17 +0800 Subject: [PATCH 1/5] fix(e2e): fetch revision-state etag by doc id for UI-13b waitForResponse raced unrelated revision-state polls, so A1/A2 compared different documents heads before the conflict save. --- web/e2e/document-revision-runtime.spec.ts | 61 ++++++++++++----------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/web/e2e/document-revision-runtime.spec.ts b/web/e2e/document-revision-runtime.spec.ts index a5d4aaca..6788fb9e 100644 --- a/web/e2e/document-revision-runtime.spec.ts +++ b/web/e2e/document-revision-runtime.spec.ts @@ -703,6 +703,24 @@ async function readRevisionStateEtag(res: Response): Promise { return json.etag; } +/** Fresh revision-state etag for a concrete doc_id (avoids racing unrelated poll responses). */ +async function fetchRevisionStateEtag(target: Page, docId: string): Promise { + const payload = await target.evaluate(async (id) => { + const res = await fetch(`/api/v1/documents/${encodeURIComponent(id)}/revision-state`, { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + const body = (await res.json().catch(() => ({}))) as { etag?: string; error?: string }; + return { ok: res.ok, status: res.status, etag: body.etag || '', error: body.error || '' }; + }, docId); + if (!payload.ok || !payload.etag) { + fail( + `revision-state fetch failed for ${docId}: HTTP ${payload.status} etag=${JSON.stringify(payload.etag)} error=${payload.error}`, + ); + } + return payload.etag; +} + /** Assert zero revision-state 404 and zero requests for template doc-p* slots. */ function assertRevisionStateCleanForNewProject(logs: NetLog[]) { const hits = logs.filter((l) => l.path.includes('/revision-state')); @@ -1533,43 +1551,28 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { test('UI-13b two author-A contexts conflict on same revision', async () => { const t0 = Date.now(); logs = []; - const etagA1P = page.waitForResponse( - (r) => - r.url().includes('/revision-state') && - r.request().method() === 'GET' && - r.ok(), - { timeout: 30_000 }, - ); - if (controlledDocId) { - await openWorkflowDocById(page, controlledDocId); - } else { - await openWorkflowDocByLabel(page, /用户初始需求|需求摸底|intake/i); - } + if (!controlledDocId) fail('UI-13b requires controlledDocId from UI-03'); + await openWorkflowDocById(page, controlledDocId); await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 20_000 }); const a1Base = await readVersionBadge(page); - const etagA1 = await readRevisionStateEtag(await etagA1P); + const etagA1 = await fetchRevisionStateEtag(page, controlledDocId); const ctxA2 = await page.context().browser()!.newContext(); const pageA2 = await ctxA2.newPage(); await login(pageA2, USER_A, FINAL_PASS_A); await pageA2.getByText(PROJECT_NAME, { exact: false }).first().click(); - const etagA2P = pageA2.waitForResponse( - (r) => - r.url().includes('/revision-state') && - r.request().method() === 'GET' && - r.ok(), - { timeout: 30_000 }, - ); - if (controlledDocId) { - await openWorkflowDocById(pageA2, controlledDocId); - } else { - await openWorkflowDocByLabel(pageA2, /用户初始需求|需求摸底|intake/i); - } + await openWorkflowDocById(pageA2, controlledDocId); await expect(pageA2.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 }); const a2Base = await readVersionBadge(pageA2); - const etagA2 = await readRevisionStateEtag(await etagA2P); - if (a2Base.r !== a1Base.r) fail(`A2 must open same revision as A1: ${a2Base.r} vs ${a1Base.r}`); - if (etagA2 !== etagA1) fail(`A2 must hydrate same etag as A1 before either save: ${etagA2} vs ${etagA1}`); + // Re-read A1 after A2 hydrates in case background reconcile advanced the head. + const etagA1Fresh = await fetchRevisionStateEtag(page, controlledDocId); + const etagA2 = await fetchRevisionStateEtag(pageA2, controlledDocId); + if (a2Base.r !== a1Base.r) { + fail(`A2 must open same revision as A1: ${a2Base.r} vs ${a1Base.r}`); + } + if (etagA2 !== etagA1Fresh) { + fail(`A2 must hydrate same etag as A1 before either save: ${etagA2} vs ${etagA1Fresh} (A1 open was ${etagA1})`); + } const markerA1 = `A1-win-${suffix}`; const markerA2 = `A2-conflict-${suffix}`; @@ -1626,7 +1629,7 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { id: 'UI-13b', name: 'author A/A head conflict', input: 'both windows hydrate same r/etag; A2 editor open+filled before A1 save; then A2 save', - actual: `base r=${a1Base.r} etag=${etagA1}; A1 kept ${markerA1}; A2 HTTP ${a2Res.status()} code=${a2Json.code || ''}; A2 editor retained`, + actual: `base r=${a1Base.r} etag=${etagA1Fresh}; A1 kept ${markerA1}; A2 HTTP ${a2Res.status()} code=${a2Json.code || ''}; A2 editor retained`, networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', From 92bb1a20046609caa6acbe915792c6de73ee69a9 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Wed, 23 Sep 2026 01:53:49 +0800 Subject: [PATCH 2/5] fix(e2e): send Bearer token when fetching revision-state etag UI-13b page.evaluate fetch used credentials-only and got HTTP 401 because auth is Bearer in smdf_auth_session, not cookies. --- web/e2e/document-revision-runtime.spec.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/web/e2e/document-revision-runtime.spec.ts b/web/e2e/document-revision-runtime.spec.ts index 6788fb9e..c8663f8a 100644 --- a/web/e2e/document-revision-runtime.spec.ts +++ b/web/e2e/document-revision-runtime.spec.ts @@ -706,9 +706,21 @@ async function readRevisionStateEtag(res: Response): Promise { /** Fresh revision-state etag for a concrete doc_id (avoids racing unrelated poll responses). */ async function fetchRevisionStateEtag(target: Page, docId: string): Promise { const payload = await target.evaluate(async (id) => { + const headers: Record = { Accept: 'application/json' }; + try { + const raw = window.localStorage.getItem('smdf_auth_session'); + if (raw) { + const session = JSON.parse(raw) as { token?: string }; + if (session.token) { + headers.Authorization = `Bearer ${session.token}`; + } + } + } catch { + // ignore corrupt session; request will 401 and fail below + } const res = await fetch(`/api/v1/documents/${encodeURIComponent(id)}/revision-state`, { credentials: 'include', - headers: { Accept: 'application/json' }, + headers, }); const body = (await res.json().catch(() => ({}))) as { etag?: string; error?: string }; return { ok: res.ok, status: res.status, etag: body.etag || '', error: body.error || '' }; From 0429b9735ec7ae004f53e222ef23dd21fbbafc3a Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Wed, 23 Sep 2026 02:12:02 +0800 Subject: [PATCH 3/5] fix(e2e): run UI-13b conflict on drafting prelim, not locked intake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After UI-11 三权, intake is 已生效受控 and blocks body edit. Use secondDocId (prelim_design) for the A/A head-conflict scenario. --- web/e2e/document-revision-runtime.spec.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/web/e2e/document-revision-runtime.spec.ts b/web/e2e/document-revision-runtime.spec.ts index c8663f8a..63de12d2 100644 --- a/web/e2e/document-revision-runtime.spec.ts +++ b/web/e2e/document-revision-runtime.spec.ts @@ -1563,22 +1563,24 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { test('UI-13b two author-A contexts conflict on same revision', async () => { const t0 = Date.now(); logs = []; - if (!controlledDocId) fail('UI-13b requires controlledDocId from UI-03'); - await openWorkflowDocById(page, controlledDocId); + // Intake is locked 已生效受控 after UI-11 三权; conflict must use drafting prelim. + const conflictDocId = secondDocId || controlledDocId; + if (!conflictDocId) fail('UI-13b requires secondDocId (prelim) from UI-11 or controlledDocId'); + await openWorkflowDocById(page, conflictDocId); await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 20_000 }); const a1Base = await readVersionBadge(page); - const etagA1 = await fetchRevisionStateEtag(page, controlledDocId); + const etagA1 = await fetchRevisionStateEtag(page, conflictDocId); const ctxA2 = await page.context().browser()!.newContext(); const pageA2 = await ctxA2.newPage(); await login(pageA2, USER_A, FINAL_PASS_A); await pageA2.getByText(PROJECT_NAME, { exact: false }).first().click(); - await openWorkflowDocById(pageA2, controlledDocId); + await openWorkflowDocById(pageA2, conflictDocId); await expect(pageA2.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 }); const a2Base = await readVersionBadge(pageA2); // Re-read A1 after A2 hydrates in case background reconcile advanced the head. - const etagA1Fresh = await fetchRevisionStateEtag(page, controlledDocId); - const etagA2 = await fetchRevisionStateEtag(pageA2, controlledDocId); + const etagA1Fresh = await fetchRevisionStateEtag(page, conflictDocId); + const etagA2 = await fetchRevisionStateEtag(pageA2, conflictDocId); if (a2Base.r !== a1Base.r) { fail(`A2 must open same revision as A1: ${a2Base.r} vs ${a1Base.r}`); } @@ -1641,7 +1643,7 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { id: 'UI-13b', name: 'author A/A head conflict', input: 'both windows hydrate same r/etag; A2 editor open+filled before A1 save; then A2 save', - actual: `base r=${a1Base.r} etag=${etagA1Fresh}; A1 kept ${markerA1}; A2 HTTP ${a2Res.status()} code=${a2Json.code || ''}; A2 editor retained`, + actual: `doc=${conflictDocId}; base r=${a1Base.r} etag=${etagA1Fresh}; A1 kept ${markerA1}; A2 HTTP ${a2Res.status()} code=${a2Json.code || ''}; A2 editor retained`, networkLogs: logs, durationMs: Date.now() - t0, verdict: 'PASS', From 67251abc09f9daff93fada6c27c14c0dae9886c1 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Wed, 23 Sep 2026 02:35:45 +0800 Subject: [PATCH 4/5] fix(e2e): release A1 lease before UI-13b A2 opens block editor Same-user second context could not dblclick-edit while A1 held the human lease on prelim. Leave to intake, let A2 claim editor, then A1 save. --- web/e2e/document-revision-runtime.spec.ts | 26 +++++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/web/e2e/document-revision-runtime.spec.ts b/web/e2e/document-revision-runtime.spec.ts index 63de12d2..32154def 100644 --- a/web/e2e/document-revision-runtime.spec.ts +++ b/web/e2e/document-revision-runtime.spec.ts @@ -1564,12 +1564,21 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { const t0 = Date.now(); logs = []; // Intake is locked 已生效受控 after UI-11 三权; conflict must use drafting prelim. - const conflictDocId = secondDocId || controlledDocId; - if (!conflictDocId) fail('UI-13b requires secondDocId (prelim) from UI-11 or controlledDocId'); + if (!secondDocId) fail('UI-13b requires secondDocId from UI-11 (prelim drafting)'); + const conflictDocId = secondDocId; await openWorkflowDocById(page, conflictDocId); await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 20_000 }); const a1Base = await readVersionBadge(page); const etagA1 = await fetchRevisionStateEtag(page, conflictDocId); + // Release A1 human lease so A2 (same user, other browser context) can open the editor. + if (controlledDocId && controlledDocId !== conflictDocId) { + await openWorkflowDocById(page, controlledDocId); + await expect(page.getByTestId('active-document-pane')).toHaveAttribute( + 'data-doc-id', + controlledDocId, + { timeout: 20_000 }, + ); + } const ctxA2 = await page.context().browser()!.newContext(); const pageA2 = await ctxA2.newPage(); @@ -1578,21 +1587,24 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { await openWorkflowDocById(pageA2, conflictDocId); await expect(pageA2.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 }); const a2Base = await readVersionBadge(pageA2); - // Re-read A1 after A2 hydrates in case background reconcile advanced the head. - const etagA1Fresh = await fetchRevisionStateEtag(page, conflictDocId); const etagA2 = await fetchRevisionStateEtag(pageA2, conflictDocId); if (a2Base.r !== a1Base.r) { fail(`A2 must open same revision as A1: ${a2Base.r} vs ${a1Base.r}`); } - if (etagA2 !== etagA1Fresh) { - fail(`A2 must hydrate same etag as A1 before either save: ${etagA2} vs ${etagA1Fresh} (A1 open was ${etagA1})`); - } const markerA1 = `A1-win-${suffix}`; const markerA2 = `A2-conflict-${suffix}`; const a2Editor = await openFirstPaneEditorAndFill(pageA2, `人工修订段落 ${markerA2}`); await expect(a2Editor).toBeVisible(); + // A1 returns to conflict doc after A2 already holds an open editor draft. + await openWorkflowDocById(page, conflictDocId); + await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 20_000 }); + const etagA1Fresh = await fetchRevisionStateEtag(page, conflictDocId); + if (etagA2 !== etagA1Fresh && etagA2 !== etagA1) { + fail(`A2/A1 etag diverged before A1 save: A2=${etagA2} A1fresh=${etagA1Fresh} A1open=${etagA1}`); + } + const patchA1 = page.waitForResponse( (r) => r.url().includes('/content') && r.request().method() === 'PATCH', { timeout: 30_000 }, From 01bf599bd4def047a0ceaf7df1e8dcdb533e6f83 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Wed, 23 Sep 2026 03:00:15 +0800 Subject: [PATCH 5/5] fix(e2e): claim prelim author before UI-13b conflict edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prelim docs ship with author_id=0 so currentDocRole is viewer and dblclick is a no-op; claim 认领此文档 first then assert the edit tip on both A contexts. --- web/e2e/document-revision-runtime.spec.ts | 26 +++++++++++++---------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/web/e2e/document-revision-runtime.spec.ts b/web/e2e/document-revision-runtime.spec.ts index 32154def..90f57a02 100644 --- a/web/e2e/document-revision-runtime.spec.ts +++ b/web/e2e/document-revision-runtime.spec.ts @@ -494,6 +494,16 @@ async function ensureControlledIntakeOpen(target: Page, docId: string) { await expect(target.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 }); } +/** Prelim/second docs often ship with author_id=0; without claim, currentDocRole is viewer and dblclick is a no-op. */ +async function ensureAuthorClaimedForEdit(target: Page) { + const claim = target.getByRole('button', { name: /认领此文档/ }); + if (await claim.isVisible().catch(() => false)) { + await claim.click(); + await expect(claim).toHaveCount(0, { timeout: 15_000 }); + } + await expect(target.getByText(/双击任意段落就地编辑/)).toBeVisible({ timeout: 30_000 }); +} + /** * V0.1 intake is blocking_when_enabled; prelim_design lives in V0.5 and is * 403'd until intake completes 三权. Drive submit→review→approve via page only. @@ -1564,21 +1574,14 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { const t0 = Date.now(); logs = []; // Intake is locked 已生效受控 after UI-11 三权; conflict must use drafting prelim. + // Prelim ships author_id=0 → resolveCurrentDocRole is viewer until 认领此文档. if (!secondDocId) fail('UI-13b requires secondDocId from UI-11 (prelim drafting)'); const conflictDocId = secondDocId; await openWorkflowDocById(page, conflictDocId); await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 20_000 }); + await ensureAuthorClaimedForEdit(page); const a1Base = await readVersionBadge(page); const etagA1 = await fetchRevisionStateEtag(page, conflictDocId); - // Release A1 human lease so A2 (same user, other browser context) can open the editor. - if (controlledDocId && controlledDocId !== conflictDocId) { - await openWorkflowDocById(page, controlledDocId); - await expect(page.getByTestId('active-document-pane')).toHaveAttribute( - 'data-doc-id', - controlledDocId, - { timeout: 20_000 }, - ); - } const ctxA2 = await page.context().browser()!.newContext(); const pageA2 = await ctxA2.newPage(); @@ -1586,6 +1589,8 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { await pageA2.getByText(PROJECT_NAME, { exact: false }).first().click(); await openWorkflowDocById(pageA2, conflictDocId); await expect(pageA2.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 }); + // Same user is now author after A1 claim; wait for edit affordance (not claim button). + await expect(pageA2.getByText(/双击任意段落就地编辑/)).toBeVisible({ timeout: 30_000 }); const a2Base = await readVersionBadge(pageA2); const etagA2 = await fetchRevisionStateEtag(pageA2, conflictDocId); if (a2Base.r !== a1Base.r) { @@ -1597,8 +1602,7 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { const a2Editor = await openFirstPaneEditorAndFill(pageA2, `人工修订段落 ${markerA2}`); await expect(a2Editor).toBeVisible(); - // A1 returns to conflict doc after A2 already holds an open editor draft. - await openWorkflowDocById(page, conflictDocId); + // A1 already on conflict doc; refresh etag then save while A2 keeps an open draft. await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 20_000 }); const etagA1Fresh = await fetchRevisionStateEtag(page, conflictDocId); if (etagA2 !== etagA1Fresh && etagA2 !== etagA1) {