選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 
 

247 行
18 KiB

  1. import { test, expect, type Page, type TestInfo } from '@playwright/test'
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. import crypto from 'node:crypto'
  5. import { execFileSync } from 'node:child_process'
  6. // @ts-ignore Local support module keeps credentials out of test sources and reports.
  7. import { api, localCredentials } from '../tools/review-api.mjs'
  8. const report = path.resolve('reports/review-20260905/live')
  9. const prefix = `UTE2E-REVIEW-${Date.now().toString(36).toUpperCase()}`
  10. const sessions: Record<string, string> = {}
  11. const users: Record<string, any> = {}
  12. const tasks: any[] = []
  13. let templates: any[] = []
  14. const password = `Rv!${crypto.randomBytes(7).toString('hex')}`
  15. const evidence: any[] = []
  16. const request = async (url: string, token?: string, body?: any, method?: string) => {
  17. const result = await api(url, token, body, method)
  18. if (result.status !== 200) throw new Error(`${method ?? (body ? 'POST' : 'GET')} ${url}: HTTP ${result.status} ${result.message ?? ''}`)
  19. return result.data
  20. }
  21. const cmd = () => crypto.randomUUID()
  22. async function shot(page: Page, info: TestInfo, name: string) {
  23. await expect(page.locator('.el-loading-mask:visible')).toHaveCount(0)
  24. await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
  25. fs.mkdirSync(path.join(report, 'screenshots'), { recursive: true })
  26. const target = path.join(report, 'screenshots', `${name}.png`)
  27. await page.screenshot({ path: target, fullPage: true, animations: 'disabled' })
  28. await info.attach(name, { path: target, contentType: 'image/png' })
  29. }
  30. async function sceneReady(page: Page) {
  31. const scene = page.locator('.runtime-scene-window .teaching-three-scene')
  32. await expect(scene).toHaveAttribute('aria-label', /^已加载发布训练场景 · [1-9]\d* 个对象$/, { timeout: 60000 })
  33. const bounds = await scene.boundingBox()
  34. const viewport = await page.locator('.runtime-scene-window').boundingBox()
  35. expect(bounds).not.toBeNull()
  36. expect(viewport).not.toBeNull()
  37. expect(Math.abs(bounds!.height - viewport!.height)).toBeLessThanOrEqual(2)
  38. evidence.push({ case: 'published scene ready', status: await scene.getAttribute('aria-label'), height: bounds!.height })
  39. }
  40. async function asRole(page: Page, role: string) {
  41. await page.addInitScript(token => { sessionStorage.setItem('unreal-tran:web:access-token:v1', token); localStorage.removeItem('unreal-tran:web:access-token:v1') }, sessions[role])
  42. }
  43. async function newTask(channel: string) {
  44. if (channel === 'PHYSICAL') {
  45. // Isolated fixture uses the historical published snapshot: its source project is now
  46. // DRAFT, so creating a new production task from that source would correctly be rejected.
  47. const result = JSON.parse(execFileSync('python', ['tools/review-db.py', 'clone-physical', '--code', `${prefix}-PHYSICAL`], { encoding: 'utf8' }))
  48. tasks.push(result)
  49. fs.writeFileSync(path.join(report, 'cleanup-manifest.json'), JSON.stringify({ prefix, tasks, users: Object.values(users).map(x => ({ id: x.id, username: x.username })) }, null, 2))
  50. return result
  51. }
  52. const row = templates.find(x => x.channel === channel && x.assignmentKind === 'TRAINING')
  53. if (!row) throw new Error(`No published ${channel} training fixture`)
  54. const source = await request(`/api/tran/v1/teaching/assignments/${row.id}`, sessions.admin)
  55. const result = await request('/api/tran/v1/teaching/assignments', sessions.admin, {
  56. code: `${prefix}-${channel}`, name: `${prefix} ${channel} 回归`, description: 'Temporary review fixture; removed after verification',
  57. channel, assignmentKind: 'TRAINING', executionMode: 'PRACTICE', audienceType: 'COMMON', collaborationMode: 'INDIVIDUAL',
  58. digitalHumanAllowed: false, contentProjectId: source.contentProjectId, contentVersionId: source.contentVersionId, members: [],
  59. })
  60. tasks.push({ id: result.id, code: result.code })
  61. fs.writeFileSync(path.join(report, 'cleanup-manifest.json'), JSON.stringify({ prefix, tasks, users: Object.values(users).map(x => ({ id: x.id, username: x.username })) }, null, 2))
  62. return request(`/api/tran/v1/teaching/assignments/${result.id}/publish`, sessions.admin, { version: result.version })
  63. }
  64. test.beforeAll(async () => {
  65. fs.mkdirSync(report, { recursive: true })
  66. const login = await request('/api/auth/v1/auth/login', undefined, { ...localCredentials(), roleCode: 'admin', rememberMe: false })
  67. sessions.admin = login.accessToken
  68. const rolesResult = await request('/api/auth/v1/roles?size=100', sessions.admin)
  69. const roles = rolesResult.records ?? rolesResult
  70. const context = await request('/api/auth/v1/auth/login-context')
  71. const department = context.departments[0]?.children?.[0] ?? context.departments[0]
  72. if (!department) throw new Error('No active department for isolated test users')
  73. for (const roleCode of ['teacher', 'student']) {
  74. const role = roles.find((x: any) => x.code === roleCode)
  75. const user = await request('/api/auth/v1/users', sessions.admin, { username: `rv_${roleCode}_${Date.now().toString(36)}`, displayName: `${prefix}-${roleCode}`, departmentId: department.id, roleIds: [role.id], defaultRoleId: role.id, enabled: true, password, remark: prefix })
  76. users[roleCode] = user.user ?? user
  77. const logged = await request('/api/auth/v1/auth/login', undefined, { userId: users[roleCode].id, roleCode, departmentId: roleCode === 'teacher' ? department.id : null, password, rememberMe: false })
  78. sessions[roleCode] = logged.accessToken
  79. }
  80. templates = (await request('/api/tran/v1/teaching/assignments?size=100', sessions.admin)).records
  81. fs.writeFileSync(path.join(report, 'cleanup-manifest.json'), JSON.stringify({ prefix, tasks, users: Object.values(users).map(x => ({ id: x.id, username: x.username })) }, null, 2))
  82. })
  83. test.afterEach(async ({ page }, info) => {
  84. if (info.status !== info.expectedStatus && !page.isClosed()) await shot(page, info, `failure-${info.title.replace(/[^a-z0-9]+/gi, '-').slice(0, 40)}`).catch(() => {})
  85. })
  86. test.afterAll(async () => {
  87. fs.writeFileSync(path.join(report, 'api-evidence.json'), JSON.stringify(evidence, null, 2))
  88. for (const [role, token] of Object.entries(sessions)) if (role !== 'admin') await api('/api/auth/v1/auth/logout', token, {}).catch(() => {})
  89. for (const user of Object.values(users)) {
  90. const current = await api(`/api/auth/v1/users/${user.id}`, sessions.admin)
  91. const result = await api(`/api/auth/v1/users/${user.id}`, sessions.admin, { version: current.data?.dataVersion }, 'DELETE')
  92. evidence.push({ cleanupUser: user.id, status: result.status })
  93. }
  94. if (sessions.admin) await api('/api/auth/v1/auth/logout', sessions.admin, {}).catch(() => {})
  95. fs.writeFileSync(path.join(report, 'api-evidence.json'), JSON.stringify(evidence, null, 2))
  96. })
  97. test('live login roles and formal virtual route', async ({ page }, info) => {
  98. await page.goto('/login')
  99. await expect(page.locator('[data-role-code]')).toHaveCount(3)
  100. await expect(page.locator('[data-role-code="administrative"]')).toHaveCount(0)
  101. await shot(page, info, '01-login-three-roles')
  102. const credentials = localCredentials()
  103. await page.locator('[data-role-code="admin"]').click()
  104. await page.locator('input[name="username"]').fill(credentials.username)
  105. await page.locator('input[name="password"]').fill(credentials.password)
  106. await page.getByRole('button', { name: /登录系统|正在验证身份/ }).click()
  107. await expect(page.locator('.platform-shell')).toBeVisible()
  108. await page.goto('/teaching/virtual-training/api?view=records')
  109. await expect(page).toHaveURL(/\/teaching\/virtual-training\?view=records/)
  110. await expect(page.getByTestId('teaching-task-center')).toBeVisible()
  111. await page.goto('/teaching/virtual-training')
  112. await expect(page.locator('.ttc-assignment-card').first()).toBeVisible()
  113. await expect(page.getByTestId('teaching-task-center').locator('iframe')).toHaveCount(0)
  114. await shot(page, info, '02-virtual-formal-task-center')
  115. await page.setViewportSize({ width: 1920, height: 1080 })
  116. await shot(page, info, '03-virtual-1920')
  117. })
  118. test('live teacher role and content write boundary', async ({ page }, info) => {
  119. await asRole(page, 'teacher')
  120. await page.goto('/content/training-projects')
  121. await expect(page.locator('.platform-shell')).toBeVisible()
  122. await expect(page.locator('.training-project-card').first()).toBeVisible()
  123. await shot(page, info, '04-teacher-content')
  124. const denied = await api('/api/tran/v1/content/projects', sessions.admin, { type: 'TRAINING', code: `${prefix}-DENIED`, name: 'Permission boundary', categoryCode: '', trainingMode: 'VIRTUAL', description: '', coverUri: '', content: {} })
  125. expect(denied.status).toBe(403)
  126. evidence.push({ case: 'admin content create requires teacher role', status: denied.status })
  127. })
  128. test('live virtual start replay step facts submit and review', async ({ page }, info) => {
  129. // Three complete scene loads plus full-page screenshots are expensive under software WebGL.
  130. test.setTimeout(240_000)
  131. page.on('response', response => {
  132. const resource = new URL(response.url()).pathname
  133. if (response.ok() && (/\.glb$/i.test(resource) || /\/assets\/[^/]+\/download$/.test(resource))) {
  134. evidence.push({ case: 'scene asset response', path: resource, status: response.status() })
  135. }
  136. })
  137. const task = await newTask('VIRTUAL')
  138. let run = await request(`/api/tran/v1/teaching/assignments/${task.id}/accept`, sessions.student, { assignmentVersion: task.version, teamCode: 'NEUTRAL', positionCode: 'OPERATOR' })
  139. const initial = await request(`/api/tran/v1/teaching/runs/${run.id}/steps`, sessions.student)
  140. expect(initial.length).toBeGreaterThan(0)
  141. expect(initial.every((x: any) => x.statusCode === 'PENDING')).toBe(true)
  142. await asRole(page, 'student')
  143. await page.goto(`/teaching/virtual-training/runs/${run.id}`)
  144. await expect(page.getByRole('button', { name: '开始训练', exact: true })).toBeVisible()
  145. const startedResponse = page.waitForResponse(r => r.url().endsWith(`/runs/${run.id}/start`) && r.request().method() === 'POST')
  146. await page.getByRole('button', { name: '开始训练', exact: true }).click()
  147. const startResponse = await startedResponse
  148. expect(startResponse.status()).toBe(200)
  149. const payload = startResponse.request().postDataJSON()
  150. run = (await startResponse.json()).data
  151. const replay = await request(`/api/tran/v1/teaching/runs/${run.id}/start`, sessions.student, payload)
  152. expect(replay.version).toBe(run.version)
  153. const conflicting = await api(`/api/tran/v1/teaching/runs/${run.id}/start`, sessions.student, { ...payload, version: payload.version + 1 })
  154. expect(conflicting.status).toBe(409)
  155. const early = await api(`/api/tran/v1/teaching/runs/${run.id}/submit`, sessions.student, { version: run.version, commandId: cmd(), summary: 'Early submit must fail' })
  156. expect(early.status).toBe(409)
  157. await expect(page.locator('.runtime-formal-scene-closed')).toHaveCount(0)
  158. await expect(page.locator('.runtime-step-fact-meta').first()).toContainText('尝试 1 次')
  159. await sceneReady(page)
  160. await shot(page, info, '05-virtual-started-no-exam-overlay')
  161. for (let i = 0; i < initial.length; i++) {
  162. const execution = await request(`/api/tran/v1/teaching/runs/${run.id}/execution`, sessions.student)
  163. const definitions = execution.definitionSnapshot?.steps ?? execution.definition?.steps ?? execution.steps ?? []
  164. const step = execution.currentStep ?? definitions.find((x: any) => (x.stepCode ?? x.code) === run.currentStepCode) ?? definitions[i]
  165. if (!step) throw new Error(`Missing current public step; execution keys: ${Object.keys(execution).join(',')}`)
  166. const data = { version: run.version, commandId: cmd(), completedStepCode: step.stepCode ?? step.code, actionCode: step.actionCode, checkpoint: {}, event: { type: 'training.action.completed', title: 'Review regression step', source: 'web', visibility: 'PUBLIC', publicPayload: {} } }
  167. run = await request(`/api/tran/v1/teaching/runs/${run.id}/progress`, sessions.student, data, 'PUT')
  168. const again = await request(`/api/tran/v1/teaching/runs/${run.id}/progress`, sessions.student, data, 'PUT')
  169. expect(again.version).toBe(run.version)
  170. }
  171. const facts = await request(`/api/tran/v1/teaching/runs/${run.id}/steps`, sessions.student)
  172. expect(facts.every((x: any) => x.statusCode === 'COMPLETED' && x.attemptCount === 1)).toBe(true)
  173. await page.reload()
  174. await expect(page.getByRole('button', { name: '提交训练结果', exact: true })).toBeVisible()
  175. await sceneReady(page)
  176. await shot(page, info, '06-virtual-all-steps-completed')
  177. await page.getByRole('button', { name: '提交训练结果', exact: true }).click()
  178. const submittedResponse = page.waitForResponse(r => r.url().endsWith(`/runs/${run.id}/submit`) && r.request().method() === 'POST')
  179. await page.getByRole('button', { name: '确认提交', exact: true }).click()
  180. const submitted = await submittedResponse
  181. expect(submitted.status()).toBe(200)
  182. run = (await submitted.json()).data
  183. const review = { version: run.version, commandId: cmd(), score: 90, passed: true, feedback: 'Isolated code review regression' }
  184. const reviewed = await request(`/api/tran/v1/teaching/runs/${run.id}/review`, sessions.admin, review)
  185. const reviewReplay = await request(`/api/tran/v1/teaching/runs/${run.id}/review`, sessions.admin, review)
  186. expect(reviewed.status).toBe('REVIEWED')
  187. expect(reviewReplay.version).toBe(reviewed.version)
  188. await page.reload()
  189. await expect(page.locator('.platform-shell')).toBeVisible()
  190. await sceneReady(page)
  191. await shot(page, info, '07-virtual-reviewed')
  192. evidence.push({ case: 'virtual lifecycle', runId: run.id, steps: facts.length, startReplay: true, progressReplay: true, reviewReplay: true, earlySubmit: early.status, conflictingCommand: conflicting.status })
  193. })
  194. test('live physical failure retry rejects stale evidence', async ({ page }, info) => {
  195. const task = await newTask('PHYSICAL')
  196. let run = await request(`/api/tran/v1/teaching/assignments/${task.id}/accept`, sessions.student, { assignmentVersion: task.version, teamCode: 'NEUTRAL', positionCode: 'OPERATOR' })
  197. run = await request(`/api/tran/v1/teaching/runs/${run.id}/start`, sessions.student, { version: run.version, commandId: cmd() })
  198. const firstTime = Math.floor(Date.now() / 1000)
  199. const execution = await request(`/api/tran/v1/teaching/runs/${run.id}/execution`, sessions.student)
  200. const defs = execution.definitionSnapshot?.steps ?? execution.definition?.steps ?? execution.steps ?? []
  201. const step = execution.currentStep ?? defs.find((x: any) => (x.stepCode ?? x.code) === run.currentStepCode)
  202. if (!step) throw new Error(`Missing physical step; keys: ${Object.keys(execution).join(',')}`)
  203. const base = { source: 'MANUAL', stepCode: run.currentStepCode, eventType: 'review.step.confirmed', publicPayload: {} }
  204. await request(`/api/tran/v1/teaching/runs/${run.id}/physical-events`, sessions.student, { ...base, runVersion: run.version, eventId: cmd(), manualConfirmed: false, occurredAt: firstTime })
  205. let facts = await request(`/api/tran/v1/teaching/runs/${run.id}/steps`, sessions.student)
  206. expect(facts[0].statusCode).toBe('FAILED')
  207. await new Promise(resolve => setTimeout(resolve, 2200))
  208. run = await request(`/api/tran/v1/teaching/runs/${run.id}`, sessions.student)
  209. run = await request(`/api/tran/v1/teaching/runs/${run.id}/start`, sessions.student, { version: run.version, commandId: cmd() })
  210. const stale = await api(`/api/tran/v1/teaching/runs/${run.id}/physical-events`, sessions.student, { ...base, runVersion: run.version, eventId: cmd(), manualConfirmed: true, occurredAt: firstTime + 1 })
  211. expect(stale.status).toBe(400)
  212. facts = await request(`/api/tran/v1/teaching/runs/${run.id}/steps`, sessions.student)
  213. expect(facts[0]).toMatchObject({ statusCode: 'IN_PROGRESS', attemptCount: 2, failureCount: 1 })
  214. await request(`/api/tran/v1/teaching/runs/${run.id}/physical-events`, sessions.student, { ...base, runVersion: run.version, eventId: cmd(), manualConfirmed: true, occurredAt: Math.floor(Date.now() / 1000) })
  215. facts = await request(`/api/tran/v1/teaching/runs/${run.id}/steps`, sessions.student)
  216. expect(facts[0].statusCode).toBe('COMPLETED')
  217. await asRole(page, 'admin')
  218. await page.goto('/teaching/physical-training')
  219. await expect(page.getByTestId('teaching-task-center')).toBeVisible()
  220. await expect(page.locator('.ttc-assignment-card').first()).toBeVisible()
  221. await shot(page, info, '08-physical-api-task-list')
  222. await page.goto(`/teaching/physical-training/preview/${task.id}`)
  223. await expect(page.locator('.physical-offline-policy')).toContainText('不推进正式训练进度,不自动计分')
  224. const video = page.locator('[data-physical-video="primary"]')
  225. await expect(video).toBeVisible()
  226. await expect.poll(() => video.evaluate((element: HTMLVideoElement) => element.readyState), { timeout: 45000 }).toBeGreaterThanOrEqual(2)
  227. await video.evaluate((element: HTMLVideoElement) => { element.pause(); element.currentTime = 3 })
  228. await expect.poll(() => video.evaluate((element: HTMLVideoElement) => !element.seeking && element.readyState >= 2)).toBe(true)
  229. await shot(page, info, '09-physical-offline-replay-boundary')
  230. const duration = await video.evaluate((element: HTMLVideoElement) => element.duration)
  231. expect(duration).toBeGreaterThan(45.2)
  232. expect(duration).toBeLessThan(46)
  233. await video.evaluate((element: HTMLVideoElement) => { element.currentTime = element.duration - 0.03 })
  234. await expect(page.locator('[data-event-timeline] [data-physical-event][data-offline-replay="true"]')).toHaveCount(12)
  235. await expect(page.locator('[data-event-timeline] [data-physical-event="offline-replay:replay-complete"]')).toBeVisible()
  236. await shot(page, info, '10-physical-twelve-candidates')
  237. evidence.push({ case: 'physical offline replay', readyState: await video.evaluate((element: HTMLVideoElement) => element.readyState), duration, candidates: 12, formalProgressAutomatic: false })
  238. evidence.push({ case: 'physical retry evidence', runId: run.id, staleStatus: stale.status, final: facts[0].statusCode, attemptCount: facts[0].attemptCount, failureCount: facts[0].failureCount })
  239. })