|
- import { test, expect, type Page, type TestInfo } from '@playwright/test'
- import fs from 'node:fs'
- import path from 'node:path'
- import crypto from 'node:crypto'
- import { execFileSync } from 'node:child_process'
- // @ts-ignore Local support module keeps credentials out of test sources and reports.
- import { api, localCredentials } from '../tools/review-api.mjs'
-
- const report = path.resolve('reports/review-20260905/live')
- const prefix = `UTE2E-REVIEW-${Date.now().toString(36).toUpperCase()}`
- const sessions: Record<string, string> = {}
- const users: Record<string, any> = {}
- const tasks: any[] = []
- let templates: any[] = []
- const password = `Rv!${crypto.randomBytes(7).toString('hex')}`
- const evidence: any[] = []
- const request = async (url: string, token?: string, body?: any, method?: string) => {
- const result = await api(url, token, body, method)
- if (result.status !== 200) throw new Error(`${method ?? (body ? 'POST' : 'GET')} ${url}: HTTP ${result.status} ${result.message ?? ''}`)
- return result.data
- }
- const cmd = () => crypto.randomUUID()
- async function shot(page: Page, info: TestInfo, name: string) {
- await expect(page.locator('.el-loading-mask:visible')).toHaveCount(0)
- await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
- fs.mkdirSync(path.join(report, 'screenshots'), { recursive: true })
- const target = path.join(report, 'screenshots', `${name}.png`)
- await page.screenshot({ path: target, fullPage: true, animations: 'disabled' })
- await info.attach(name, { path: target, contentType: 'image/png' })
- }
- async function sceneReady(page: Page) {
- const scene = page.locator('.runtime-scene-window .teaching-three-scene')
- await expect(scene).toHaveAttribute('aria-label', /^已加载发布训练场景 · [1-9]\d* 个对象$/, { timeout: 60000 })
- const bounds = await scene.boundingBox()
- const viewport = await page.locator('.runtime-scene-window').boundingBox()
- expect(bounds).not.toBeNull()
- expect(viewport).not.toBeNull()
- expect(Math.abs(bounds!.height - viewport!.height)).toBeLessThanOrEqual(2)
- evidence.push({ case: 'published scene ready', status: await scene.getAttribute('aria-label'), height: bounds!.height })
- }
- async function asRole(page: Page, role: string) {
- await page.addInitScript(token => { sessionStorage.setItem('unreal-tran:web:access-token:v1', token); localStorage.removeItem('unreal-tran:web:access-token:v1') }, sessions[role])
- }
- async function newTask(channel: string) {
- if (channel === 'PHYSICAL') {
- // Isolated fixture uses the historical published snapshot: its source project is now
- // DRAFT, so creating a new production task from that source would correctly be rejected.
- const result = JSON.parse(execFileSync('python', ['tools/review-db.py', 'clone-physical', '--code', `${prefix}-PHYSICAL`], { encoding: 'utf8' }))
- tasks.push(result)
- 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))
- return result
- }
- const row = templates.find(x => x.channel === channel && x.assignmentKind === 'TRAINING')
- if (!row) throw new Error(`No published ${channel} training fixture`)
- const source = await request(`/api/tran/v1/teaching/assignments/${row.id}`, sessions.admin)
- const result = await request('/api/tran/v1/teaching/assignments', sessions.admin, {
- code: `${prefix}-${channel}`, name: `${prefix} ${channel} 回归`, description: 'Temporary review fixture; removed after verification',
- channel, assignmentKind: 'TRAINING', executionMode: 'PRACTICE', audienceType: 'COMMON', collaborationMode: 'INDIVIDUAL',
- digitalHumanAllowed: false, contentProjectId: source.contentProjectId, contentVersionId: source.contentVersionId, members: [],
- })
- tasks.push({ id: result.id, code: result.code })
- 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))
- return request(`/api/tran/v1/teaching/assignments/${result.id}/publish`, sessions.admin, { version: result.version })
- }
-
- test.beforeAll(async () => {
- fs.mkdirSync(report, { recursive: true })
- const login = await request('/api/auth/v1/auth/login', undefined, { ...localCredentials(), roleCode: 'admin', rememberMe: false })
- sessions.admin = login.accessToken
- const rolesResult = await request('/api/auth/v1/roles?size=100', sessions.admin)
- const roles = rolesResult.records ?? rolesResult
- const context = await request('/api/auth/v1/auth/login-context')
- const department = context.departments[0]?.children?.[0] ?? context.departments[0]
- if (!department) throw new Error('No active department for isolated test users')
- for (const roleCode of ['teacher', 'student']) {
- const role = roles.find((x: any) => x.code === roleCode)
- 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 })
- users[roleCode] = user.user ?? user
- const logged = await request('/api/auth/v1/auth/login', undefined, { userId: users[roleCode].id, roleCode, departmentId: roleCode === 'teacher' ? department.id : null, password, rememberMe: false })
- sessions[roleCode] = logged.accessToken
- }
- templates = (await request('/api/tran/v1/teaching/assignments?size=100', sessions.admin)).records
- 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))
- })
-
- test.afterEach(async ({ page }, info) => {
- if (info.status !== info.expectedStatus && !page.isClosed()) await shot(page, info, `failure-${info.title.replace(/[^a-z0-9]+/gi, '-').slice(0, 40)}`).catch(() => {})
- })
- test.afterAll(async () => {
- fs.writeFileSync(path.join(report, 'api-evidence.json'), JSON.stringify(evidence, null, 2))
- for (const [role, token] of Object.entries(sessions)) if (role !== 'admin') await api('/api/auth/v1/auth/logout', token, {}).catch(() => {})
- for (const user of Object.values(users)) {
- const current = await api(`/api/auth/v1/users/${user.id}`, sessions.admin)
- const result = await api(`/api/auth/v1/users/${user.id}`, sessions.admin, { version: current.data?.dataVersion }, 'DELETE')
- evidence.push({ cleanupUser: user.id, status: result.status })
- }
- if (sessions.admin) await api('/api/auth/v1/auth/logout', sessions.admin, {}).catch(() => {})
- fs.writeFileSync(path.join(report, 'api-evidence.json'), JSON.stringify(evidence, null, 2))
- })
-
- test('live login roles and formal virtual route', async ({ page }, info) => {
- await page.goto('/login')
- await expect(page.locator('[data-role-code]')).toHaveCount(3)
- await expect(page.locator('[data-role-code="administrative"]')).toHaveCount(0)
- await shot(page, info, '01-login-three-roles')
- const credentials = localCredentials()
- await page.locator('[data-role-code="admin"]').click()
- await page.locator('input[name="username"]').fill(credentials.username)
- await page.locator('input[name="password"]').fill(credentials.password)
- await page.getByRole('button', { name: /登录系统|正在验证身份/ }).click()
- await expect(page.locator('.platform-shell')).toBeVisible()
- await page.goto('/teaching/virtual-training/api?view=records')
- await expect(page).toHaveURL(/\/teaching\/virtual-training\?view=records/)
- await expect(page.getByTestId('teaching-task-center')).toBeVisible()
- await page.goto('/teaching/virtual-training')
- await expect(page.locator('.ttc-assignment-card').first()).toBeVisible()
- await expect(page.getByTestId('teaching-task-center').locator('iframe')).toHaveCount(0)
- await shot(page, info, '02-virtual-formal-task-center')
- await page.setViewportSize({ width: 1920, height: 1080 })
- await shot(page, info, '03-virtual-1920')
- })
-
- test('live teacher role and content write boundary', async ({ page }, info) => {
- await asRole(page, 'teacher')
- await page.goto('/content/training-projects')
- await expect(page.locator('.platform-shell')).toBeVisible()
- await expect(page.locator('.training-project-card').first()).toBeVisible()
- await shot(page, info, '04-teacher-content')
- 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: {} })
- expect(denied.status).toBe(403)
- evidence.push({ case: 'admin content create requires teacher role', status: denied.status })
- })
-
- test('live virtual start replay step facts submit and review', async ({ page }, info) => {
- // Three complete scene loads plus full-page screenshots are expensive under software WebGL.
- test.setTimeout(240_000)
- page.on('response', response => {
- const resource = new URL(response.url()).pathname
- if (response.ok() && (/\.glb$/i.test(resource) || /\/assets\/[^/]+\/download$/.test(resource))) {
- evidence.push({ case: 'scene asset response', path: resource, status: response.status() })
- }
- })
- const task = await newTask('VIRTUAL')
- let run = await request(`/api/tran/v1/teaching/assignments/${task.id}/accept`, sessions.student, { assignmentVersion: task.version, teamCode: 'NEUTRAL', positionCode: 'OPERATOR' })
- const initial = await request(`/api/tran/v1/teaching/runs/${run.id}/steps`, sessions.student)
- expect(initial.length).toBeGreaterThan(0)
- expect(initial.every((x: any) => x.statusCode === 'PENDING')).toBe(true)
- await asRole(page, 'student')
- await page.goto(`/teaching/virtual-training/runs/${run.id}`)
- await expect(page.getByRole('button', { name: '开始训练', exact: true })).toBeVisible()
- const startedResponse = page.waitForResponse(r => r.url().endsWith(`/runs/${run.id}/start`) && r.request().method() === 'POST')
- await page.getByRole('button', { name: '开始训练', exact: true }).click()
- const startResponse = await startedResponse
- expect(startResponse.status()).toBe(200)
- const payload = startResponse.request().postDataJSON()
- run = (await startResponse.json()).data
- const replay = await request(`/api/tran/v1/teaching/runs/${run.id}/start`, sessions.student, payload)
- expect(replay.version).toBe(run.version)
- const conflicting = await api(`/api/tran/v1/teaching/runs/${run.id}/start`, sessions.student, { ...payload, version: payload.version + 1 })
- expect(conflicting.status).toBe(409)
- const early = await api(`/api/tran/v1/teaching/runs/${run.id}/submit`, sessions.student, { version: run.version, commandId: cmd(), summary: 'Early submit must fail' })
- expect(early.status).toBe(409)
- await expect(page.locator('.runtime-formal-scene-closed')).toHaveCount(0)
- await expect(page.locator('.runtime-step-fact-meta').first()).toContainText('尝试 1 次')
- await sceneReady(page)
- await shot(page, info, '05-virtual-started-no-exam-overlay')
- for (let i = 0; i < initial.length; i++) {
- const execution = await request(`/api/tran/v1/teaching/runs/${run.id}/execution`, sessions.student)
- const definitions = execution.definitionSnapshot?.steps ?? execution.definition?.steps ?? execution.steps ?? []
- const step = execution.currentStep ?? definitions.find((x: any) => (x.stepCode ?? x.code) === run.currentStepCode) ?? definitions[i]
- if (!step) throw new Error(`Missing current public step; execution keys: ${Object.keys(execution).join(',')}`)
- 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: {} } }
- run = await request(`/api/tran/v1/teaching/runs/${run.id}/progress`, sessions.student, data, 'PUT')
- const again = await request(`/api/tran/v1/teaching/runs/${run.id}/progress`, sessions.student, data, 'PUT')
- expect(again.version).toBe(run.version)
- }
- const facts = await request(`/api/tran/v1/teaching/runs/${run.id}/steps`, sessions.student)
- expect(facts.every((x: any) => x.statusCode === 'COMPLETED' && x.attemptCount === 1)).toBe(true)
- await page.reload()
- await expect(page.getByRole('button', { name: '提交训练结果', exact: true })).toBeVisible()
- await sceneReady(page)
- await shot(page, info, '06-virtual-all-steps-completed')
- await page.getByRole('button', { name: '提交训练结果', exact: true }).click()
- const submittedResponse = page.waitForResponse(r => r.url().endsWith(`/runs/${run.id}/submit`) && r.request().method() === 'POST')
- await page.getByRole('button', { name: '确认提交', exact: true }).click()
- const submitted = await submittedResponse
- expect(submitted.status()).toBe(200)
- run = (await submitted.json()).data
- const review = { version: run.version, commandId: cmd(), score: 90, passed: true, feedback: 'Isolated code review regression' }
- const reviewed = await request(`/api/tran/v1/teaching/runs/${run.id}/review`, sessions.admin, review)
- const reviewReplay = await request(`/api/tran/v1/teaching/runs/${run.id}/review`, sessions.admin, review)
- expect(reviewed.status).toBe('REVIEWED')
- expect(reviewReplay.version).toBe(reviewed.version)
- await page.reload()
- await expect(page.locator('.platform-shell')).toBeVisible()
- await sceneReady(page)
- await shot(page, info, '07-virtual-reviewed')
- evidence.push({ case: 'virtual lifecycle', runId: run.id, steps: facts.length, startReplay: true, progressReplay: true, reviewReplay: true, earlySubmit: early.status, conflictingCommand: conflicting.status })
- })
-
- test('live physical failure retry rejects stale evidence', async ({ page }, info) => {
- const task = await newTask('PHYSICAL')
- let run = await request(`/api/tran/v1/teaching/assignments/${task.id}/accept`, sessions.student, { assignmentVersion: task.version, teamCode: 'NEUTRAL', positionCode: 'OPERATOR' })
- run = await request(`/api/tran/v1/teaching/runs/${run.id}/start`, sessions.student, { version: run.version, commandId: cmd() })
- const firstTime = Math.floor(Date.now() / 1000)
- const execution = await request(`/api/tran/v1/teaching/runs/${run.id}/execution`, sessions.student)
- const defs = execution.definitionSnapshot?.steps ?? execution.definition?.steps ?? execution.steps ?? []
- const step = execution.currentStep ?? defs.find((x: any) => (x.stepCode ?? x.code) === run.currentStepCode)
- if (!step) throw new Error(`Missing physical step; keys: ${Object.keys(execution).join(',')}`)
- const base = { source: 'MANUAL', stepCode: run.currentStepCode, eventType: 'review.step.confirmed', publicPayload: {} }
- await request(`/api/tran/v1/teaching/runs/${run.id}/physical-events`, sessions.student, { ...base, runVersion: run.version, eventId: cmd(), manualConfirmed: false, occurredAt: firstTime })
- let facts = await request(`/api/tran/v1/teaching/runs/${run.id}/steps`, sessions.student)
- expect(facts[0].statusCode).toBe('FAILED')
- await new Promise(resolve => setTimeout(resolve, 2200))
- run = await request(`/api/tran/v1/teaching/runs/${run.id}`, sessions.student)
- run = await request(`/api/tran/v1/teaching/runs/${run.id}/start`, sessions.student, { version: run.version, commandId: cmd() })
- 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 })
- expect(stale.status).toBe(400)
- facts = await request(`/api/tran/v1/teaching/runs/${run.id}/steps`, sessions.student)
- expect(facts[0]).toMatchObject({ statusCode: 'IN_PROGRESS', attemptCount: 2, failureCount: 1 })
- 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) })
- facts = await request(`/api/tran/v1/teaching/runs/${run.id}/steps`, sessions.student)
- expect(facts[0].statusCode).toBe('COMPLETED')
- await asRole(page, 'admin')
- await page.goto('/teaching/physical-training')
- await expect(page.getByTestId('teaching-task-center')).toBeVisible()
- await expect(page.locator('.ttc-assignment-card').first()).toBeVisible()
- await shot(page, info, '08-physical-api-task-list')
- await page.goto(`/teaching/physical-training/preview/${task.id}`)
- await expect(page.locator('.physical-offline-policy')).toContainText('不推进正式训练进度,不自动计分')
- const video = page.locator('[data-physical-video="primary"]')
- await expect(video).toBeVisible()
- await expect.poll(() => video.evaluate((element: HTMLVideoElement) => element.readyState), { timeout: 45000 }).toBeGreaterThanOrEqual(2)
- await video.evaluate((element: HTMLVideoElement) => { element.pause(); element.currentTime = 3 })
- await expect.poll(() => video.evaluate((element: HTMLVideoElement) => !element.seeking && element.readyState >= 2)).toBe(true)
- await shot(page, info, '09-physical-offline-replay-boundary')
- const duration = await video.evaluate((element: HTMLVideoElement) => element.duration)
- expect(duration).toBeGreaterThan(45.2)
- expect(duration).toBeLessThan(46)
- await video.evaluate((element: HTMLVideoElement) => { element.currentTime = element.duration - 0.03 })
- await expect(page.locator('[data-event-timeline] [data-physical-event][data-offline-replay="true"]')).toHaveCount(12)
- await expect(page.locator('[data-event-timeline] [data-physical-event="offline-replay:replay-complete"]')).toBeVisible()
- await shot(page, info, '10-physical-twelve-candidates')
- evidence.push({ case: 'physical offline replay', readyState: await video.evaluate((element: HTMLVideoElement) => element.readyState), duration, candidates: 12, formalProgressAutomatic: false })
- evidence.push({ case: 'physical retry evidence', runId: run.id, staleStatus: stale.status, final: facts[0].statusCode, attemptCount: facts[0].attemptCount, failureCount: facts[0].failureCount })
- })
|