Integrate export controls with current identity boundaries
Why: Complete the authorized export entry while preserving current main project and session behavior. Evidence: SELF full frontend tests and build, 263 unit tests; deferred browser races reproduced then all 6 passed. Raw records stay outside source until Dev task closure. Impact: Export callbacks are invalidated on project, identity and unmount changes. Command readiness and current main security paths remain intact. Refs: Dev PR #488; Frontend PR #73; user takeover authorization on 2026-10-14
This commit is contained in:
@@ -17,14 +17,21 @@ async function login(page: Page) {
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
const gate = page.getByText('修改密码')
|
||||
// 受限设密门(2026-10-13 合同):heading 唯一定位(标题与按钮同名),
|
||||
// 门挂载后登录卡已卸载,fill 不再与登录卡竞态。
|
||||
const gate = page.getByRole('heading', { name: '设置新密码' })
|
||||
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (gated) {
|
||||
const pwInputs = page.locator('.lg-card input[type="password"]')
|
||||
await pwInputs.nth(0).fill(PASSWORD)
|
||||
await pwInputs.nth(0).fill(PASSWORD2)
|
||||
await pwInputs.nth(1).fill(PASSWORD2)
|
||||
await pwInputs.nth(2).fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '设置新密码并进入' }).click()
|
||||
await page.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||||
// 设密成功后服务端销毁会话:等成功页再回登录(在途点取消会被会话复验拒绝)。
|
||||
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: '返回登录', exact: true }).click()
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
} else {
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// 登录恢复流程真实浏览器验收(2026-10-13 合同,docs/svc-deploy/
|
||||
// 2026-10-13-login-recovery-contract.md):管理员签发一次性登录 Key →
|
||||
// 用户在登录页只输 Key 兑换 → 受限设密门(无当前密码框)→ 设密成功回登录页 →
|
||||
// 新密码登录进工作台;重复兑换同一 Key 被拒。
|
||||
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
|
||||
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
|
||||
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const ADMIN = process.env.E2E_ADMIN || 'e2e-admin'
|
||||
const ADMIN_BOOT = process.env.E2E_ADMIN_PASSWORD || 'E2eAdmin#2026'
|
||||
const ADMIN_PW = process.env.E2E_ADMIN_PASSWORD2 || 'E2eAdmin#2026b'
|
||||
const USER = process.env.E2E_LOGIN_KEY_USER || 'e2e-user'
|
||||
const USER_PW = process.env.E2E_LOGIN_KEY_PASSWORD2 || 'E2eUser#2026b'
|
||||
|
||||
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
|
||||
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (auth-on instance required)')
|
||||
|
||||
async function passwordLogin(page: Page, owner: string, password: string) {
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('由管理员分配').fill(owner)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(password)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
}
|
||||
|
||||
/** 管理员登录;初始密码(must-change)则先完成受限设密再以新密码重登。
|
||||
* 初始密码单次有效:已跑过本用例的环境(boot 已被改掉)自动改用新密码。 */
|
||||
async function adminLogin(page: Page) {
|
||||
await passwordLogin(page, ADMIN, ADMIN_BOOT)
|
||||
// 门标题与提交按钮同名(设置新密码):用 heading 角色唯一定位,
|
||||
// getByText 双命中会触发 strict-mode 抛错被 catch 吞掉、误判 gated=false。
|
||||
let gate = page.getByRole('heading', { name: '设置新密码' })
|
||||
let gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (!gated) {
|
||||
// boot 密码失败(此前运行已完成改密):换正式密码重登。
|
||||
const loginErr = await page.locator('.lg-err').waitFor({ state: 'visible', timeout: 3_000 }).then(() => true).catch(() => false)
|
||||
if (!loginErr) throw new Error('admin login neither gated nor errored')
|
||||
await passwordLogin(page, ADMIN, ADMIN_PW)
|
||||
gate = page.getByRole('heading', { name: '设置新密码' })
|
||||
gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
}
|
||||
if (gated) {
|
||||
const pw = page.locator('.lg-card input[type="password"]')
|
||||
await pw.nth(0).fill(ADMIN_PW)
|
||||
await pw.nth(1).fill(ADMIN_PW)
|
||||
await page.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||||
// 等设密事务完成再回登录:提交在途时点取消会登出受限会话、
|
||||
// 服务端按会话复验拒绝该次设密(产品正确行为,测试必须同步成功页)。
|
||||
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: '返回登录', exact: true }).click()
|
||||
await passwordLogin(page, ADMIN, ADMIN_PW)
|
||||
}
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test('登录恢复全流程:签发 → 兑换 → 免旧密码设密 → 新密码登录;重复兑换被拒', async ({ page }) => {
|
||||
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
|
||||
test.setTimeout(120_000)
|
||||
// 1) 管理员进入用户管理,为目标用户生成一次性登录 Key(明文仅显示一次)。
|
||||
await adminLogin(page)
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '系统管理…' }).click()
|
||||
const zone = page.getByRole('dialog', { name: '系统管理' })
|
||||
await expect(zone).toBeVisible()
|
||||
// 每个用户行都有同款按钮:限定目标用户所在行。
|
||||
await zone.locator('tr', { hasText: USER }).getByRole('button', { name: '生成登录 Key' }).click()
|
||||
await zone.getByRole('button', { name: '生成 Key', exact: true }).click()
|
||||
const keyInput = zone.locator('input[aria-label="一次性登录 Key(仅显示一次)"]')
|
||||
await expect(keyInput).toBeVisible({ timeout: 10_000 })
|
||||
const key = await keyInput.inputValue()
|
||||
expect(key).toMatch(/^alk_/)
|
||||
await expect(zone.getByText(/单次有效/)).toBeVisible()
|
||||
|
||||
// 2) 管理员退出系统管理与登录态,回到登录页。
|
||||
//(Key 面板内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
|
||||
await zone.getByRole('button', { name: '返回工作台' }).click()
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '退出登录' }).click()
|
||||
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// 3) 用户只输 Key 兑换;身份由服务端绑定确定(页面不出现用户名输入框)。
|
||||
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
|
||||
await page.getByPlaceholder('alk_…').fill(key)
|
||||
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
|
||||
|
||||
// 4) 受限设密门:服务端确认用户名;无当前密码输入框。
|
||||
const gate = page.locator('.lg-card')
|
||||
await expect(gate.getByText(`账号 ${USER}(服务端确认)`)).toBeVisible({ timeout: 10_000 })
|
||||
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(0)
|
||||
const pw = gate.locator('input[type="password"]')
|
||||
await pw.nth(0).fill(USER_PW)
|
||||
await pw.nth(1).fill(USER_PW)
|
||||
await gate.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||||
|
||||
// 5) 成功页:提示用新密码登录,唯一出口回登录页。
|
||||
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: '返回登录', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible()
|
||||
|
||||
// 6) 新密码登录进工作台(旧初始密码已不可用一并验证)。
|
||||
await passwordLogin(page, USER, USER_PW)
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// 7) 同一 Key 重复兑换被拒,错误文案指向管理员重签。
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '退出登录' }).click()
|
||||
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
|
||||
await page.getByPlaceholder('alk_…').fill(key)
|
||||
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
|
||||
await expect(page.getByText('该 Key 已被使用过,请联系管理员重新签发')).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
// 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([])
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
// Isolated browser component regression: actual React workspace, deliberately deferred API responses.
|
||||
// This checks client request ownership, not server authorization or business acceptance.
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_COMPONENT_BASE_URL, 'requires the isolated Vite component test server')
|
||||
test.use({ baseURL: process.env.PLAYWRIGHT_COMPONENT_BASE_URL })
|
||||
|
||||
async function mount(page: Page, delayed: string) {
|
||||
const index = await page.request.get('/')
|
||||
const html = (await index.text()).replace(/<script[^>]+src="\/src\/main\.tsx[^" ]*"[^>]*><\/script>/, '')
|
||||
// Let Vite prepare ReactDOM before requesting its optimized module directly.
|
||||
await page.request.get('/src/main.tsx')
|
||||
await page.route('**/__export_test', route => route.fulfill({ contentType: 'text/html',
|
||||
body: html }))
|
||||
await page.goto('/__export_test')
|
||||
await page.waitForFunction(() => typeof (window as any).$RefreshReg$ === 'function')
|
||||
await page.evaluate(async delayed => {
|
||||
const load = (path: string) => import(/* @vite-ignore */ path)
|
||||
const { default: React } = await load('/node_modules/.vite/deps/react.js')
|
||||
const { default: ReactDOM } = await load('/node_modules/.vite/deps/react-dom_client.js')
|
||||
const { RdmAuditWorkspace } = await load('/src/components/RdmAuditWorkspace.tsx')
|
||||
const { api } = await load('/src/api.ts')
|
||||
const { auth } = await load('/src/auth.ts')
|
||||
const { store } = await load('/src/store.ts')
|
||||
const { RDM_AUDIT_COMMANDS, RDM_EXPORT_COMMANDS } = await load('/src/workspaces.ts')
|
||||
const calls: { command: string; params: any }[] = []
|
||||
let actor = 'alice'
|
||||
const pending: (() => void)[] = []
|
||||
let downloads = 0
|
||||
URL.createObjectURL = () => { downloads++; return 'blob:export-test' }
|
||||
URL.revokeObjectURL = () => {}
|
||||
HTMLAnchorElement.prototype.click = () => {}
|
||||
api.listPlugins = async () => []
|
||||
api.listWorkspaces = async () => []
|
||||
api.listCommands = async () => [...RDM_AUDIT_COMMANDS, ...RDM_EXPORT_COMMANDS].map(id => ({ id, enabled: true }))
|
||||
api.adminZoneSections = async () => []
|
||||
api.getSettings = async () => ({ user: {} })
|
||||
api.rdmCommand = async (command: string, params: any = {}) => {
|
||||
calls.push({ command, params })
|
||||
const result = command === 'rdm.inspect.capabilities'
|
||||
? { actor, crossProject: false, projects: ['A', 'B'].map(projectId => ({ projectId, name: actor + projectId, canAudit: true })) }
|
||||
: command === 'rdm.inspect.summary'
|
||||
? { projectId: params.projectId, counts: {}, watermark: 7 }
|
||||
: command === 'rdm.inspect.export.create'
|
||||
? { exportId: params.projectId + '-report', watermark: 7, sha256: 'abc', fileName: 'report.docx' }
|
||||
: command === 'rdm.inspect.export.status'
|
||||
? { expiresAt: '2099-01-01', watermark: 7, sha256: 'abc' }
|
||||
: { fileName: 'report.docx', mime: 'application/octet-stream', dataB64: 'QQ==', sha256: 'abc' }
|
||||
if (command === delayed && params.projectId === 'A') {
|
||||
await new Promise<void>(resolve => pending.push(resolve))
|
||||
}
|
||||
return result
|
||||
}
|
||||
await store.refreshPlugins()
|
||||
const root = ReactDOM.createRoot(document.getElementById('root')!)
|
||||
root.render(React.createElement(RdmAuditWorkspace))
|
||||
;(window as any).exportTest = { calls, release: () => pending.splice(0).forEach(resolve => resolve()),
|
||||
downloads: () => downloads, identity: async () => { actor = 'bob'; auth.beginSession(); await store.refreshPlugins() },
|
||||
unmount: () => root.unmount() }
|
||||
}, delayed)
|
||||
await expect(page.locator('.rdm-audit-export')).toBeVisible()
|
||||
}
|
||||
async function release(page: Page) {
|
||||
await page.evaluate(async () => { (window as any).exportTest.release(); await new Promise(resolve => setTimeout(resolve, 0)) })
|
||||
}
|
||||
const generate = (page: Page) => page.getByRole('button', { name: '生成', exact: true }).click()
|
||||
|
||||
for (const stage of ['create', 'status']) {
|
||||
test(`ignores old ${stage} response after project A -> B -> A`, async ({ page }) => {
|
||||
await mount(page, 'rdm.inspect.export.' + stage)
|
||||
await generate(page)
|
||||
await expect.poll(() => page.evaluate(stage => (window as any).exportTest.calls
|
||||
.some((c: any) => c.command === 'rdm.inspect.export.' + stage), stage)).toBe(true)
|
||||
await page.getByLabel('选择项目').selectOption('B')
|
||||
await expect(page.locator('.rdm-audit-export')).toBeVisible()
|
||||
await page.getByLabel('选择项目').selectOption('A')
|
||||
await expect(page.locator('.rdm-audit-export')).toBeVisible()
|
||||
await release(page)
|
||||
await expect(page.getByRole('button', { name: '下载', exact: true })).toHaveCount(0)
|
||||
if (stage === 'create') expect(await page.evaluate(() => (window as any).exportTest.calls
|
||||
.filter((c: any) => c.command === 'rdm.inspect.export.status').length)).toBe(0)
|
||||
await expect(page.getByRole('button', { name: '生成', exact: true })).toBeEnabled()
|
||||
})
|
||||
}
|
||||
for (const change of ['project', 'identity', 'unmount']) {
|
||||
test(`suppresses delayed download after ${change} change`, async ({ page }) => {
|
||||
await mount(page, 'rdm.inspect.export.download')
|
||||
await generate(page)
|
||||
await page.getByRole('button', { name: '下载', exact: true }).click()
|
||||
if (change === 'project') {
|
||||
await page.getByLabel('选择项目').selectOption('B')
|
||||
await expect(page.locator('.rdm-audit-export')).toBeVisible()
|
||||
} else if (change === 'identity') {
|
||||
await page.evaluate(() => (window as any).exportTest.identity())
|
||||
await expect(page.getByLabel('选择项目').locator('option').first()).toHaveText('bobA')
|
||||
} else await page.evaluate(() => (window as any).exportTest.unmount())
|
||||
await release(page)
|
||||
expect(await page.evaluate(() => (window as any).exportTest.downloads())).toBe(0)
|
||||
})
|
||||
}
|
||||
test('current identity can generate and download normally', async ({ page }) => {
|
||||
await mount(page, '')
|
||||
await generate(page)
|
||||
await page.getByRole('button', { name: '下载', exact: true }).click()
|
||||
await expect.poll(() => page.evaluate(() => (window as any).exportTest.downloads())).toBe(1)
|
||||
await expect(page.locator('.rdm-audit-export-result')).toContainText('水位 7')
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import { test, expect, type Page, type APIRequestContext, type Response } from '@playwright/test'
|
||||
import type { Artifact, Message, Part, Run, UploadInfo } from '../src/contracts'
|
||||
|
||||
test.skip(!process.env.WORK_COMPANION_LIVE_BROWSER, 'Requires the task-owned backend, SQLite data and MockRunner')
|
||||
|
||||
async function openRealConversation(page: Page, request: APIRequestContext) {
|
||||
const projectResponse = await request.post('/api/projects', { data: { name: '工作台验收 ' + Date.now() } })
|
||||
expect(projectResponse.ok()).toBe(true)
|
||||
const project = await projectResponse.json()
|
||||
const conversationResponse = await request.post(`/api/projects/${project.id}/conversations`, { data: { title: '真实接口验收' } })
|
||||
expect(conversationResponse.ok()).toBe(true)
|
||||
const conversation = await conversationResponse.json()
|
||||
await page.addInitScript(({ projectId, conversationId }) => {
|
||||
localStorage.setItem('as.conv', conversationId)
|
||||
localStorage.setItem('as.ui', JSON.stringify({ theme: 'light', rightCollapsed: true, lastProjectId: projectId }))
|
||||
}, { projectId: project.id, conversationId: conversation.id })
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.chat-title')).toHaveText('真实接口验收')
|
||||
return { project, conversation }
|
||||
}
|
||||
|
||||
test('real upload/send/reload preserves three actual attachments', async ({ page, request }, testInfo) => {
|
||||
const { project, conversation } = await openRealConversation(page, request)
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
const uploadResponses: Response[] = []
|
||||
const uploadPath = `/api/projects/${project.id}/uploads`
|
||||
const observeUpload = (response: Response) => {
|
||||
if (response.request().method() === 'POST' && new URL(response.url()).pathname === uploadPath) uploadResponses.push(response)
|
||||
}
|
||||
page.on('response', observeUpload)
|
||||
await page.locator('input[type=file]').first().setInputFiles([
|
||||
{ name: '会议纪要.txt', mimeType: 'text/plain', buffer: Buffer.from('会议决定:先明确交付日期') },
|
||||
{ name: '需求说明.txt', mimeType: 'text/plain', buffer: Buffer.from('需求:整理材料并生成摘要') },
|
||||
{ name: '评审意见.txt', mimeType: 'text/plain', buffer: Buffer.from('评审意见:补充验收标准') },
|
||||
])
|
||||
await expect(page.locator('.attach-chip')).toHaveCount(3)
|
||||
page.off('response', observeUpload)
|
||||
expect(uploadResponses).toHaveLength(3)
|
||||
const uploads: UploadInfo[] = await Promise.all(uploadResponses.map(async response => {
|
||||
expect(response.ok()).toBe(true)
|
||||
return response.json()
|
||||
}))
|
||||
expect(uploads.map(upload => upload.filename)).toEqual(['会议纪要.txt', '需求说明.txt', '评审意见.txt'])
|
||||
const attachmentIds = uploads.map(upload => upload.artifactId)
|
||||
for (const id of attachmentIds) expect(id).toEqual(expect.stringMatching(/\S+/))
|
||||
expect(new Set(attachmentIds).size).toBe(3)
|
||||
const artifactsResponse = await request.get(`/api/projects/${project.id}/artifacts`)
|
||||
expect(artifactsResponse.ok()).toBe(true)
|
||||
const artifacts: Artifact[] = await artifactsResponse.json()
|
||||
expect(artifacts).toHaveLength(3)
|
||||
expect(artifacts.map(artifact => artifact.id).sort()).toEqual([...attachmentIds].sort())
|
||||
const expectedParts: Part[] = uploads.map(upload => {
|
||||
const artifact = artifacts.find(artifact => artifact.id === upload.artifactId)!
|
||||
expect(artifact.name).toBe(upload.filename)
|
||||
return { type: 'artifact_ref', artifact: { artifactId: artifact.id, name: artifact.name, kind: artifact.kind } }
|
||||
})
|
||||
const messagePath = `/api/conversations/${conversation.id}/messages`
|
||||
const [sendRequest, sendResponse] = await Promise.all([
|
||||
page.waitForRequest(request => request.method() === 'POST' && new URL(request.url()).pathname === messagePath),
|
||||
page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === messagePath),
|
||||
page.getByRole('button', { name: '发送', exact: true }).click(),
|
||||
])
|
||||
const sent = sendRequest.postDataJSON()
|
||||
expect(sent.text).toBe('')
|
||||
expect(sent.attachmentIds).toEqual(attachmentIds)
|
||||
expect(sendResponse.ok()).toBe(true)
|
||||
const accepted: { message: Message; run: Run } = await sendResponse.json()
|
||||
expect(accepted.message.role).toBe('user')
|
||||
expect(accepted.message.conversationId).toBe(conversation.id)
|
||||
expect(accepted.message.parts).toEqual(expectedParts)
|
||||
expect(accepted.run.kind).toBe('conversation')
|
||||
await expect.poll(async () => {
|
||||
const response = await request.get(`/api/conversations/${conversation.id}`)
|
||||
expect(response.ok()).toBe(true)
|
||||
const current: { runs: Run[] } = await response.json()
|
||||
return current.runs.some(run => run.id === accepted.run.id && run.kind === 'conversation' && run.status === 'completed')
|
||||
}).toBe(true)
|
||||
async function persistedMessage() {
|
||||
const response = await request.get(`/api/conversations/${conversation.id}`)
|
||||
expect(response.ok()).toBe(true)
|
||||
const current: { messages: Message[] } = await response.json()
|
||||
const users = current.messages.filter(message => message.role === 'user')
|
||||
expect(users).toHaveLength(1)
|
||||
expect(users[0].id).toBe(accepted.message.id)
|
||||
expect(users[0].conversationId).toBe(conversation.id)
|
||||
expect(users[0].parts).toEqual(expectedParts)
|
||||
return users[0]
|
||||
}
|
||||
const beforeReload = await persistedMessage()
|
||||
await page.reload()
|
||||
await expect(page.locator('.chat-title')).toHaveText('真实接口验收')
|
||||
const afterReload = await persistedMessage()
|
||||
expect(afterReload).toEqual(beforeReload)
|
||||
const association = { projectId: project.id, conversationId: conversation.id, runId: accepted.run.id,
|
||||
uploads: uploads.map(({ artifactId, filename }) => ({ artifactId, filename })),
|
||||
sent: { text: sent.text, attachmentIds: sent.attachmentIds }, beforeReload, afterReload }
|
||||
await testInfo.attach('real-material-associations', { body: Buffer.from(JSON.stringify(association, null, 2)), contentType: 'application/json' })
|
||||
console.log('real-material-associations', JSON.stringify(association))
|
||||
await page.getByRole('button', { name: '打开文件', exact: true }).click()
|
||||
await expect(page.locator('[data-artifact-row]')).toHaveCount(3)
|
||||
await page.locator('[data-artifact-row]').first().click()
|
||||
await expect(page.locator('.preview-pane-name')).toBeVisible()
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('real background card and HITL decision survive query reconciliation', async ({ page, request }) => {
|
||||
const { conversation } = await openRealConversation(page, request)
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
const input = page.getByRole('textbox', { name: '工作目标或消息', exact: true })
|
||||
// Backend's deterministic runner exercises the real run/card/event lifecycle;
|
||||
// this is not a live-model quality or stage-findings test.
|
||||
await input.fill('#background')
|
||||
await page.getByRole('button', { name: '发送', exact: true }).click()
|
||||
await expect.poll(async () => {
|
||||
const out = await request.get(`/api/conversations/${conversation.id}/cards`).then(r => r.json())
|
||||
return out.cards?.some((card: { kind: string; status: string }) => card.kind === 'progress' && ['done', 'failed'].includes(card.status))
|
||||
}, { timeout: 15000 }).toBe(true)
|
||||
await expect(page.locator('[data-conversation-work]')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '发送', exact: true })).toBeVisible()
|
||||
await input.fill('#hitl')
|
||||
await page.getByRole('button', { name: '发送', exact: true }).click()
|
||||
await expect(page.locator('.hitl-card')).toBeVisible()
|
||||
await page.locator('.hitl-card').getByRole('button', { name: /Markdown 文档/ }).click()
|
||||
await expect(page.locator('.hitl-card')).toHaveCount(0)
|
||||
await expect.poll(async () => {
|
||||
const current = await request.get(`/api/conversations/${conversation.id}`).then(r => r.json())
|
||||
return current.activeHitl.length
|
||||
}).toBe(0)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import type { Artifact, Conversation, SurfaceMeta, SurfaceSnapshot } from '../src/contracts'
|
||||
|
||||
test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview')
|
||||
|
||||
const stamp = '2026-10-12T10:00:00Z'
|
||||
const conversation: Conversation = {
|
||||
id: 'navigation-conversation', projectId: 'navigation-project', title: '继续阅读材料',
|
||||
createdAt: stamp, updatedAt: stamp,
|
||||
}
|
||||
const artifact: Artifact = {
|
||||
id: 'navigation-document', projectId: conversation.projectId, name: '材料摘要.md', kind: 'markdown',
|
||||
size: 64, createdAt: stamp, updatedAt: stamp,
|
||||
}
|
||||
const snapshot: SurfaceSnapshot = {
|
||||
identity: { surfaceId: 'saved-navigation-page', owner: 'reader', pluginId: 'generative-ui', conversationId: conversation.id },
|
||||
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' }, revision: 1, activationEpoch: 1,
|
||||
spec: { title: '已保存的材料页面', nodes: [
|
||||
{ id: 'summary', type: 'markdown', markdown: '已保存的材料范围' },
|
||||
{ id: 'source', type: 'doc-ref', docId: artifact.id },
|
||||
] },
|
||||
}
|
||||
const metadata: SurfaceMeta = {
|
||||
surfaceId: snapshot.identity.surfaceId, title: snapshot.spec.title, pluginId: snapshot.identity.pluginId,
|
||||
revision: snapshot.revision, conversationId: conversation.id, updatedAt: stamp,
|
||||
}
|
||||
|
||||
// Delay the external list contract, while running the real store, App and panes.
|
||||
// The saved selection is restored by openConversation rather than test-only state injection.
|
||||
async function fixture(page: Page) {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
let release!: () => void
|
||||
const hydration = new Promise<void>(resolve => { release = resolve })
|
||||
await page.addInitScript(({ conversationId }) => {
|
||||
localStorage.setItem('as.conv', conversationId)
|
||||
localStorage.setItem('as.ui', JSON.stringify({
|
||||
theme: 'light', rightTab: 'files', filesView: 'preview', rightCollapsed: true,
|
||||
}))
|
||||
}, { conversationId: conversation.id })
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request()
|
||||
const path = new URL(request.url()).pathname
|
||||
if (request.method() !== 'GET') return route.fulfill({ status: 405, body: 'read-only navigation fixture' })
|
||||
if (path === '/api/events') return route.fulfill({ contentType: 'text/event-stream', body: ': connected\n\n' })
|
||||
let body: unknown = []
|
||||
if (path === '/api/health') body = { ok: true }
|
||||
else if (path === '/api/me') body = { owner: 'reader', name: '材料阅读者', auth: true, authVia: 'session', isAdmin: false }
|
||||
else if (path === '/api/projects') body = [{ id: conversation.projectId, name: '材料项目', createdAt: stamp, updatedAt: stamp }]
|
||||
else if (path === '/api/conversations') body = [conversation]
|
||||
else if (path === `/api/conversations/${conversation.id}`) body = { conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 }
|
||||
else if (path === `/api/conversations/${conversation.id}/surfaces`) { await hydration; body = [metadata] }
|
||||
else if (path === `/api/surfaces/${snapshot.identity.surfaceId}`) body = snapshot
|
||||
else if (path === `/api/conversations/${conversation.id}/cards`) body = { cards: [] }
|
||||
else if (path === `/api/projects/${conversation.projectId}/artifacts`) body = [artifact]
|
||||
else if (path === `/api/artifacts/${artifact.id}/content`) {
|
||||
return route.fulfill({ contentType: 'text/markdown', body: '# 材料摘要\n\n原始材料的阅读范围' })
|
||||
} else if (path === '/api/settings') body = { user: {}, project: {}, effective: {}, schema: [] }
|
||||
else if (path === '/api/workspaces') body = [{ id: 'navigation-materials', title: '材料工作区',
|
||||
body: [{ type: 'doc-ref', docId: artifact.id }] }]
|
||||
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
|
||||
})
|
||||
const listRequest = page.waitForRequest(request => new URL(request.url()).pathname === `/api/conversations/${conversation.id}/surfaces`)
|
||||
await page.goto('/')
|
||||
await listRequest
|
||||
await expect(page.locator('.chat-title')).toHaveText(conversation.title)
|
||||
return { release, errors }
|
||||
}
|
||||
|
||||
for (const width of [736, 390]) {
|
||||
test(`keeps context and focus when saved Surface hydration arrives at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
const state = await fixture(page)
|
||||
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||
const search = page.getByRole('textbox', { name: '搜索本项目会话', exact: true })
|
||||
await search.fill('材料')
|
||||
await expect(search).toBeFocused()
|
||||
state.release()
|
||||
// The real hidden preview proves both restored tab selection and snapshot hydration finished.
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
|
||||
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'context')
|
||||
await expect(page.locator('.left-nav')).toBeVisible()
|
||||
await expect(page.locator('.main')).toBeHidden()
|
||||
await expect(page.locator('.right-panel')).toBeHidden()
|
||||
await expect(search).toHaveValue('材料')
|
||||
await expect(search).toBeFocused()
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test(`preserves explicit work, Surface, document and return navigation at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
const state = await fixture(page)
|
||||
state.release()
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
|
||||
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'work')
|
||||
await expect(page.locator('.right-panel')).toBeVisible()
|
||||
await expect(page.locator('.left-nav')).toBeHidden()
|
||||
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||
await page.getByRole('button', { name: '打开文件', exact: true }).click()
|
||||
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'files')
|
||||
await page.getByRole('button', { name: '返回文件列表', exact: true }).click()
|
||||
await page.locator(`[data-surface-row="${snapshot.identity.surfaceId}"]`).click()
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
|
||||
// Surface doc-ref uses the existing document bridge, which opens the same preview.
|
||||
await page.locator('.preview-pane').getByRole('button', { name: artifact.id, exact: true }).click()
|
||||
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
|
||||
await expect(page.locator('.preview-pane-body')).toContainText('原始材料的阅读范围')
|
||||
await page.getByRole('button', { name: '展开预览', exact: true }).click()
|
||||
await expect(page.locator('.right-panel')).toHaveClass(/expanded/)
|
||||
await page.getByRole('button', { name: '返回对话', exact: true }).click()
|
||||
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'conversation')
|
||||
await expect(page.locator('.main')).toBeVisible()
|
||||
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await expect(page.locator('.main')).toBeVisible()
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'conversation')
|
||||
await expect(page.locator('.left-nav')).toBeHidden()
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test(`opens a workspace document while context overlays the selected files pane at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
const state = await fixture(page)
|
||||
state.release()
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
|
||||
await page.getByRole('button', { name: '打开文件', exact: true }).click()
|
||||
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||
await page.locator('.left-nav').getByRole('button', { name: /材料工作区/ }).click()
|
||||
const workspace = page.getByRole('dialog', { name: '材料工作区', exact: true })
|
||||
await workspace.getByRole('button', { name: artifact.name, exact: true }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'files')
|
||||
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
|
||||
await expect(page.locator('.preview-pane-body')).toContainText('原始材料的阅读范围')
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import type { ActivityCard, Artifact, Conversation, StreamEvent } from '../src/contracts'
|
||||
|
||||
test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview')
|
||||
|
||||
const stamp = '2026-10-12T10:00:00Z'
|
||||
const project = { id: 'p1', name: '产品升级计划', createdAt: stamp, updatedAt: stamp }
|
||||
const conversations: Conversation[] = [
|
||||
{ id: 'c1', projectId: 'p1', title: '整理项目材料', createdAt: stamp, updatedAt: stamp },
|
||||
{ id: 'c2', projectId: 'p1', title: '讨论本周工作', createdAt: stamp, updatedAt: stamp },
|
||||
]
|
||||
const artifact: Artifact = { id: 'doc1', projectId: 'p1', name: '材料整理摘要.md', kind: 'markdown', size: 64, createdAt: stamp, updatedAt: stamp }
|
||||
function work(id: string, status: ActivityCard['status'], over: Partial<ActivityCard> = {}): ActivityCard {
|
||||
return { id, status, projectId: 'p1', conversationId: 'c1', kind: 'progress', title: '整理项目材料', createdAt: stamp, updatedAt: stamp, ...over }
|
||||
}
|
||||
const progress = work('work1', 'active', { body: [{ type: 'kv', kv: [
|
||||
['状态', '进行中'], ['正在做', '核对会议结论'], ['当前发现', '两份材料的时间安排需要核对'], ['剩余', '评审意见仍待核查'], ['材料键', 'private-material-key'],
|
||||
] }] })
|
||||
const confirmation = work('confirm1', 'action_required', { kind: 'confirm', title: '生成项目草稿',
|
||||
body: [{ type: 'markdown', markdown: '依据三份材料新增一份草稿,保留原件,不对外发送' }],
|
||||
actions: [{ id: 'confirm', label: '确认生成', style: 'primary' }, { id: 'cancel', label: '暂不生成' }],
|
||||
})
|
||||
|
||||
async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fontSize = 14) {
|
||||
let cards = [...initialCards]
|
||||
let actionCount = 0
|
||||
let releaseAction: (() => void) | undefined
|
||||
let holdAction = false
|
||||
let sent: { text: string; attachmentIds: string[] } | undefined
|
||||
let uploadNumber = 0
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
await page.addInitScript(({ fontSize }) => {
|
||||
localStorage.setItem('as.conv', 'c1')
|
||||
localStorage.setItem('as.ui', JSON.stringify({ theme: 'light', fontSize, rightCollapsed: true, leftWidth: 260, rightWidth: 360 }))
|
||||
const streams: { url: string; emit: (event: unknown) => void }[] = []
|
||||
class TestEventSource {
|
||||
onopen: (() => void) | null = null
|
||||
onerror = null
|
||||
onmessage: ((event: { data: string }) => void) | null = null
|
||||
listeners: Record<string, ((event: { data: string }) => void)[]> = {}
|
||||
constructor(public url: string) { streams.push(this); queueMicrotask(() => this.onopen?.()) }
|
||||
addEventListener(type: string, callback: (event: { data: string }) => void) { (this.listeners[type] ??= []).push(callback) }
|
||||
emit(event: unknown) {
|
||||
const value = event as { type: string }
|
||||
for (const callback of this.listeners[value.type] ?? []) callback({ data: JSON.stringify(event) })
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
Object.assign(window, { EventSource: TestEventSource, __workStreams: streams })
|
||||
}, { fontSize })
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const path = url.pathname
|
||||
let body: unknown = []
|
||||
if (path === '/api/health') body = { ok: true }
|
||||
else if (path === '/api/me') body = { owner: 'reader', name: '林老师', auth: false, isAdmin: false }
|
||||
else if (path === '/api/projects') body = [project]
|
||||
else if (path === '/api/conversations') body = conversations
|
||||
else if (path === '/api/projects/p1/conversations' && request.method() === 'POST') body = conversations[1]
|
||||
else if (/^\/api\/conversations\/c[12]$/.test(path)) {
|
||||
const id = path.split('/').at(-1)!
|
||||
body = { conversation: conversations.find(c => c.id === id), messages: id === 'c1' ? [
|
||||
{ id: 'm1', conversationId: id, role: 'user', parts: [{ type: 'text', text: '请整理这三份材料,看看需要优先处理什么' }], createdAt: stamp },
|
||||
{ id: 'm2', conversationId: id, role: 'assistant', parts: [{ type: 'text', text: '先核对时间安排,再补齐验收条件。其他内容仍在整理。' }], createdAt: stamp },
|
||||
] : [], runs: [], activeHitl: [], lastSeq: 10 }
|
||||
} else if (path === '/api/conversations/c1/cards') body = { cards }
|
||||
else if (path === '/api/conversations/c2/cards') body = { cards: [] }
|
||||
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
|
||||
else if (path === '/api/projects/p1/artifacts') body = [artifact]
|
||||
else if (path === '/api/artifacts/doc1/content') {
|
||||
await route.fulfill({ status: 200, contentType: 'text/markdown', body: '# 材料整理摘要\n\n已整理两份材料,评审意见仍待核对' }); return
|
||||
} else if (path === '/api/settings') body = { user: { runtime: { mock: false } }, project: {}, effective: {}, schema: [] }
|
||||
else if (path === '/api/profiles') body = [{ id: 'default', name: '工作助手', model: {}, tools: { allow: [] }, context: { maxHistoryMessages: 20 }, hitl: { askOnAmbiguity: true }, systemPrompt: '' }]
|
||||
else if (path === '/api/projects/p1/uploads') body = { artifactId: 'upload' + ++uploadNumber, filename: '材料.txt', size: 16 }
|
||||
else if (/\/messages$/.test(path) && request.method() === 'POST') {
|
||||
sent = request.postDataJSON()
|
||||
body = { message: { id: 'sent', conversationId: 'c1', role: 'user', parts: [{ type: 'text', text: sent?.text ?? '' }], createdAt: stamp }, run: { id: 'run1', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'running', createdAt: stamp } }
|
||||
} else if (path === '/api/cards/confirm1/actions/confirm') {
|
||||
actionCount++
|
||||
if (holdAction) await new Promise<void>(resolve => { releaseAction = resolve })
|
||||
cards = cards.map(card => card.id === 'confirm1' ? { ...card, status: 'resolved' } : card)
|
||||
body = { card: cards.find(card => card.id === 'confirm1') }
|
||||
}
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
})
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.chat-title')).toHaveText('整理项目材料')
|
||||
if (initialCards.length) await expect(page.locator('[data-conversation-work]')).toBeVisible()
|
||||
return { errors, get actionCount() { return actionCount }, get sent() { return sent }, hold() { holdAction = true }, release() { releaseAction?.() },
|
||||
async emit(type: StreamEvent['type'], payload: unknown, seq: number) {
|
||||
await page.evaluate(({ type, payload, seq }) => {
|
||||
const win = window as typeof window & { __workStreams: { url: string; emit: (e: unknown) => void }[] }
|
||||
const stream = win.__workStreams.findLast(s => s.url.includes('conversationId=c1'))
|
||||
if (!stream) throw Error('conversation stream unavailable')
|
||||
stream.emit({ type, payload, seq, conversationId: 'c1', ts: '2026-10-12T10:00:00Z', v: 1 })
|
||||
}, { type, payload, seq })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('keeps real summary and details accessible with right pane closed', async ({ page }) => {
|
||||
const state = await fixture(page)
|
||||
await expect(page.locator('.right-panel')).toBeHidden()
|
||||
await expect(page.getByText('核对会议结论', { exact: true }).first()).toBeVisible()
|
||||
await expect(page.getByText('private-material-key')).toHaveCount(0)
|
||||
await page.locator('[data-conversation-work]').getByRole('button', { name: '查看依据与范围', exact: true }).click()
|
||||
await page.locator('[data-conversation-work]').getByRole('button', { name: '查看执行记录', exact: true }).click()
|
||||
await expect(page.locator('[data-conversation-work]').getByText('private-material-key')).toBeVisible()
|
||||
await state.emit('card.status', { cardId: 'work1', status: 'done' }, 12)
|
||||
await state.emit('card.status', { cardId: 'work1', status: 'active' }, 11)
|
||||
await expect(page.locator('[data-conversation-work] .acard-status')).toHaveText('已完成')
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps one expanded ordinary summary when the desktop work pane opens', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 })
|
||||
const state = await fixture(page)
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
await expect(page.locator('.right-panel .acard')).toHaveCount(1)
|
||||
await expect(page.locator('[data-conversation-work] .acard')).toHaveCount(0)
|
||||
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: /整理项目材料 · 查看摘要/ })).toBeVisible()
|
||||
if (process.env.WORK_COMPANION_SCREENSHOT) await page.screenshot({ path: process.env.WORK_COMPANION_SCREENSHOT, fullPage: true })
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('preserves the selected material, physical scroll and focus when background work finishes', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 })
|
||||
const tabbed = work('work1', 'active', { body: [...progress.body!, {
|
||||
type: 'tabs', id: 'materials', tabs: { tabs: [
|
||||
{ id: 'first', label: '第一份材料', nodes: [{ type: 'markdown', markdown: '第一份材料的范围' }] },
|
||||
{ id: 'second', label: '第二份材料', nodes: [{ type: 'markdown', markdown: Array.from({ length: 70 }, (_, i) => `待核对事项 ${i + 1}`).join('\n\n') }] },
|
||||
] },
|
||||
}] })
|
||||
const state = await fixture(page, [tabbed, work('peer', 'active', { body: [] })])
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
const card = page.locator('.right-panel [data-card-id="work1"]')
|
||||
await card.getByRole('button', { name: '查看依据与范围', exact: true }).click()
|
||||
const tab = card.getByRole('tab', { name: '第二份材料', exact: true })
|
||||
await tab.click()
|
||||
await tab.focus()
|
||||
const details = card.locator('.acard-detail-body')
|
||||
await expect.poll(() => details.evaluate(element => element.clientHeight)).toBe(420)
|
||||
const physical = await details.evaluate(element => {
|
||||
Object.assign(window, { __workReading: element })
|
||||
element.scrollTop = 137
|
||||
return { height: element.clientHeight, scrollHeight: element.scrollHeight, top: element.scrollTop }
|
||||
})
|
||||
expect(physical.scrollHeight).toBeGreaterThan(physical.height)
|
||||
expect(physical.top).toBe(137)
|
||||
await state.emit('card.status', { cardId: 'work1', status: 'done' }, 12)
|
||||
await expect(card.locator('.acard-status')).toHaveText('已完成')
|
||||
await expect(tab).toHaveAttribute('aria-selected', 'true')
|
||||
expect(await details.evaluate(element => ({
|
||||
retained: (window as typeof window & { __workReading: Element }).__workReading === element,
|
||||
top: element.scrollTop,
|
||||
}))).toEqual({ retained: true, top: 137 })
|
||||
expect(await tab.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('shares submitting feedback between conversation and work confirmations', async ({ page }) => {
|
||||
const state = await fixture(page, [confirmation])
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
state.hold()
|
||||
await page.locator('[data-conversation-work]').getByRole('button', { name: '确认生成', exact: true }).click()
|
||||
await expect(page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true })).toBeDisabled()
|
||||
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: '暂不生成', exact: true })).toBeDisabled()
|
||||
expect(state.actionCount).toBe(1)
|
||||
state.release()
|
||||
await expect(page.getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('opens the one file preview and preserves reading during background updates', async ({ page }) => {
|
||||
const result = work('result1', 'done', { kind: 'result', title: '材料摘要已准备好', body: [{ type: 'doc-ref', docId: 'doc1' }] })
|
||||
const state = await fixture(page, [result])
|
||||
await page.locator('[data-conversation-work]').getByText(artifact.name, { exact: true }).click()
|
||||
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
|
||||
await expect(page.locator('.preview-pane-body')).toContainText('评审意见仍待核对')
|
||||
await state.emit('surface.updated', { identity: { surfaceId: 'new-page', owner: 'reader', pluginId: 'generative-ui', conversationId: 'c1' }, revision: 1, activationEpoch: 0, compat: { schemaVersion: 1, catalogVersion: '1' }, spec: { title: '新任务页面', nodes: [] } }, 11)
|
||||
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
|
||||
await page.getByRole('button', { name: '展开预览', exact: true }).click()
|
||||
await expect(page.locator('.main')).toBeHidden()
|
||||
await page.getByRole('button', { name: '返回对话', exact: true }).click()
|
||||
await expect(page.locator('.main')).toBeVisible()
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('sends three attachments without text and states foreground queueing truthfully', async ({ page }) => {
|
||||
const state = await fixture(page, [])
|
||||
await page.locator('input[type=file]').first().setInputFiles([
|
||||
{ name: '会议纪要.txt', mimeType: 'text/plain', buffer: Buffer.from('会议内容') },
|
||||
{ name: '需求说明.txt', mimeType: 'text/plain', buffer: Buffer.from('需求内容') },
|
||||
{ name: '评审意见.txt', mimeType: 'text/plain', buffer: Buffer.from('评审内容') },
|
||||
])
|
||||
await expect(page.locator('.attach-chip')).toHaveCount(3)
|
||||
await page.getByRole('button', { name: '发送', exact: true }).click()
|
||||
await expect.poll(() => state.sent?.attachmentIds.length).toBe(3)
|
||||
expect(state.sent?.text).toBe('')
|
||||
await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('请优先核对时间安排')
|
||||
await expect(page.getByRole('button', { name: '加入队列', exact: true })).toBeEnabled()
|
||||
await page.getByRole('button', { name: '加入队列', exact: true }).click()
|
||||
await expect(page.getByText('请优先核对时间安排', { exact: true })).toBeVisible()
|
||||
await expect(page.locator('.input-status')).toContainText('排队')
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
for (const width of [1440, 1024, 736, 390, 320]) {
|
||||
test(`keeps navigation and key actions usable at ${width}px with large UI type`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 960 })
|
||||
const state = await fixture(page, [progress], 18)
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
await expect(page.locator('.right-panel')).toBeVisible()
|
||||
await expect(page.locator('.right-panel').getByText('评审意见仍待核查')).toBeVisible()
|
||||
if (width < 1024) {
|
||||
await page.getByRole('button', { name: '返回对话', exact: true }).click()
|
||||
await expect(page.locator('.main')).toBeVisible()
|
||||
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '新建对话', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: '返回对话', exact: true }).click()
|
||||
}
|
||||
const layout = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }))
|
||||
expect(layout.scroll).toBeLessThanOrEqual(layout.width)
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user