import fs from 'node:fs';import crypto from 'node:crypto';import assert from 'node:assert/strict'; import {chromium,expect} from '@playwright/test';import {login,call} from './role-permissions-live.mjs'; const report='reports/virtual-html-full-closure-20260912';fs.mkdirSync(report,{recursive:true}); const admin=await login('root'),checks=[],errors=[]; const api=async(endpoint,session=admin,body,method)=>{const r=await call(endpoint,session?.accessToken,body,method);assert.equal(r.status,200,endpoint+': '+r.message);return r.data}; const suffix=Date.now().toString(36),password='Ex!'+crypto.randomBytes(8).toString('hex'); const roles=await api('auth/v1/roles?size=100'),role=(roles.records||roles).find(r=>r.code==='student'); const user=await api('auth/v1/users',admin,{username:'ex_'+suffix,displayName:'HTML考试验收-'+suffix,departmentId:'5',roleIds:[role.id],defaultRoleId:role.id,enabled:true,password}); const student=await api('auth/v1/auth/login',null,{userId:(user.user||user).id,roleCode:'student',password,rememberMe:false}); const source=(await api('tran/v1/content/projects/161')).project,now=Math.floor(Date.now()/1000); const task=await api('tran/v1/teaching/assignments',admin,{code:'HTML-EX-'+suffix,name:'HTML正式考试-'+suffix,description:'正式考试迁移与评定统计验收',channel:'VIRTUAL',assignmentKind:'FORMAL_EXAM',executionMode:'EXAM',audienceType:'ASSIGNED',collaborationMode:'INDIVIDUAL',digitalHumanAllowed:false,contentProjectId:source.id,contentVersionId:source.publishedVersionId,scheduleStartAt:now-60,dueAt:now+3600,members:[{userId:(user.user||user).id,memberType:'LEARNER',teamCode:'NEUTRAL',positionCode:'LEARNER',sortOrder:0}]}); const published=await api(`tran/v1/teaching/assignments/${task.id}/publish`,admin,{version:task.version}); const run=await api(`tran/v1/teaching/assignments/${task.id}/accept`,student,{assignmentVersion:published.version,teamCode:'NEUTRAL',positionCode:'LEARNER'}); fs.writeFileSync(report+'/exam-fixture.json',JSON.stringify({userId:(user.user||user).id,assignmentId:task.id,runId:run.id},null,2)); const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']}); const context=await browser.newContext({viewport:{width:1920,height:1080}}); await context.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},student); const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message)); const pass=(name,details)=>{checks.push({name,pass:true,details});console.log('PASS',name)}; try{ await page.goto(`http://127.0.0.1:6180/teaching/exams/runs/${run.id}`); await page.locator('.training-shell[data-formal-exam="true"]').waitFor({timeout:120000}); const details=await api(`tran/v1/teaching/assignments/${task.id}`,student); assert.ok(!details.definitionSnapshot?.steps?.length,'Exam assignment must not disclose steps'); const execution=await api(`tran/v1/teaching/runs/${run.id}/execution`,student); assert.equal(execution.currentStep.targetId,undefined);assert.ok(execution.deadlineAt>execution.serverNow); await expect(page.locator('[data-role="teacher-panel"]')).toBeHidden(); await expect(page.locator('[data-action="toggle-issue-highlight"]')).toBeHidden(); pass('正式考试进入 JS+HTML,仅读取服务端当前步骤,关闭数字人和目标提示'); for(let index=0;indexBoolean(await page.evaluate(()=>globalThis.__trainingSceneDiagnostics?.()?.publishedContentMounted)),{timeout:90000}).toBe(true); const button=page.locator('[data-exam-confirm]'),canvas=page.locator('canvas.training-canvas'); await expect(button).toBeDisabled();const rect=await canvas.boundingBox(); for(const [x,y] of [[.29,.4],[.3,.44],[.27,.42],[.5,.55],[.55,.6],[.5,.7],[.65,.65],[.45,.5]]){const px=rect.x+rect.width*x,py=rect.y+rect.height*y;await page.mouse.click(px,py);if(await button.isEnabled())break;await page.waitForTimeout(150);} await expect(button).toBeEnabled(); if(!index)await page.screenshot({path:report+'/01-exam-current-step.png',fullPage:true}); await button.click(); await expect.poll(async()=>(await api(`tran/v1/teaching/runs/${run.id}`,student)).progressPercent,{timeout:20000}).toBe(Math.round((index+1)*100/execution.totalSteps)); if(index+1(await api(`tran/v1/teaching/runs/${run.id}/execution`,student)).currentStepIndex).toBe(index+1); } await expect.poll(async()=>(await api(`tran/v1/teaching/runs/${run.id}`,student)).status,{timeout:20000}).toBe('SUBMITTED'); pass('实际三维选择、逐步提交、重新获取下一安全投影、最终交卷'); await page.screenshot({path:report+'/02-exam-submitted.png',fullPage:true}); const submitted=await api(`tran/v1/teaching/runs/${run.id}`,admin); const denied=await call(`tran/v1/teaching/runs/${run.id}/review`,student.accessToken,{version:submitted.version,score:100,passed:true,feedback:'越权测试',commandId:crypto.randomUUID()});assert.equal(denied.status,403); assert.equal(submitted.checkpoint.examObservations.length,execution.totalSteps,'All step observations retained for teacher'); const teacherContext=await browser.newContext({viewport:{width:1920,height:1080}});await teacherContext.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},admin); const teacher=await teacherContext.newPage();await teacher.goto('http://127.0.0.1:6180/teaching/exams'); await teacher.getByRole('button',{name:'查看',exact:true}).first().waitFor({timeout:60000}); await teacher.getByPlaceholder('请输入任务名称或编码').fill(task.name);await teacher.getByPlaceholder('请输入任务名称或编码').press('Enter'); await teacher.locator('article').filter({has:teacher.getByRole('heading',{name:task.name,exact:true})}).getByRole('button',{name:'查看',exact:true}).click(); await teacher.locator('.el-drawer').getByRole('button',{name:'评定',exact:true}).click(); const dialog=teacher.getByRole('dialog',{name:'教学任务评定'});await expect(dialog).toContainText('考核操作记录'); await dialog.getByText('已核对本次运行的评定依据',{exact:true}).click();await dialog.getByRole('spinbutton').fill('86'); await dialog.getByText('通过',{exact:true}).click();await dialog.getByPlaceholder('请输入评定意见').fill('HTML正式考试操作已核验'); await teacher.screenshot({path:report+'/06-teacher-exam-review.png',fullPage:true});await dialog.getByRole('button',{name:'提交评定'}).click(); await expect.poll(async()=>(await api(`tran/v1/teaching/runs/${run.id}`,admin)).status).toBe('REVIEWED'); const reviewed=await api(`tran/v1/teaching/runs/${run.id}`,admin); assert.equal(reviewed.status,'REVIEWED');assert.equal(reviewed.passed,true);assert.equal(reviewed.score,86); const records=await api(`tran/v1/teaching/analytics/records?keyword=${encodeURIComponent(task.name)}&assignmentKind=FORMAL_EXAM&page=1&size=20`,admin); assert.ok(records.records.some(r=>String(r.runId||r.id)===String(run.id))); const learnerResult=await api(`tran/v1/teaching/runs/${run.id}`,student);assert.equal(learnerResult.status,'REVIEWED');assert.equal(learnerResult.checkpoint.examObservations,undefined); pass('学员不能自评;教员评分后学员可见,统计明细包含本次运行',{runId:run.id,score:86}); await teacher.goto('http://127.0.0.1:6180/teaching/data-center'); await teacher.getByTestId('analytics-tab-exam').click();await teacher.getByPlaceholder('搜索学员、编号或任务名称').fill(task.name); await teacher.getByPlaceholder('搜索学员、编号或任务名称').press('Enter'); const table=teacher.getByTestId('analytics-record-table');await expect(table).toContainText(task.name,{timeout:30000});await expect(table).toContainText('86 分'); await table.getByRole('button',{name:'详情',exact:true}).first().click();await teacher.screenshot({path:report+'/07-exam-analytics.png',fullPage:true}); pass('数据中心页面查询正式考核成绩与记录详情'); const history=await call(`tran/v1/teaching/runs/${run.id}/execution`,student.accessToken);assert.notEqual(history.status,200); const deadlineNow=Math.floor(Date.now()/1000); const timedTask=await api('tran/v1/teaching/assignments',admin,{code:'HTML-TIME-'+suffix,name:'HTML考试截止验收-'+suffix,description:'安全边界验收',channel:'VIRTUAL',assignmentKind:'FORMAL_EXAM',executionMode:'EXAM',audienceType:'ASSIGNED',collaborationMode:'INDIVIDUAL',digitalHumanAllowed:false,contentProjectId:source.id,contentVersionId:source.publishedVersionId,scheduleStartAt:deadlineNow-5,dueAt:deadlineNow+25,members:[{userId:(user.user||user).id,memberType:'LEARNER',teamCode:'NEUTRAL',positionCode:'LEARNER',sortOrder:0}]}); const timedPublished=await api(`tran/v1/teaching/assignments/${timedTask.id}/publish`,admin,{version:timedTask.version}); const timedRun=await api(`tran/v1/teaching/assignments/${timedTask.id}/accept`,student,{assignmentVersion:timedPublished.version,teamCode:'NEUTRAL',positionCode:'LEARNER'}); const fixture=JSON.parse(fs.readFileSync(report+'/exam-fixture.json','utf8'));Object.assign(fixture,{deadlineAssignmentId:timedTask.id,deadlineRunId:timedRun.id});fs.writeFileSync(report+'/exam-fixture.json',JSON.stringify(fixture,null,2)); await page.goto(`http://127.0.0.1:6180/teaching/exams/runs/${timedRun.id}`);await page.locator('.training-shell').waitFor({timeout:20000}); await expect(page.getByRole('button',{name:'返回考试测评',exact:true})).toBeVisible({timeout:35000}); const expiredProjection=await call(`tran/v1/teaching/runs/${timedRun.id}/execution`,student.accessToken);assert.notEqual(expiredProjection.status,200); const expiredAssets=await call(`tran/v1/teaching/runs/${timedRun.id}/assets`,student.accessToken);assert.notEqual(expiredAssets.status,200); await page.screenshot({path:report+'/08-exam-deadline.png',fullPage:true}); const expiredRun=await api(`tran/v1/teaching/runs/${timedRun.id}`,admin);await api(`tran/v1/teaching/runs/${timedRun.id}/timeout`,admin,{version:expiredRun.version}); pass('考试到期自动关闭工作区;服务端拒绝继续获取执行投影和资产,教员完成超时结算'); assert.equal(errors.length,0,errors.join('\n')); }catch(error){checks.push({name:'考试、评分和统计验收',pass:false,error:error.message});console.error(error);process.exitCode=1;await page.screenshot({path:report+'/exam-failure.png',fullPage:true})} finally{fs.writeFileSync(report+'/exam-checks.json',JSON.stringify({checks,errors},null,2));await browser.close()}