import assert from 'node:assert/strict' import fs from 'node:fs' import path from 'node:path' import crypto from 'node:crypto' import { fileURLToPath } from 'node:url' import { api, localCredentials } from './review-api.mjs' // Development integration test. Creates isolated students, tasks and runs; // credentials stay in memory and no hardware adapter is contacted. // node tools/training-modes-api-live.mjs --physical 163:215 --confrontation 164:216 const args = process.argv.slice(2) const parameter = name => args[args.indexOf(name) + 1] function reference(name) { if (!args.includes(name) || !/^\d+:\d+$/.test(parameter(name) || '')) throw new Error(`Required: ${name} projectId:publishedVersionId`) const [projectId, versionId] = parameter(name).split(':') return { projectId, versionId } } const waitForConfrontation = args.includes('--wait-confrontation') const references = { PHYSICAL: reference('--physical'), CONFRONTATION: waitForConfrontation ? null : reference('--confrontation') } const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/training-modes-20260905') const readyFile = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../.codex-tmp/training-modes-confrontation-ready.json') fs.mkdirSync(reportDir, { recursive: true }) const evidencePath = path.join(reportDir, 'api-evidence.json') const suffix = Date.now().toString(36).toUpperCase() const prefix = `UTE2E-MODES-${suffix}` const evidence = { title: '实装人工事件与红蓝对抗正式 API 闭环', status: 'RUNNING', startedAt: new Date().toISOString(), baseURL: 'http://127.0.0.1:6180', method: '真实 Auth / Gateway / Tran API;新建隔离学生与开发测试任务;不连接设备、视觉推理或外部通知。', references, checks: [], assignments: [], runs: [], actors: [], requests: [], limitations: [ '实装事件均为本次自动化提交的 MANUAL 人工确认或明确的权限拒绝测试,不代表真实设备、PLC、摄像机或视觉模型联调。', '教学运行结果来自正式 API;默认实装离线回放和默认对抗本地工作台的显示另行记录,不混同为服务端运行闭环。', '对抗按四名隔离学员的独立会话依次操作,检查服务端截止时间已生效;未等待整局自然超时,也不代表多人同时操作的压力或网络同步测试。', '测试账号使用本次随机密码,密码与会话仅在运行内存中;报告只保留用户 ID、队伍和岗位以便追溯。', ], } const credentials = localCredentials() const ephemeralPassword = `Tm!${crypto.randomBytes(8).toString('hex')}` const secretValues = [credentials.username, credentials.password, ephemeralPassword] const sessions = [] let admin const save = () => fs.writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`) function safeError(error) { let result = error instanceof Error ? error.message : String(error) for (const value of secretValues) if (value) result = result.split(value).join('[redacted]') return result.replace(/Bearer\s+\S+/gi, 'Bearer [redacted]').replace(/eyJ[\w-]+\.[\w-]+\.[\w-]+/g, '[redacted]') } async function call(url, actor, body, method) { const result = await api(url, typeof actor === 'string' ? actor : actor?.token, body, method) if (url.startsWith('/api/tran/v1/teaching/')) evidence.requests.push({ method: method || (body === undefined ? 'GET' : 'POST'), path: url, status: result.status }) return result } async function request(url, actor, body, method) { const result = await call(url, actor, body, method) if (result.status !== 200) throw new Error(`${method || (body === undefined ? 'GET' : 'POST')} ${url}: HTTP ${result.status}; ${safeError(result.message || '')}`) return result.data } async function denied(url, actor, body, expected, method) { const result = await call(url, actor, body, method) assert.ok([expected].flat().includes(result.status), `${method || 'POST'} ${url}: expected ${expected}, received ${result.status}: ${safeError(result.message || '')}`) return { status: result.status, message: safeError(result.message || '') } } function passed(title, details) { evidence.checks.push({ title, status: 'PASS', details }); save() } async function section(title, fn) { try { await fn() } catch (error) { evidence.checks.push({ title, status: 'FAIL', details: safeError(error) }); save() } } const uuid = () => crypto.randomUUID() const teaching = relative => `/api/tran/v1/teaching${relative}` const codeOf = step => String(step.stepCode || step.code || step.id || '') const actionOf = step => String(step.actionCode || step.action?.code || '') const slimRun = run => ({ id: run.id, assignmentId: run.assignmentId, status: run.status, version: run.version, progressPercent: run.progressPercent, currentStepCode: run.currentStepCode, score: run.score, passed: run.passed }) const slimFacts = facts => facts.map(fact => ({ stepCode: fact.stepCode, status: fact.statusCode, targetProgressPercent: fact.targetProgressPercent, attemptCount: fact.attemptCount, failureCount: fact.failureCount, evidenceSourceCode: fact.evidenceSourceCode })) const rowsOf = value => value?.records || value || [] const runOf = (id, actor) => request(teaching(`/runs/${id}`), actor) const factsOf = (id, actor) => request(teaching(`/runs/${id}/steps`), actor) function rememberRun(run, extra = {}) { const entry = { ...slimRun(run), ...extra } const index = evidence.runs.findIndex(item => item.id === run.id) if (index >= 0) evidence.runs[index] = { ...evidence.runs[index], ...entry } else evidence.runs.push(entry) save() } async function loginAdmin() { const login = await request('/api/auth/v1/auth/login', null, { ...credentials, roleCode: 'admin', rememberMe: false }) admin = { token: login.accessToken } secretValues.push(admin.token) sessions.push(admin) } async function createActors() { const roles = rowsOf(await request('/api/auth/v1/roles?size=100', admin)) const role = roles.find(item => item.code === 'student') assert.ok(role?.id, 'student role must exist') const context = await request('/api/auth/v1/auth/login-context', admin) const department = context.departments?.find(item => item.children?.length)?.children?.[0] || context.departments?.[0] assert.ok(department?.id, 'An active department must exist') const actors = [] for (const team of ['RED', 'BLUE']) for (const position of ['COMMANDER', 'OPERATOR']) { const username = `tm_${suffix.toLowerCase()}_${team[0].toLowerCase()}${position[0].toLowerCase()}` secretValues.push(username) const created = await request('/api/auth/v1/users', admin, { username, displayName: `模式实测-${suffix}-${team}-${position}`, departmentId: department.id, roleIds: [role.id], defaultRoleId: role.id, enabled: true, password: ephemeralPassword, remark: `${prefix}; isolated development API test; no hardware`, }) const user = created.user || created assert.ok(user.id, 'New student must have an id') evidence.actors.push({ userId: String(user.id), team, position, departmentId: String(department.id) }) save() const logged = await request('/api/auth/v1/auth/login', null, { userId: user.id, roleCode: 'student', password: ephemeralPassword, rememberMe: false }) const actor = { id: String(user.id), token: logged.accessToken, team, position } secretValues.push(actor.token) sessions.push(actor) actors.push(actor) } return actors } async function contentReference(channel) { const reference = references[channel] const detail = await request(`/api/tran/v1/content/projects/${reference.projectId}`, admin) const project = detail.project || detail assert.equal(project.status, 'PUBLISHED') assert.equal(String(project.publishedVersionId), reference.versionId) const version = await request(`/api/tran/v1/content/projects/${reference.projectId}/versions/${reference.versionId}`, admin) const content = version.content || version.version?.content assert.ok(content && Array.isArray(content.steps) && content.steps.length > 1, `${channel}: published steps required`) return { reference, content, sceneDependencies: (version.dependencies || []).filter(dependency => dependency.relationType === 'TRAINING_SCENE') } } async function verifyFrozenSnapshots(profiles) { assert.equal(evidence.assignments.length, 2, 'Both isolated teaching assignments must exist') const snapshots = [] for (const assignment of evidence.assignments) { const profile = profiles[assignment.channel] const current = await request(teaching(`/assignments/${assignment.id}`), admin) assert.equal(String(current.contentProjectId), profile.reference.projectId) assert.equal(String(current.contentVersionId), profile.reference.versionId) assert.equal(current.definitionSnapshot?.trainingMode, assignment.channel) assert.deepEqual(current.definitionSnapshot.steps.map(codeOf), profile.content.steps.map(codeOf)) assert.equal(profile.sceneDependencies.length, 1) const scene = current.definitionSnapshot.teachingScene const expected = profile.sceneDependencies[0] assert.equal(String(scene.sourceProjectId), String(expected.targetProjectId)) assert.equal(String(scene.sourceVersionId), String(expected.targetVersionId)) const models = scene.objects.filter(object => object.targetProjectId).map(object => ({ projectId: object.targetProjectId, versionId: object.targetVersionId, assetCode: object.assetCode })) assert.ok(models.length && models.every(model => model.versionId && model.assetCode)) const frozenScene = { projectId: scene.sourceProjectId, versionId: scene.sourceVersionId, models } assignment.frozenScene = frozenScene snapshots.push({ assignmentId: assignment.id, channel: assignment.channel, content: profile.reference, frozenScene }) } passed('正式任务快照固定训练、场景与模型发布版本', { snapshots }) } async function waitForConfrontationPublication() { evidence.waitingFor = 'CONFRONTATION published project/version reference from the UI test owner' save() console.log(JSON.stringify({ phase: 'WAITING_FOR_CONFRONTATION', physicalChecks: evidence.checks.length, readyFile })) const until = Date.now() + 30 * 60 * 1000 while (Date.now() < until) { if (fs.existsSync(readyFile) && fs.statSync(readyFile).mtimeMs >= Date.parse(evidence.startedAt)) { const ready = JSON.parse(fs.readFileSync(readyFile, 'utf8').replace(/^\uFEFF/, '')) assert.match(String(ready.projectId), /^\d+$/) assert.match(String(ready.versionId), /^\d+$/) references.CONFRONTATION = { projectId: String(ready.projectId), versionId: String(ready.versionId) } delete evidence.waitingFor save() return } await new Promise(resolve => setTimeout(resolve, 1500)) } throw new Error('Timed out waiting for the published CONFRONTATION UI reference; PHYSICAL evidence and all retained IDs are preserved') } async function newAssignment(channel, profile, actors) { const members = channel === 'PHYSICAL' ? [{ userId: actors[0].id, memberType: 'LEARNER', teamCode: 'NEUTRAL', positionCode: 'OPERATOR', sortOrder: 0 }] : actors.map((actor, index) => ({ userId: actor.id, memberType: 'LEARNER', teamCode: actor.team, positionCode: actor.position, sortOrder: index })) const created = await request(teaching('/assignments'), admin, { code: `${prefix}-${channel}`, name: `开发实测-${channel === 'PHYSICAL' ? '实装人工事件' : '红蓝岗位对抗'}-${suffix}`, description: '开发环境正式 API 流程验证;人工/模拟事件,不代表设备联调或实际技能评定。', channel, assignmentKind: 'TRAINING', executionMode: 'PRACTICE', audienceType: 'ASSIGNED', collaborationMode: channel === 'PHYSICAL' ? 'INDIVIDUAL' : 'TEAM', digitalHumanAllowed: false, contentProjectId: profile.reference.projectId, contentVersionId: profile.reference.versionId, members, }) evidence.assignments.push({ id: created.id, code: created.code, channel, contentProjectId: profile.reference.projectId, contentVersionId: profile.reference.versionId, status: created.status }) save() const published = await request(teaching(`/assignments/${created.id}/publish`), admin, { version: created.version }) evidence.assignments.at(-1).status = published.status evidence.assignments.at(-1).version = published.version save() return published } async function accept(assignment, actor, physical = false) { const run = await request(teaching(`/assignments/${assignment.id}/accept`), actor, { assignmentVersion: assignment.version, teamCode: physical ? 'NEUTRAL' : actor.team, positionCode: physical ? 'OPERATOR' : actor.position, }) rememberRun(run, { channel: assignment.channel, team: physical ? 'NEUTRAL' : actor.team }) return run } async function startWithReplay(run, actor) { const body = { version: run.version, commandId: uuid() } const started = await request(teaching(`/runs/${run.id}/start`), actor, body) const replay = await request(teaching(`/runs/${run.id}/start`), actor, body) assert.equal(replay.version, started.version) const facts = await factsOf(run.id, actor) assert.equal(facts[0].attemptCount, 1) assert.equal(facts[0].statusCode, 'IN_PROGRESS') passed('开始命令重放不会重复激活或递增次数', { runId: run.id, version: started.version, attemptCount: facts[0].attemptCount }) return started } async function submitAndReview(run, actor, channel) { const body = { version: run.version, commandId: uuid(), summary: '开发自动化合同测试完成;不代表真实技能训练。', evidence: { testOnly: true, source: 'development-api-contract' }, payload: {} } const submitted = await request(teaching(`/runs/${run.id}/submit`), actor, body) const replay = await request(teaching(`/runs/${run.id}/submit`), actor, body) assert.equal(submitted.status, 'SUBMITTED') assert.equal(replay.version, submitted.version) const reviewBody = { version: submitted.version, commandId: uuid(), score: 100, passed: true, feedback: '仅测试正式 API 评定留痕,不代表实际维修能力。', rubric: { testOnly: true } } const reviewed = await request(teaching(`/runs/${run.id}/review`), admin, reviewBody) const reviewReplay = await request(teaching(`/runs/${run.id}/review`), admin, reviewBody) assert.equal(reviewed.status, 'REVIEWED') assert.equal(reviewReplay.version, reviewed.version) const facts = await factsOf(run.id, admin) assert.ok(facts.every(fact => fact.statusCode === 'COMPLETED')) rememberRun(reviewed, { channel, stepFacts: slimFacts(facts) }) passed(`${channel} 提交、教员评定与结果回读`, { run: slimRun(reviewed), submitReplay: true, reviewReplay: true, facts: slimFacts(facts) }) return reviewed } async function physicalFlow(profile, actors) { const steps = profile.content.steps assert.ok(steps.every(step => step.physicalEventRule?.source === 'MANUAL'), 'This test only drives explicitly configured MANUAL rules') assert.deepEqual(steps.map(step => step.physicalEventRule.progressPercent), steps.map((_, index) => Math.round((index + 1) * 100 / steps.length))) const assignment = await newAssignment('PHYSICAL', profile, actors) const actor = actors[0] let run = await accept(assignment, actor, true) run = await startWithReplay(run, actor) passed('实装任务固定发布版本、分配学员并开始', { assignmentId: assignment.id, run: slimRun(run), content: profile.reference, rules: steps.map(step => ({ stepCode: codeOf(step), source: step.physicalEventRule.source, eventType: step.physicalEventRule.eventType, progressPercent: step.physicalEventRule.progressPercent })) }) const eventBody = (step, extra = {}) => ({ eventId: `MANUAL-${uuid()}`, runVersion: run.version, source: 'MANUAL', eventType: step.physicalEventRule.eventType, stepCode: codeOf(step), manualConfirmed: true, occurredAt: Math.floor(Date.now() / 1000), publicPayload: { testOnly: true, evidenceKind: 'api-contract-simulation' }, ...extra }) const forbiddenProgress = await denied(teaching(`/runs/${run.id}/progress`), actor, { version: run.version, commandId: uuid(), completedStepCode: codeOf(steps[0]), actionCode: actionOf(steps[0]) }, 403, 'PUT') const prematureSubmit = await denied(teaching(`/runs/${run.id}/submit`), actor, { version: run.version, commandId: uuid(), summary: 'negative test' }, 409) const skipped = await denied(teaching(`/runs/${run.id}/physical-events`), actor, eventBody(steps[1]), 409) const untrustedDevice = await denied(teaching(`/runs/${run.id}/physical-events`), actor, eventBody(steps[0], { source: 'DEVICE' }), 403) const managerManual = await denied(teaching(`/runs/${run.id}/physical-events`), admin, eventBody(steps[0]), 403) assert.equal((await runOf(run.id, actor)).progressPercent, 0) passed('实装拒绝普通进度接口、抢跑、提前提交及错误事件身份', { runId: run.id, forbiddenProgress, prematureSubmit, skipped, untrustedDevice, managerManual, progressUnchanged: 0 }) const failed = await request(teaching(`/runs/${run.id}/physical-events`), actor, eventBody(steps[0], { manualConfirmed: false })) assert.equal(failed.outcome, 'UNMATCHED') assert.equal(failed.progressApplied, false) let facts = await factsOf(run.id, actor) assert.equal(facts[0].statusCode, 'FAILED') assert.equal(facts[0].failureCount, 1) run = await runOf(run.id, actor) const beforeRetry = await denied(teaching(`/runs/${run.id}/physical-events`), actor, eventBody(steps[0]), 409) run = await request(teaching(`/runs/${run.id}/start`), actor, { version: run.version, commandId: uuid() }) facts = await factsOf(run.id, actor) assert.equal(facts[0].attemptCount, 2) passed('实装不匹配事件失败,必须重试后才可继续', { runId: run.id, outcome: failed.outcome, beforeRetry, facts: slimFacts(facts) }) const progression = [] for (const [index, step] of steps.entries()) { const body = eventBody(step) const result = await request(teaching(`/runs/${run.id}/physical-events`), actor, body) assert.equal(result.outcome, 'MATCHED') assert.equal(result.progressApplied, true) const after = await runOf(run.id, actor) assert.equal(after.progressPercent, Math.round((index + 1) * 100 / steps.length)) const replay = await request(teaching(`/runs/${run.id}/physical-events`), actor, body) assert.equal(replay.runVersion, after.version) assert.equal((await runOf(run.id, actor)).version, after.version) const differentPayload = await denied(teaching(`/runs/${run.id}/physical-events`), actor, { ...body, publicPayload: { testOnly: true, altered: true } }, 409) progression.push({ stepCode: codeOf(step), eventId: result.eventId, progress: after.progressPercent, replayVersion: replay.runVersion, alteredEventStatus: differentPayload.status }) run = after } facts = await factsOf(run.id, actor) assert.ok(facts.every(fact => fact.statusCode === 'COMPLETED' && fact.evidenceSourceCode === 'MANUAL')) passed('实装逐步人工确认、事件幂等与载荷冲突', { runId: run.id, progression, facts: slimFacts(facts) }) await submitAndReview(run, actor, 'PHYSICAL') } async function confrontationFlow(profile, actors) { const configuration = profile.content.channelProfiles?.CONFRONTATION || profile.content.modeConfig assert.equal(configuration.teamSize, 2, 'Isolated test expects two learners per team') assert.ok(configuration.submitPositions.includes('COMMANDER')) assert.ok(profile.content.steps.every(step => step.allowedPositions?.includes('COMMANDER') && !step.allowedPositions?.includes('OPERATOR')), 'Each tested action must be COMMANDER-only for the negative role test') const assignment = await newAssignment('CONFRONTATION', profile, actors) const runs = new Map() for (const actor of actors) { const run = await accept(assignment, actor) if (runs.has(actor.team)) assert.equal(run.id, runs.get(actor.team).id) runs.set(actor.team, run) } assert.notEqual(runs.get('RED').id, runs.get('BLUE').id) const listed = rowsOf(await request(teaching(`/runs?assignmentId=${assignment.id}&size=100`), admin)) assert.equal(listed.length, 2) passed('对抗红蓝两队各两人,按队伍共享且运行互相独立', { assignmentId: assignment.id, content: profile.reference, configuration: { teamSize: configuration.teamSize, roundDurationMinutes: configuration.roundDurationMinutes, submitPositions: configuration.submitPositions }, runs: listed.map(slimRun) }) const redCommander = actors.find(actor => actor.team === 'RED' && actor.position === 'COMMANDER') const crossTeam = await denied(teaching(`/runs/${runs.get('BLUE').id}`), redCommander, undefined, 403, 'GET') passed('对抗学员不能读取另一队运行明细', { requestingTeam: 'RED', otherRunId: runs.get('BLUE').id, response: crossTeam }) for (const team of ['RED', 'BLUE']) { const commander = actors.find(actor => actor.team === team && actor.position === 'COMMANDER') const operator = actors.find(actor => actor.team === team && actor.position === 'OPERATOR') let run = await startWithReplay(runs.get(team), commander) const execution = await request(teaching(`/runs/${run.id}/execution`), commander) assert.ok(Number.isFinite(execution.deadlineAt) && execution.deadlineAt > execution.serverNow, 'Server deadline must be active') const steps = profile.content.steps const progressBody = step => ({ version: run.version, commandId: uuid(), completedStepCode: codeOf(step), actionCode: actionOf(step), checkpoint: { testOnly: true }, automaticResult: {} }) const wrongPosition = await denied(teaching(`/runs/${run.id}/progress`), operator, progressBody(steps[0]), 403, 'PUT') const skipped = await denied(teaching(`/runs/${run.id}/progress`), commander, progressBody(steps[1]), [400, 409], 'PUT') const earlySubmit = await denied(teaching(`/runs/${run.id}/submit`), commander, { version: run.version, commandId: uuid(), summary: 'negative test' }, 409) assert.equal((await runOf(run.id, commander)).progressPercent, 0) passed(`${team} 队岗位越权、跳步和提前提交均拒绝`, { runId: run.id, wrongPosition, skipped, earlySubmit, deadlineAt: execution.deadlineAt, serverNow: execution.serverNow }) const progression = [] for (const [index, step] of steps.entries()) { const body = progressBody(step) const advanced = await request(teaching(`/runs/${run.id}/progress`), commander, body, 'PUT') const replay = await request(teaching(`/runs/${run.id}/progress`), commander, body, 'PUT') assert.equal(replay.version, advanced.version) assert.equal(advanced.progressPercent, Math.round((index + 1) * 100 / steps.length)) const conflict = await denied(teaching(`/runs/${run.id}/progress`), commander, { ...body, checkpoint: { testOnly: true, altered: true } }, 409, 'PUT') progression.push({ stepCode: codeOf(step), progress: advanced.progressPercent, replayVersion: replay.version, changedCommandStatus: conflict.status }) run = advanced } const wrongSubmitPosition = await denied(teaching(`/runs/${run.id}/submit`), operator, { version: run.version, commandId: uuid(), summary: 'negative role test' }, 403) passed(`${team} 队授权动作逐步推进、重放幂等、提交负责人限制`, { runId: run.id, progression, wrongSubmitPosition }) await submitAndReview(run, commander, 'CONFRONTATION') if (team === 'RED') { const blue = await runOf(runs.get('BLUE').id, admin) assert.equal(blue.progressPercent, 0) passed('红队完成不会推进蓝队进度', { redRunId: run.id, blueRun: slimRun(blue) }) } } } save() try { await loginAdmin() const physical = await contentReference('PHYSICAL') let confrontation = waitForConfrontation ? null : await contentReference('CONFRONTATION') const actors = await createActors() await section('PHYSICAL 正式流程', () => physicalFlow(physical, actors)) if (waitForConfrontation) { await waitForConfrontationPublication() confrontation = await contentReference('CONFRONTATION') } await section('CONFRONTATION 正式流程', () => confrontationFlow(confrontation, actors)) await section('固定发布快照回读', () => verifyFrozenSnapshots({ PHYSICAL: physical, CONFRONTATION: confrontation })) } catch (error) { evidence.checks.push({ title: '测试准备', status: 'FAIL', details: safeError(error) }) } finally { for (const actor of sessions.reverse()) await api('/api/auth/v1/auth/logout', actor.token, {}).catch(() => {}) evidence.status = evidence.checks.some(check => check.status === 'FAIL') ? 'FAIL' : 'PASS' evidence.finishedAt = new Date().toISOString() evidence.retainedData = { assignments: evidence.assignments.map(item => item.id), runs: evidence.runs.map(item => item.id), users: evidence.actors.map(item => item.userId), note: '仅新建本次开发测试数据,未修改或删除既有账号、任务与运行。' } save() console.log(JSON.stringify({ status: evidence.status, checks: evidence.checks.length, passed: evidence.checks.filter(check => check.status === 'PASS').length, evidence: 'reports/training-modes-20260905/api-evidence.json', retainedData: evidence.retainedData })) if (evidence.status !== 'PASS') process.exitCode = 1 }