Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

150 linhas
11 KiB

  1. import { chromium, expect } from '@playwright/test';
  2. import crypto from 'node:crypto';
  3. import fs from 'node:fs';
  4. import assert from 'node:assert/strict';
  5. import { login, call } from './role-permissions-live.mjs';
  6. const dir = 'reports/bridge-scripted-tasks-20260913';
  7. const admin = await login('root'), checks = [], fixtures = [], errors = [];
  8. 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; };
  9. const roles = await adminApi('auth/v1/roles/all'), role = (roles.records || roles).find(item => item.code === 'student');
  10. const suffix = Date.now().toString(36), password = 'Br!' + crypto.randomBytes(8).toString('hex');
  11. const created = await adminApi('auth/v1/users', {username: 'bridge_' + suffix, password, displayName: '桥梁脚本闭环验收', departmentId: '5', roleIds: [role.id], defaultRoleId: role.id, enabled: true, remark: '临时验收账号'});
  12. const userId = (created.user || created).id;
  13. const browser = await chromium.launch({channel: 'msedge', headless: true, args: ['--enable-unsafe-swiftshader']});
  14. let page;
  15. try {
  16. const signed = await call('auth/v1/auth/login', null, {userId, password, roleCode: 'student', rememberMe: false});
  17. assert.equal(signed.status, 200);
  18. 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; };
  19. const registered = JSON.parse(fs.readFileSync(`${dir}/registered.json`, 'utf8'));
  20. const context = await browser.newContext({viewport: {width: 1600, height: 1000}});
  21. await context.addInitScript(s => {
  22. sessionStorage.setItem('unreal-tran:web:access-token:v1', s.accessToken);
  23. sessionStorage.setItem('unreal-tran:web:refresh-token:v1', s.refreshToken);
  24. localStorage.setItem('unreal-tran:web:preferences:v1', JSON.stringify({themeMode: 'military'}));
  25. }, signed.data);
  26. page = await context.newPage(); page.on('pageerror', error => errors.push(error.message));
  27. page.on('response', async response => {
  28. if (response.status() >= 400 && response.url().includes('/api/tran/')) {
  29. const body = await response.json().catch(() => ({}));
  30. console.log(JSON.stringify({path: new URL(response.url()).pathname, status: response.status(), message: body.message}));
  31. }
  32. });
  33. await page.goto('http://127.0.0.1:6180/teaching/virtual-training');
  34. await page.getByPlaceholder('请输入任务名称或编码').fill('TASK-VIRTUAL-00');
  35. await page.getByPlaceholder('请输入任务名称或编码').press('Enter');
  36. for (const item of registered) {
  37. const card = page.getByTestId(`teaching-assignment-${item.code}`);
  38. await expect(card).toBeVisible({timeout:30000});
  39. await expect(card.getByRole('button',{name:'领取任务',exact:true})).toBeEnabled();
  40. }
  41. await expect(page.getByText('静态流程 · 不计成绩',{exact:true})).toHaveCount(0);
  42. await page.screenshot({path:`${dir}/normal-task-list.png`});
  43. checks.push('原四条任务保留正常领取入口,不显示静态演示或禁评分标签');
  44. // Test copies keep the four delivery tasks free of test runs and test grades.
  45. for (const item of [...registered.slice(1), registered[0]].filter(item => !process.env.BRIDGE_CODE || item.code === process.env.BRIDGE_CODE)) {
  46. const original = await api(`teaching/assignments/${item.id}`);
  47. assert.equal(original.executionMode, 'PRACTICE'); assert.equal(original.definitionSnapshot.runtime.definitionSource, 'SCRIPT');
  48. let task = await adminApi('tran/v1/teaching/assignments', {code: `CHECK-${suffix}-${item.code}`, name: `验收·${item.name}`,
  49. description: '脚本训练闭环自动化验证,完成后归档', channel: 'VIRTUAL', assignmentKind: 'TRAINING', executionMode: 'PRACTICE',
  50. audienceType: 'COMMON', collaborationMode: 'INDIVIDUAL', digitalHumanAllowed: false, contentProjectId: item.projectId, contentVersionId: item.versionId, members: []});
  51. const fixture = {taskId: task.id, code: item.code}; fixtures.push(fixture);
  52. task = await adminApi(`tran/v1/teaching/assignments/${task.id}/publish`, {version: task.version});
  53. let run = await api(`teaching/assignments/${task.id}/accept`, {assignmentVersion: task.version, teamCode: 'NEUTRAL', positionCode: 'LEARNER'});
  54. fixture.runId = run.id;
  55. await page.goto(`http://127.0.0.1:6180/teaching/virtual-training/runs/${run.id}`);
  56. const procedure = item.presentation === 'procedure';
  57. if (procedure) await expect(page.locator('.bridge-procedure h2')).toContainText(item.name);
  58. else await expect(page.locator('[data-role="loading"]')).toHaveClass(/is-hidden/, {timeout: 120000});
  59. run = await api(`teaching/runs/${run.id}`); assert.equal(run.status, 'IN_PROGRESS');
  60. for (let i = 0; i < item.steps; i++) {
  61. if (procedure) {
  62. await expect(page.locator('.bridge-procedure-current > small')).toHaveText(`第 ${i + 1} / ${item.steps} 步`);
  63. if (item.code === 'TASK-VIRTUAL-008' && [10,14].includes(i)) {
  64. await page.getByRole('button', {name: '完成本步并保存', exact: true}).click();
  65. await expect(page.locator('.bridge-procedure-progress')).toContainText(`已保存 ${i} /`);
  66. await page.getByRole('textbox', {name: '操作记录'}).fill('验收记录:原稿相反指令已标记,连接状态与处理依据交教员核对。');
  67. }
  68. // A rejected save must leave both UI and server at the same step.
  69. if (i === 0 && item.code === 'TASK-VIRTUAL-006') {
  70. 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});
  71. await page.getByRole('button', {name:'完成本步并保存', exact:true}).click();
  72. await expect(page.locator('.bridge-procedure-progress')).toContainText('已保存 0 /');
  73. assert.equal((await api(`teaching/runs/${run.id}`)).progressPercent, 0);
  74. checks.push('步骤保存失败不前进,允许幂等重试');
  75. }
  76. await page.getByRole('button', {name:'完成本步并保存', exact:true}).click();
  77. await expect(page.getByRole('button', {name:'本步已保存', exact:true})).toBeDisabled();
  78. } else {
  79. await expect(page.locator('[data-role="step-count"]')).toHaveText(`${i + 1} / 5`);
  80. await page.locator('[data-role="part-label"]').click();
  81. if (i === 4) {
  82. await expect(page.locator('[data-role="part-label"]')).toContainText('互救开关');
  83. await page.locator('[data-role="part-label"]').click();
  84. }
  85. await expect(page.locator('[data-action="next"]')).toBeEnabled();
  86. }
  87. const saved = await api(`teaching/runs/${run.id}`);
  88. assert.ok(saved.progressPercent > (i / item.steps) * 100 - 1);
  89. const steps = await api(`teaching/runs/${run.id}/steps`);
  90. assert.equal(steps.filter(s => s.statusCode === 'COMPLETED').length, i + 1);
  91. if (i === 0) {
  92. await page.reload();
  93. if (procedure) await expect(page.locator('.bridge-procedure-progress')).toContainText('已保存 1 /');
  94. else {
  95. await expect(page.locator('[data-role="loading"]')).toHaveClass(/is-hidden/, {timeout:120000});
  96. await expect(page.locator('[data-role="step-count"]')).toHaveText('2 / 5');
  97. const bar = page.locator('[data-role="model-commandbar"]');
  98. 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();
  99. }
  100. checks.push(`${item.code} 刷新从服务端恢复已完成步骤`);
  101. } else if (i < item.steps - 1) {
  102. await (procedure ? page.getByRole('button', {name:'下一步', exact:true}) : page.locator('[data-action="next"]')).click();
  103. }
  104. }
  105. await (procedure ? page.getByRole('button', {name:'提交训练', exact:true}) : page.locator('[data-action="next"]')).click();
  106. await expect.poll(async () => (await api(`teaching/runs/${run.id}`)).status).toBe('SUBMITTED');
  107. run = await api(`teaching/runs/${run.id}`); assert.equal(run.score, null);
  108. await page.screenshot({path:`${dir}/normal-${item.code}-submitted.png`});
  109. run = await adminApi(`tran/v1/teaching/runs/${run.id}/review`, {version:run.version,commandId:crypto.randomUUID(),score:85,passed:true,feedback:'自动化验收样例评分,验证教员评定链路',rubric:{testFixture:true}});
  110. assert.equal(run.status,'REVIEWED'); assert.equal(run.score,85);
  111. const visible = await api(`teaching/runs/${run.id}`); assert.equal(visible.score,85);
  112. await page.reload();
  113. await expect(page.locator('.virtual-server-status')).toContainText('教员评定:85 分');
  114. if (!procedure) await expect(page.locator('[data-role="loading"]')).toHaveClass(/is-hidden/, {timeout:120000});
  115. await page.screenshot({path:`${dir}/normal-${item.code}-reviewed.png`});
  116. const analytics = await adminApi(`tran/v1/teaching/analytics/records?userId=${userId}&channel=VIRTUAL&assignmentKind=TRAINING&size=100`);
  117. assert.ok(analytics.records.some(r => String(r.runId || r.id) === run.id));
  118. checks.push(`${item.code} ${item.steps} 步:领取、开始、保存、恢复、提交、教员85分评定、统计记录通过`);
  119. await adminApi(`tran/v1/teaching/runs/${run.id}/archive`, {version:run.version});
  120. task = await adminApi(`tran/v1/teaching/assignments/${task.id}`);
  121. await adminApi(`tran/v1/teaching/assignments/${task.id}/archive`, {version:task.version, reason:'闭环验收完成,归档测试副本'});
  122. fixture.archived = true;
  123. fs.writeFileSync(`${dir}/normal-fixtures.json`,JSON.stringify(fixtures,null,2));
  124. console.log(checks.at(-1));
  125. }
  126. assert.deepEqual(errors,[]);
  127. fs.writeFileSync(`${dir}/normal-acceptance.json`,JSON.stringify({pass:true,checks,fixtures,errors},null,2));
  128. } catch (error) {
  129. console.log('Visible feedback:', await page?.locator('.el-message').allTextContents());
  130. await page?.screenshot({path:`${dir}/normal-failure.png`}).catch(()=>{});
  131. fs.writeFileSync(`${dir}/normal-acceptance.json`,JSON.stringify({pass:false,error:error.message,checks,fixtures,errors},null,2));
  132. throw error;
  133. } finally {
  134. await browser.close();
  135. for (const fixture of fixtures.filter(item => !item.archived)) {
  136. if (fixture.runId) {
  137. let run = await adminApi(`tran/v1/teaching/runs/${fixture.runId}`);
  138. 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:'自动化验收中止,清理测试副本'});
  139. 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:'未完成的自动化验收副本'});
  140. if (run.status === 'REVIEWED') await adminApi(`tran/v1/teaching/runs/${run.id}/archive`, {version:run.version});
  141. }
  142. const task = await adminApi(`tran/v1/teaching/assignments/${fixture.taskId}`);
  143. await adminApi(`tran/v1/teaching/assignments/${task.id}/archive`, {version:task.version,reason:'自动化测试副本归档'});
  144. fixture.archived = true;
  145. }
  146. fs.appendFileSync(`${dir}/normal-attempts.jsonl`, JSON.stringify({time:new Date().toISOString(),fixtures})+'\n');
  147. const user = await adminApi(`auth/v1/users/${userId}`);
  148. await adminApi(`auth/v1/users/${userId}`, {version:user.version}, 'DELETE');
  149. }