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 = {} const users: Record = {} 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(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 }) })