test(e2e): open both editors before UI-13b first save
Require matching r/etag on both windows and keep A2's editor filled before A1 saves so the conflict is a real race. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -361,16 +361,31 @@ async function closeNamedDialog(page: Page, name: string, timeout = 10_000) {
|
||||
await expect(dialog).toHaveCount(0, { timeout });
|
||||
}
|
||||
|
||||
async function editFirstPaneBlockAndSave(target: Page, text: string) {
|
||||
async function openFirstPaneEditorAndFill(target: Page, text: string) {
|
||||
const pane = target.getByTestId('active-document-pane');
|
||||
const block = pane.locator('[data-overlay-kind], p, h1, h2').first();
|
||||
await block.dblclick({ timeout: 15_000 });
|
||||
const editor = target.locator('textarea, [contenteditable="true"]').last();
|
||||
await expect(editor).toBeVisible({ timeout: 10_000 });
|
||||
await editor.fill(text);
|
||||
return editor;
|
||||
}
|
||||
|
||||
async function saveOpenEditor(target: Page) {
|
||||
await target.getByRole('button', { name: /保存修改|保存/ }).click();
|
||||
}
|
||||
|
||||
async function editFirstPaneBlockAndSave(target: Page, text: string) {
|
||||
await openFirstPaneEditorAndFill(target, text);
|
||||
await saveOpenEditor(target);
|
||||
}
|
||||
|
||||
async function readRevisionStateEtag(res: Response): Promise<string> {
|
||||
const json = (await res.json().catch(() => ({}))) as { etag?: string };
|
||||
if (!json.etag) fail(`revision-state missing etag: HTTP ${res.status()}`);
|
||||
return json.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'));
|
||||
@@ -1094,18 +1109,33 @@ 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);
|
||||
}
|
||||
await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 20_000 });
|
||||
const { r: rBase } = await readVersionBadge(page);
|
||||
const a1Base = await readVersionBadge(page);
|
||||
const etagA1 = await readRevisionStateEtag(await etagA1P);
|
||||
|
||||
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 {
|
||||
@@ -1113,10 +1143,15 @@ test.describe.serial('document-revision-runtime UI acceptance', () => {
|
||||
}
|
||||
await expect(pageA2.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 });
|
||||
const a2Base = await readVersionBadge(pageA2);
|
||||
if (a2Base.r !== rBase) fail(`A2 must open same revision as A1: ${a2Base.r} vs ${rBase}`);
|
||||
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 markerA1 = `A1-win-${suffix}`;
|
||||
const markerA2 = `A2-conflict-${suffix}`;
|
||||
const a2Editor = await openFirstPaneEditorAndFill(pageA2, `人工修订段落 ${markerA2}`);
|
||||
await expect(a2Editor).toBeVisible();
|
||||
|
||||
const patchA1 = page.waitForResponse(
|
||||
(r) => r.url().includes('/content') && r.request().method() === 'PATCH',
|
||||
{ timeout: 30_000 },
|
||||
@@ -1127,12 +1162,13 @@ test.describe.serial('document-revision-runtime UI acceptance', () => {
|
||||
fail(`A1 save must be 2xx, got HTTP ${a1Res.status()}`);
|
||||
}
|
||||
await expect(page.getByText(markerA1)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(a2Editor).toBeVisible();
|
||||
|
||||
const patchA2 = pageA2.waitForResponse(
|
||||
(r) => r.url().includes('/content') && r.request().method() === 'PATCH',
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await editFirstPaneBlockAndSave(pageA2, `人工修订段落 ${markerA2}`);
|
||||
await saveOpenEditor(pageA2);
|
||||
const a2Res = await patchA2;
|
||||
const a2Json = (await a2Res.json().catch(() => ({}))) as { code?: string };
|
||||
if (a2Res.status() !== 409 && a2Json.code !== 'DOCUMENT_HEAD_CONFLICT') {
|
||||
@@ -1142,6 +1178,13 @@ test.describe.serial('document-revision-runtime UI acceptance', () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(pageA2.getByRole('button', { name: '刷新后重做' })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(a2Editor).toBeVisible();
|
||||
const a2Kept = await a2Editor
|
||||
.inputValue()
|
||||
.catch(async () => (await a2Editor.innerText()) || '');
|
||||
if (!a2Kept.includes(markerA2)) {
|
||||
fail(`A2 editor must keep unsaved text after conflict, got ${JSON.stringify(a2Kept)}`);
|
||||
}
|
||||
await expect(page.getByText(markerA1)).toBeVisible();
|
||||
await expect(page.getByText(markerA2)).toHaveCount(0);
|
||||
await shot(pageA2, 'UI-13b-conflict-A2.png');
|
||||
@@ -1149,8 +1192,8 @@ test.describe.serial('document-revision-runtime UI acceptance', () => {
|
||||
recordPass({
|
||||
id: 'UI-13b',
|
||||
name: 'author A/A head conflict',
|
||||
input: 'two A contexts; A1 save then A2 save on same base revision',
|
||||
actual: `A1 kept ${markerA1}; A2 HTTP ${a2Res.status()} code=${a2Json.code || ''}; A2 text not written`,
|
||||
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`,
|
||||
networkLogs: logs,
|
||||
durationMs: Date.now() - t0,
|
||||
verdict: 'PASS',
|
||||
|
||||
Reference in New Issue
Block a user