import { chromium, expect } from '@playwright/test'; import crypto from 'node:crypto'; import fs from 'node:fs'; import assert from 'node:assert/strict'; import { login, call } from './role-permissions-live.mjs'; const dir = 'reports/bridge-scripted-tasks-20260913'; const admin = await login('root'), checks = [], fixtures = [], errors = []; const adminApi = async (url, data, method) => { const r = await call(url, admin.accessToken, data, method); assert.equal(r.status, 200, `${url}: ${r.message}`); return r.data; }; const roles = await adminApi('auth/v1/roles/all'), role = (roles.records || roles).find(item => item.code === 'student'); const suffix = Date.now().toString(36), password = 'Br!' + crypto.randomBytes(8).toString('hex'); const created = await adminApi('auth/v1/users', {username: 'bridge_' + suffix, password, displayName: '桥梁脚本闭环验收', departmentId: '5', roleIds: [role.id], defaultRoleId: role.id, enabled: true, remark: '临时验收账号'}); const userId = (created.user || created).id; const browser = await chromium.launch({channel: 'msedge', headless: true, args: ['--enable-unsafe-swiftshader']}); let page; try { const signed = await call('auth/v1/auth/login', null, {userId, password, roleCode: 'student', rememberMe: false}); assert.equal(signed.status, 200); const api = async (url, data) => { const r = await call(`tran/v1/${url}`, signed.data.accessToken, data); assert.equal(r.status, 200, `${url}: ${r.message}`); return r.data; }; const registered = JSON.parse(fs.readFileSync(`${dir}/registered.json`, 'utf8')); const context = await browser.newContext({viewport: {width: 1600, height: 1000}}); await context.addInitScript(s => { sessionStorage.setItem('unreal-tran:web:access-token:v1', s.accessToken); sessionStorage.setItem('unreal-tran:web:refresh-token:v1', s.refreshToken); localStorage.setItem('unreal-tran:web:preferences:v1', JSON.stringify({themeMode: 'military'})); }, signed.data); page = await context.newPage(); page.on('pageerror', error => errors.push(error.message)); page.on('response', async response => { if (response.status() >= 400 && response.url().includes('/api/tran/')) { const body = await response.json().catch(() => ({})); console.log(JSON.stringify({path: new URL(response.url()).pathname, status: response.status(), message: body.message})); } }); await page.goto('http://127.0.0.1:6180/teaching/virtual-training'); await page.getByPlaceholder('请输入任务名称或编码').fill('TASK-VIRTUAL-00'); await page.getByPlaceholder('请输入任务名称或编码').press('Enter'); for (const item of registered) { const card = page.getByTestId(`teaching-assignment-${item.code}`); await expect(card).toBeVisible({timeout:30000}); await expect(card.getByRole('button',{name:'领取任务',exact:true})).toBeEnabled(); } await expect(page.getByText('静态流程 · 不计成绩',{exact:true})).toHaveCount(0); await page.screenshot({path:`${dir}/normal-task-list.png`}); checks.push('原四条任务保留正常领取入口,不显示静态演示或禁评分标签'); // Test copies keep the four delivery tasks free of test runs and test grades. for (const item of [...registered.slice(1), registered[0]].filter(item => !process.env.BRIDGE_CODE || item.code === process.env.BRIDGE_CODE)) { const original = await api(`teaching/assignments/${item.id}`); assert.equal(original.executionMode, 'PRACTICE'); assert.equal(original.definitionSnapshot.runtime.definitionSource, 'SCRIPT'); let task = await adminApi('tran/v1/teaching/assignments', {code: `CHECK-${suffix}-${item.code}`, name: `验收·${item.name}`, description: '脚本训练闭环自动化验证,完成后归档', channel: 'VIRTUAL', assignmentKind: 'TRAINING', executionMode: 'PRACTICE', audienceType: 'COMMON', collaborationMode: 'INDIVIDUAL', digitalHumanAllowed: false, contentProjectId: item.projectId, contentVersionId: item.versionId, members: []}); const fixture = {taskId: task.id, code: item.code}; fixtures.push(fixture); task = await adminApi(`tran/v1/teaching/assignments/${task.id}/publish`, {version: task.version}); let run = await api(`teaching/assignments/${task.id}/accept`, {assignmentVersion: task.version, teamCode: 'NEUTRAL', positionCode: 'LEARNER'}); fixture.runId = run.id; await page.goto(`http://127.0.0.1:6180/teaching/virtual-training/runs/${run.id}`); const procedure = item.presentation === 'procedure'; if (procedure) await expect(page.locator('.bridge-procedure h2')).toContainText(item.name); else await expect(page.locator('[data-role="loading"]')).toHaveClass(/is-hidden/, {timeout: 120000}); run = await api(`teaching/runs/${run.id}`); assert.equal(run.status, 'IN_PROGRESS'); for (let i = 0; i < item.steps; i++) { if (procedure) { await expect(page.locator('.bridge-procedure-current > small')).toHaveText(`第 ${i + 1} / ${item.steps} 步`); if (item.code === 'TASK-VIRTUAL-008' && [10,14].includes(i)) { await page.getByRole('button', {name: '完成本步并保存', exact: true}).click(); await expect(page.locator('.bridge-procedure-progress')).toContainText(`已保存 ${i} /`); await page.getByRole('textbox', {name: '操作记录'}).fill('验收记录:原稿相反指令已标记,连接状态与处理依据交教员核对。'); } // A rejected save must leave both UI and server at the same step. if (i === 0 && item.code === 'TASK-VIRTUAL-006') { await page.route(`**/teaching/runs/${run.id}/progress`, route => route.fulfill({status: 503, contentType:'application/json', body:JSON.stringify({code:'SERVICE_UNAVAILABLE',message:'验收模拟保存失败'})}), {times:1}); await page.getByRole('button', {name:'完成本步并保存', exact:true}).click(); await expect(page.locator('.bridge-procedure-progress')).toContainText('已保存 0 /'); assert.equal((await api(`teaching/runs/${run.id}`)).progressPercent, 0); checks.push('步骤保存失败不前进,允许幂等重试'); } await page.getByRole('button', {name:'完成本步并保存', exact:true}).click(); await expect(page.getByRole('button', {name:'本步已保存', exact:true})).toBeDisabled(); } else { await expect(page.locator('[data-role="step-count"]')).toHaveText(`${i + 1} / 5`); await page.locator('[data-role="part-label"]').click(); if (i === 4) { await expect(page.locator('[data-role="part-label"]')).toContainText('互救开关'); await page.locator('[data-role="part-label"]').click(); } await expect(page.locator('[data-action="next"]')).toBeEnabled(); } const saved = await api(`teaching/runs/${run.id}`); assert.ok(saved.progressPercent > (i / item.steps) * 100 - 1); const steps = await api(`teaching/runs/${run.id}/steps`); assert.equal(steps.filter(s => s.statusCode === 'COMPLETED').length, i + 1); if (i === 0) { await page.reload(); if (procedure) await expect(page.locator('.bridge-procedure-progress')).toContainText('已保存 1 /'); else { await expect(page.locator('[data-role="loading"]')).toHaveClass(/is-hidden/, {timeout:120000}); await expect(page.locator('[data-role="step-count"]')).toHaveText('2 / 5'); const bar = page.locator('[data-role="model-commandbar"]'); for (const action of ['toggle-structure-tree','focus-selected-part','viewer-reset-view','toggle-part-labels','toggle-issue-highlight','toggle-hide-others','toggle-explosion']) await expect(bar.locator(`[data-action="${action}"]`)).toBeVisible(); } checks.push(`${item.code} 刷新从服务端恢复已完成步骤`); } else if (i < item.steps - 1) { await (procedure ? page.getByRole('button', {name:'下一步', exact:true}) : page.locator('[data-action="next"]')).click(); } } await (procedure ? page.getByRole('button', {name:'提交训练', exact:true}) : page.locator('[data-action="next"]')).click(); await expect.poll(async () => (await api(`teaching/runs/${run.id}`)).status).toBe('SUBMITTED'); run = await api(`teaching/runs/${run.id}`); assert.equal(run.score, null); await page.screenshot({path:`${dir}/normal-${item.code}-submitted.png`}); run = await adminApi(`tran/v1/teaching/runs/${run.id}/review`, {version:run.version,commandId:crypto.randomUUID(),score:85,passed:true,feedback:'自动化验收样例评分,验证教员评定链路',rubric:{testFixture:true}}); assert.equal(run.status,'REVIEWED'); assert.equal(run.score,85); const visible = await api(`teaching/runs/${run.id}`); assert.equal(visible.score,85); await page.reload(); await expect(page.locator('.virtual-server-status')).toContainText('教员评定:85 分'); if (!procedure) await expect(page.locator('[data-role="loading"]')).toHaveClass(/is-hidden/, {timeout:120000}); await page.screenshot({path:`${dir}/normal-${item.code}-reviewed.png`}); const analytics = await adminApi(`tran/v1/teaching/analytics/records?userId=${userId}&channel=VIRTUAL&assignmentKind=TRAINING&size=100`); assert.ok(analytics.records.some(r => String(r.runId || r.id) === run.id)); checks.push(`${item.code} ${item.steps} 步:领取、开始、保存、恢复、提交、教员85分评定、统计记录通过`); await adminApi(`tran/v1/teaching/runs/${run.id}/archive`, {version:run.version}); task = await adminApi(`tran/v1/teaching/assignments/${task.id}`); await adminApi(`tran/v1/teaching/assignments/${task.id}/archive`, {version:task.version, reason:'闭环验收完成,归档测试副本'}); fixture.archived = true; fs.writeFileSync(`${dir}/normal-fixtures.json`,JSON.stringify(fixtures,null,2)); console.log(checks.at(-1)); } assert.deepEqual(errors,[]); fs.writeFileSync(`${dir}/normal-acceptance.json`,JSON.stringify({pass:true,checks,fixtures,errors},null,2)); } catch (error) { console.log('Visible feedback:', await page?.locator('.el-message').allTextContents()); await page?.screenshot({path:`${dir}/normal-failure.png`}).catch(()=>{}); fs.writeFileSync(`${dir}/normal-acceptance.json`,JSON.stringify({pass:false,error:error.message,checks,fixtures,errors},null,2)); throw error; } finally { await browser.close(); for (const fixture of fixtures.filter(item => !item.archived)) { if (fixture.runId) { let run = await adminApi(`tran/v1/teaching/runs/${fixture.runId}`); if (['IN_PROGRESS','ACCEPTED','PENDING'].includes(run.status)) run = await adminApi(`tran/v1/teaching/runs/${run.id}/terminate`, {version:run.version,commandId:crypto.randomUUID(),reason:'自动化验收中止,清理测试副本'}); if (run.status === 'SUBMITTED') run = await adminApi(`tran/v1/teaching/runs/${run.id}/review`, {version:run.version,commandId:crypto.randomUUID(),score:0,passed:false,feedback:'未完成的自动化验收副本'}); if (run.status === 'REVIEWED') await adminApi(`tran/v1/teaching/runs/${run.id}/archive`, {version:run.version}); } const task = await adminApi(`tran/v1/teaching/assignments/${fixture.taskId}`); await adminApi(`tran/v1/teaching/assignments/${task.id}/archive`, {version:task.version,reason:'自动化测试副本归档'}); fixture.archived = true; } fs.appendFileSync(`${dir}/normal-attempts.jsonl`, JSON.stringify({time:new Date().toISOString(),fixtures})+'\n'); const user = await adminApi(`auth/v1/users/${userId}`); await adminApi(`auth/v1/users/${userId}`, {version:user.version}, 'DELETE'); }