Files
Agent-System-Frontend/e2e/rdm-duties.playwright.ts
T
weichen 2ba275b991 Keep document duties tied to the current selection
Why:
Fresh documents had no first-submit action, reading active duties opened
confirmation dialogs, and delayed detail or status queries could overwrite
another selection and send its document ID with the wrong duty draft.

Evidence:
SELF real-browser regressions observed the missing action, render-time
dialogs, detail/status races, and a follow-up query after panel unmount.
Five focused Playwright scenarios passed after the fix, together with the
full npm test, npm run build, and git diff --check commands. Receipts:
/tmp/project-member-duties-browser-green-20261013.json and
/tmp/project-member-duties-full-build-green-20261013.json.

Impact:
Offer first submission independently of an existing review round. Ask for
invalidation only on a dirty save click. Guard queries and continuations
with project lifetime, selection, and request sequences; keep the existing
Picker, canonical project routing, controls, and UI tokens.

Refs:
Dev project-member-access Task 4; independent synchronized Frontend P2 review
2026-10-13 10:21:37 +00:00

161 lines
9.3 KiB
TypeScript

// Real React/Picker interaction with deterministic HTTP responses; no live account or database.
import { test, expect, type Page } from '@playwright/test'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
function deferred() {
let resolve!: () => void
const promise = new Promise<void>(ok => { resolve = ok })
return { promise, resolve }
}
async function dutiesPage(page: Page) {
const writes: { command: string; params: Record<string, unknown> }[] = []
const calls: { command: string; params: Record<string, unknown> }[] = []
const holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>()
const active = new Set<string>()
const submissions: Record<string, unknown> = { 'doc-A': null, 'doc-B': null }
const commands = ['rdm.project.member.list', 'rdm.project.member.add', 'rdm.project.member.remove',
'rdm.duty.catalog', 'rdm.duty.get', 'rdm.review.assign.doc', 'rdm.review.assign.doc.batch',
'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final', 'rdm.review.doc.status']
const docs = [
{ docId: 'doc-A', title: '文档A', typeKey: 'design', sections: 1, assigned: true, author: 'alice', reviewers: ['carol'], approver: 'dave' },
{ docId: 'doc-B', title: '文档B', typeKey: 'design', sections: 1, assigned: true, author: 'bob', reviewers: ['carol'], approver: 'dave' },
]
await page.route('**/api/**', async route => {
const request = route.request()
const path = new URL(request.url()).pathname
let body: unknown = []
if (path === '/api/health') body = { ok: true }
else if (path === '/api/me') body = { owner: 'alice', name: 'Alice', auth: true, isAdmin: true }
else if (path === '/api/projects') body = [{ id: 'host-1', name: '职责测试项目', createdAt: '', updatedAt: '',
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' } }]
else if (path === '/api/settings') body = { schema: [], defaults: {}, user: {}, project: {} }
else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true }))
else if (path.startsWith('/api/commands/')) {
const command = decodeURIComponent(path.substring('/api/commands/'.length))
const params = request.postDataJSON().params as Record<string, unknown>
expect(params.projectId).toBe('domain-7')
calls.push({ command, params })
const hold = holds.get(`${command}:${params.docId ?? ''}`)
if (hold) { hold.started.resolve(); await hold.release.promise }
let payload: unknown = {}
if (command === 'rdm.duty.catalog') payload = { docs }
else if (command === 'rdm.project.member.list') payload = { members: ['alice', 'bob', 'carol', 'dave'].map(owner => ({ owner, role: 'member', addedBy: '', addedAt: '' })) }
else if (command === 'rdm.duty.get') {
const doc = docs.find(d => d.docId === params.docId)!
payload = { docId: doc.docId, title: doc.title, assigned: doc.assigned,
roles: { author: doc.author, reviewers: doc.reviewers, approver: doc.approver }, sections: [],
active: active.has(doc.docId) ? { round: 1, state: 'submitted', createdBy: 'alice' } : null }
} else if (command === 'rdm.review.doc.status') payload = { submission: submissions[String(params.docId)] }
else { writes.push({ command, params }) }
body = { payload }
} else if (path.endsWith('/cards')) body = { cards: [] }
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
})
await page.goto('/')
await page.getByRole('button', { name: '项目设置', exact: true }).click()
await page.getByRole('tab', { name: /文档职责/ }).click()
await expect(page.getByRole('button', { name: '文档A', exact: true })).toBeVisible()
return { writes, calls, active, submissions,
hold(command: string, docId: string) {
const pending = { started: deferred(), release: deferred() }
holds.set(`${command}:${docId}`, pending)
return pending
} }
}
async function chooseMember(page: Page, label: string, owner: string) {
await page.locator(`.picker[aria-label="${label}"]`).getByRole('combobox').fill(owner)
await page.getByRole('option', { name: new RegExp(`^${owner}`) }).click()
}
async function settleResponse(page: Page, command: string, release: () => void) {
const response = page.waitForResponse(r => r.url().endsWith(`/api/commands/${command}`))
release()
await response
await page.evaluate(() => new Promise<void>(done => requestAnimationFrame(() => requestAnimationFrame(() => done()))))
}
test('a newly assigned document can submit its first whole-document review', async ({ page }) => {
const fixture = await dutiesPage(page)
await page.getByRole('button', { name: '文档A', exact: true }).click()
await expect(page.getByText('该文档尚无整文提交记录(当前职责仍可分配)。', { exact: true })).toBeVisible()
const submit = page.getByRole('button', { name: '整文提交(编制人)', exact: true })
await expect(submit).toBeEnabled()
await submit.click()
await expect(page.getByText('整文已提交审核(全文锁定)', { exact: true })).toBeVisible()
expect(fixture.writes).toEqual([{ command: 'rdm.review.doc.submit', params: { projectId: 'domain-7', docId: 'doc-A' } }])
})
test('viewing active duties is passive; only a dirty save asks for confirmation', async ({ page }) => {
const fixture = await dutiesPage(page)
fixture.active.add('doc-A')
const dialogs: string[] = []
let accept = false
page.on('dialog', async dialog => { dialogs.push(dialog.message()); accept ? await dialog.accept() : await dialog.dismiss() })
await page.getByRole('button', { name: '文档A', exact: true }).click()
await expect(page.getByRole('heading', { name: /文档A · 当前职责/ })).toBeVisible()
const save = page.getByRole('button', { name: '保存当前职责', exact: true })
await expect(save).toBeDisabled()
expect(dialogs).toEqual([])
await chooseMember(page, '编制人', 'bob')
await expect(save).toBeEnabled()
expect(dialogs).toEqual([])
await save.click()
expect(dialogs).toHaveLength(1)
expect(fixture.writes).toEqual([])
accept = true
await save.click()
await expect(page.getByText('已保存当前职责', { exact: true })).toBeVisible()
expect(dialogs).toHaveLength(2)
expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc', params: {
projectId: 'domain-7', docId: 'doc-A', author: 'bob', reviewers: ['carol'], approver: 'dave',
} }])
})
test('a late document detail cannot overwrite another selection or its saved roles', async ({ page }) => {
const fixture = await dutiesPage(page)
const old = fixture.hold('rdm.duty.get', 'doc-A')
await page.getByRole('button', { name: '文档A', exact: true }).click()
await old.started.promise
await page.getByRole('button', { name: '文档B', exact: true }).click()
await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible()
await settleResponse(page, 'rdm.duty.get', old.release.resolve)
await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible()
expect(fixture.calls.filter(c => c.command === 'rdm.review.doc.status' && c.params.docId === 'doc-A')).toEqual([])
await chooseMember(page, '批准人', 'alice')
await page.getByRole('button', { name: '保存当前职责', exact: true }).click()
await expect(page.getByText('已保存当前职责', { exact: true })).toBeVisible()
expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc', params: {
projectId: 'domain-7', docId: 'doc-B', author: 'bob', reviewers: ['carol'], approver: 'alice',
} }])
})
test('a late review status cannot replace the selected document frozen review', async ({ page }) => {
const fixture = await dutiesPage(page)
const submission = (round: number, author: string) => ({ round, state: 'submitted', digest: '', createdBy: author,
createdAt: '', duties: { author, reviewers: ['carol'], approver: 'dave' }, reviews: {}, sections: {} })
fixture.submissions['doc-A'] = submission(1, 'alice')
fixture.submissions['doc-B'] = submission(2, 'bob')
const old = fixture.hold('rdm.review.doc.status', 'doc-A')
await page.getByRole('button', { name: '文档A', exact: true }).click()
await old.started.promise
await page.getByRole('button', { name: '文档B', exact: true }).click()
await expect(page.getByRole('heading', { name: /整文审签 · 第 2 轮/ })).toBeVisible()
await settleResponse(page, 'rdm.review.doc.status', old.release.resolve)
await expect(page.getByRole('heading', { name: /整文审签 · 第 2 轮/ })).toBeVisible()
await expect(page.getByRole('heading', { name: /整文审签 · 第 1 轮/ })).toHaveCount(0)
})
test('closing the project panel invalidates a pending detail before its follow-up status read', async ({ page }) => {
const fixture = await dutiesPage(page)
const old = fixture.hold('rdm.duty.get', 'doc-A')
await page.getByRole('button', { name: '文档A', exact: true }).click()
await old.started.promise
await page.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click()
await expect(page.getByRole('tab', { name: /文档职责/ })).toHaveCount(0)
await settleResponse(page, 'rdm.duty.get', old.release.resolve)
expect(fixture.calls.filter(c => c.command === 'rdm.review.doc.status' && c.params.docId === 'doc-A')).toEqual([])
})