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='Wf!'+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:'wf_'+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/419'),content=structuredClone(source.currentVersion.content); content.steps.forEach(step=>{step.mode='measure';step.interaction='rule-check';step.targetNodeId=step.targetId;step.action={...step.action,kind:'rule-check',mode:'measure',targetId:step.targetId,requiresPlacement:false};}); const edge=(to,rule='')=>({id:crypto.randomUUID(),to,rule}),node=(id,type,expression,transitions,extra={})=>({id,type,title:id,config:{expression},resolution:{expression,timeoutSeconds:2},transitions,...extra}); content.executionPlan={schema:'unreal-tran.training-execution-plan/v1',entryNodeId:'start',nodes:[ node('start','start','',[edge('fault')]),node('fault','fault-inject','PUMP',[edge('condition')]), node('condition','condition','fault.PUMP',[edge('wait','true'),edge('s1','false')]), node('s1','step','',[edge('s2')],{stepId:content.steps[0].id}), node('wait','event-wait','DEVICE_READY',[edge('camera')]), node('camera','camera','wide',[edge('audio')]),node('audio','audio','/audio/sfx/ui.wav',[edge('safety')]), node('safety','safety','event.DEVICE_READY',[edge('s2','true'),edge('s1','false')]), node('s2','step','',[edge('clear')],{stepId:content.steps[1].id}), node('clear','fault-clear','PUMP',[edge('score')]),node('score','score','5',[edge('end')]),node('end','end','',[])]}; const created=await api('tran/v1/content/projects',admin,{type:'TRAINING',name:'HTML服务端编排-'+suffix,categoryCode:'MAINTENANCE',trainingMode:'VIRTUAL',content,dependencies:source.currentVersion.dependencies,assets:[]}); await api(`tran/v1/content/projects/${created.project.id}/publish`,admin,{version:created.project.version}); const project=(await api(`tran/v1/content/projects/${created.project.id}`)).project; const task=await api('tran/v1/teaching/assignments',admin,{code:'HTML-WF-'+suffix,name:'HTML编排等待与分支-'+suffix,description:'服务端故障、条件分支和外部事件验收',channel:'VIRTUAL',assignmentKind:'TRAINING',executionMode:'PRACTICE',audienceType:'ASSIGNED',collaborationMode:'INDIVIDUAL',digitalHumanAllowed:true,contentProjectId:project.id,contentVersionId:project.publishedVersionId,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+'/workflow-fixture.json',JSON.stringify({userId:(user.user||user).id,projectId:project.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)}; const endpoint=`tran/v1/teaching/runs/${run.id}`,diagnostic=()=>page.evaluate(()=>globalThis.__trainingSceneDiagnostics?.()); const mounted=async()=>{await expect.poll(async()=>Boolean((await diagnostic())?.publishedContentMounted),{timeout:90000}).toBe(true)}; try{ await page.goto(`http://127.0.0.1:6180/teaching/virtual-training/runs/${run.id}`);await mounted(); let state=await api(endpoint+'/workflow',student);assert.equal(state.waiting,true);assert.equal(state.execution,undefined); await expect(page.getByRole('button',{name:'编排状态与事件'})).toHaveCount(0); const privateState=await api(endpoint+'/workflow');assert.equal(privateState.execution.faults.PUMP,true); const before=await api(endpoint,student); await call(endpoint+'/events',student.accessToken,{type:'workflow.state',title:'spoof',summary:'spoof',source:'WORKFLOW',visibility:'PUBLIC',publicPayload:{cursor:'end',ended:true}}); assert.equal((await api(endpoint+'/workflow',student)).waiting,true,'Public event cannot replace private workflow journal'); const denied=await call(endpoint+'/workflow/signals',student.accessToken,{version:before.version,commandId:crypto.randomUUID(),signalCode:'DEVICE_READY'});assert.equal(denied.status,403); const premature=await call(endpoint+'/submit',student.accessToken,{version:before.version,commandId:crypto.randomUUID(),summary:'premature'});assert.notEqual(premature.status,200); await page.getByRole('button',{name:'刷新流程',exact:true}).click();await mounted(); await expect.poll(async()=>(await api(endpoint+'/workflow',student)).timedOut,{timeout:12000}).toBe(true); await page.reload();await mounted();assert.equal((await api(endpoint+'/workflow',student)).waiting,true); await page.screenshot({path:report+'/03-workflow-waiting.png',fullPage:true}); pass('故障真值与编排游标在服务端;超时仍等待,刷新恢复;学员不能注入事件或提前交卷'); const managerContext=await browser.newContext({viewport:{width:1920,height:1080}});await managerContext.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},admin); const manager=await managerContext.newPage();await manager.goto(`http://127.0.0.1:6180/teaching/virtual-training/runs/${run.id}`); await manager.getByRole('button',{name:'编排状态与事件'}).click();await manager.getByPlaceholder('与事件等待节点一致,例如 DEVICE_READY').fill('DEVICE_READY'); const signalRequest=manager.waitForRequest(request=>request.url().endsWith('/workflow/signals')&&request.method()==='POST'); await manager.getByRole('button',{name:'确认事件并继续编排'}).click(); const signalBody=(await signalRequest).postDataJSON(); await expect.poll(async()=>(await api(endpoint+'/workflow',student)).waiting).toBe(false); const signalVersion=(await api(endpoint)).version; const replayed=await api(endpoint+'/workflow/signals',admin,signalBody);assert.equal(replayed.version,signalVersion);assert.equal((await api(endpoint)).version,signalVersion); await manager.screenshot({path:report+'/04-teacher-workflow-event.png',fullPage:true}); const facts=await api(endpoint+'/steps',student);assert.equal(facts[0].statusCode,'SKIPPED');assert.equal(facts[1].statusCode,'IN_PROGRESS'); await page.getByRole('button',{name:'刷新流程',exact:true}).click();await mounted(); await expect(page.locator('[data-step-index="0"]')).toContainText('分支跳过'); assert.deepEqual((await api(endpoint+'/workflow',student)).effects.map(e=>e.type),['camera','audio']); await expect.poll(async()=>Math.abs((await diagnostic()).framing.distance-15.5),{timeout:6000}).toBeLessThan(.1); const panel=page.locator('.training-server-preparation');if(!(await panel.evaluate(p=>p.open)))await panel.locator('summary').click(); await panel.locator('[data-tool="pressure-gauge"]').check();for(const checkbox of await panel.locator('[data-safety]').all())await checkbox.check(); await panel.locator('textarea').fill('编排分支验收:压力归零。');await panel.locator('[data-save-preparation]').click();await expect(panel.locator('[data-save-status]')).toHaveText('已保存');await panel.locator('summary').click(); await page.waitForTimeout(1500);await page.locator('[data-action="toggle-issue-highlight"]').click();await page.waitForTimeout(500); const d=await diagnostic(),rect=await page.locator('canvas.training-canvas').boundingBox();assert.ok(d.framing.targetOnScreen?.insideCanvas); await page.mouse.click(rect.x+d.framing.targetOnScreen.localX,rect.y+d.framing.targetOnScreen.localY+38); await expect.poll(async()=>(await api(endpoint,student)).progressPercent,{timeout:20000}).toBe(100); const ended=await api(endpoint+'/workflow');assert.equal(ended.ended,true);assert.deepEqual(ended.execution.faults,{});assert.equal(Number(ended.execution.suggestedScore),5); await page.locator('[data-action="next"]').click();await expect.poll(async()=>(await api(endpoint,student)).status,{timeout:20000}).toBe('SUBMITTED'); await page.screenshot({path:report+'/05-workflow-submitted.png',fullPage:true}); pass('教员页面确认事件、服务端选择分支、明确跳过第一步、真实操作第二步、清除故障与提交'); const submitted=await api(endpoint);await api(endpoint+'/review',admin,{version:submitted.version,commandId:crypto.randomUUID(),score:91,passed:true,feedback:'编排路径与实际操作已核验'}); const records=await api(`tran/v1/teaching/analytics/records?keyword=${encodeURIComponent(task.name)}&assignmentKind=TRAINING&page=1&size=20`);assert.ok(records.records.some(r=>String(r.runId||r.id)===run.id)); pass('编排训练交卷后由教员评分,真实统计包含本次运行',{runId:run.id,score:91});assert.equal(errors.length,0,errors.join('\n')); }catch(error){checks.push({name:'编排闭环实测',pass:false,error:error.message});console.error(error.message);process.exitCode=1;await page.screenshot({path:report+'/workflow-failure.png',fullPage:true}).catch(()=>{})} finally{fs.writeFileSync(report+'/workflow-checks.json',JSON.stringify({checks,errors},null,2));await browser.close()}