merge: feature/docrev-ui-rerun-2026-09-22 (UI-13b claim-before-edit)
This commit is contained in:
@@ -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.
|
||||
@@ -703,6 +713,36 @@ async function readRevisionStateEtag(res: Response): Promise<string> {
|
||||
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<string> {
|
||||
const payload = await target.evaluate(async (id) => {
|
||||
const headers: Record<string, string> = { 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,
|
||||
});
|
||||
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,49 +1573,42 @@ 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);
|
||||
}
|
||||
// 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 readRevisionStateEtag(await etagA1P);
|
||||
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();
|
||||
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, 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 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}`);
|
||||
const etagA2 = await fetchRevisionStateEtag(pageA2, conflictDocId);
|
||||
if (a2Base.r !== a1Base.r) {
|
||||
fail(`A2 must open same revision as A1: ${a2Base.r} vs ${a1Base.r}`);
|
||||
}
|
||||
|
||||
const markerA1 = `A1-win-${suffix}`;
|
||||
const markerA2 = `A2-conflict-${suffix}`;
|
||||
const a2Editor = await openFirstPaneEditorAndFill(pageA2, `人工修订段落 ${markerA2}`);
|
||||
await expect(a2Editor).toBeVisible();
|
||||
|
||||
// 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) {
|
||||
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 },
|
||||
@@ -1626,7 +1659,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: `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',
|
||||
|
||||
Reference in New Issue
Block a user