import { expect, test } from '@playwright/test'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; const fixture = JSON.parse( readFileSync(resolve('web_platform/src/rl/fixtures/obstacleDeployment.json'), 'utf8'), ); // Original Go2 collision/inertia model, visual meshes omitted to keep the smoke fixture lightweight. const go2 = readFileSync( resolve('training_server/rl/src/assets/robots/unitree_go2/xmls/go2.xml'), 'utf8', ) .replace(/]*\/>/g, '') .replace(/]*\bmesh="[^"]*"[^>]*\/>/g, ''); const model = readFileSync( process.env.GO2_SMOKE_POLICY ?? resolve('web_platform/fixtures/obstacle/zero-action.onnx'), ); test('训练作业一键导入:真实WASM地图+81维ORT+PiP+射线开关', async ({ page }) => { await page.addInitScript(() => { localStorage.setItem('mujoco-local-training-job-id', 'a'.repeat(32)); sessionStorage.setItem('mujoco-local-training-token', 'test'); }); const job = { id: 'a'.repeat(32), taskId: 'Unitree-Go2-ObstacleAvoidance', state: 'succeeded', artifactReady: true, progress: 1, iteration: 1, maxIterations: 1, logs: [ 'Learning iteration 0 / 1', 'Mean value loss: 0.9', 'Mean surrogate loss: -0.1', 'Mean entropy loss: -1', 'Mean reward: 2', 'Mean episode length: 20', 'Learning iteration 1 / 1', 'Mean value loss: 0.5', 'Mean surrogate loss: -0.2', 'Mean entropy loss: -0.8', 'Mean reward: 3', 'Mean episode length: 30', ], message: '测试专用零动作策略', deployment: fixture, }; await page.route('http://127.0.0.1:8765/**', (route) => { const url = route.request().url(); if (url.endsWith('/policy.onnx')) return route.fulfill({ contentType: 'application/octet-stream', body: model }); if (url.endsWith('/health')) return route.fulfill({ json: { ready: true, trainerRoot: '/test', tasks: [job.taskId], activeJobId: job.id }, }); if (url.includes('/presets')) return route.fulfill({ json: { presets: [] } }); return route.fulfill({ json: job }); }); await page.goto('/'); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(go2) }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('tab', { name: '控制台' }).click(); const tools = page.getByRole('tabpanel', { name: '控制台' }); await tools.getByRole('button', { name: /强化学习任务/ }).click(); await tools.getByRole('button', { name: '连接', exact: true }).click(); await expect(tools.getByRole('button', { name: '导入策略' })).toBeEnabled(); await tools.getByRole('button', { name: '导入策略' }).click(); await expect(page.getByText('训练配套物理地图', { exact: false })).toBeVisible({ timeout: 30_000, }); await expect(page.getByLabel('摄像头画面', { exact: true })).toBeVisible(); const rays = page.getByRole('checkbox', { name: '显示避障射线' }); await expect(rays).toBeChecked(); await rays.uncheck(); await rays.check(); const section = tools.getByRole('button', { name: /ONNX 策略运行/ }); if ((await section.getAttribute('aria-expanded')) === 'false') await section.click(); await expect(tools.getByText('81 / 12')).toBeVisible(); await expect(tools.getByText('Go2 前视射线避障导航', { exact: true })).toBeVisible(); const count = tools.getByText('推理次数', { exact: true }).locator('..'); await expect .poll(async () => Number((await count.textContent())?.replace(/\D/g, ''))) .toBeGreaterThan(0); const pause = page.getByRole('button', { name: '⏸ 暂停' }); if (await pause.isVisible()) await pause.click(); const stop = tools.getByRole('button', { name: '停止', exact: true }); if (await stop.isVisible()) await stop.click(); const targetRow = tools.getByText('当前目标 (X, Y) m', { exact: true }).locator('..'); const initialTarget = await targetRow.textContent(); const selection = () => page.locator('[aria-selected="true"]').allTextContents(); const beforeSelection = await selection(); await tools.getByRole('button', { name: '设定目标', exact: true }).click(); await page.keyboard.press('Escape'); await expect(tools.getByRole('button', { name: '设定目标', exact: true })).toHaveAttribute( 'aria-pressed', 'false', ); await expect(targetRow).toHaveText(initialTarget!); await tools.getByRole('button', { name: '设定目标', exact: true }).click(); const canvas = page.locator('.viewport-shell canvas').first(); const bounds = (await canvas.boundingBox())!; await canvas.click({ position: { x: bounds.width * 0.5, y: bounds.height * 0.55 } }); await expect(targetRow).not.toHaveText(initialTarget!); await expect(tools.getByRole('button', { name: '设定目标', exact: true })).toHaveAttribute( 'aria-pressed', 'false', ); expect(await selection()).toEqual(beforeSelection); await expect(tools.getByRole('button', { name: '启用', exact: true })).toBeVisible(); await tools.getByRole('button', { name: '复位目标点' }).click(); await expect(targetRow).toHaveText(initialTarget!); await tools.getByRole('button', { name: '设定目标', exact: true }).click(); await tools.getByRole('button', { name: '卸载', exact: true }).click(); await expect(targetRow).toHaveCount(0); await expect(tools.locator('.uplot')).toHaveCount(0); await tools.getByRole('button', { name: /训练指标趋势/ }).click(); await expect(tools.locator('.uplot canvas')).toHaveCount(1); await tools.getByRole('tab', { name: '综合', exact: true }).click(); await expect(tools.locator('.uplot canvas')).toHaveCount(5); await tools.getByRole('button', { name: /训练指标趋势/ }).click(); await expect(tools.locator('.uplot')).toHaveCount(0); }); test('下载metadata与作业不匹配时拒绝,未更换地图或启用策略', async ({ page }) => { await page.addInitScript(() => sessionStorage.setItem('mujoco-local-training-token', 'test')); const job = { id: 'b'.repeat(32), taskId: fixture.taskId, state: 'succeeded', artifactReady: true, progress: 1, iteration: 1, maxIterations: 1, logs: [], message: '', deployment: { ...fixture, sensorCfg: { ...fixture.sensorCfg, fov: 60 } }, }; await page.route('http://127.0.0.1:8765/**', (route) => { const url = route.request().url(); if (url.endsWith('/policy.onnx')) return route.fulfill({ contentType: 'application/octet-stream', body: model }); if (url.endsWith('/health')) return route.fulfill({ json: { ready: true, tasks: [job.taskId], activeJobId: job.id } }); if (url.includes('/presets')) return route.fulfill({ json: { presets: [] } }); return route.fulfill({ json: job }); }); await page.goto('/'); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(go2) }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('tab', { name: '控制台' }).click(); const tools = page.getByRole('tabpanel', { name: '控制台' }); await tools.getByRole('button', { name: /强化学习任务/ }).click(); await tools.getByRole('button', { name: '连接', exact: true }).click(); await tools.getByRole('button', { name: '导入策略' }).click(); await expect(page.getByText('下载的策略与训练作业部署配置不一致').first()).toBeVisible(); await expect(page.getByText('训练配套物理地图', { exact: false })).toHaveCount(0); await expect(page.getByLabel('摄像头画面', { exact: true })).toHaveCount(0); }); for (const failure of ['wrong-graph', 'ort-init-failure']) { test(`事务导入${failure}时保留旧地图、策略和暂停状态`, async ({ page }) => { await page.addInitScript(() => sessionStorage.setItem('mujoco-local-training-token', 'test')); let bytes = model; const job = { id: 'c'.repeat(32), taskId: fixture.taskId, state: 'succeeded', artifactReady: true, progress: 1, iteration: 1, maxIterations: 1, logs: [], message: '', deployment: fixture, }; await page.route('http://127.0.0.1:8765/**', (route) => { const url = route.request().url(); if (url.endsWith('/policy.onnx')) return route.fulfill({ contentType: 'application/octet-stream', body: bytes }); if (url.endsWith('/health')) return route.fulfill({ json: { ready: true, tasks: [job.taskId], activeJobId: job.id } }); if (url.includes('/presets')) return route.fulfill({ json: { presets: [] } }); return route.fulfill({ json: job }); }); await page.goto('/'); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(go2) }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('tab', { name: '控制台' }).click(); const tools = page.getByRole('tabpanel', { name: '控制台' }); await tools.getByRole('button', { name: /强化学习任务/ }).click(); await tools.getByRole('button', { name: '连接', exact: true }).click(); await tools.getByRole('button', { name: '导入策略' }).click(); await expect(page.getByLabel('摄像头画面', { exact: true })).toBeVisible(); const pause = page.getByRole('button', { name: '⏸ 暂停' }); if (await pause.isVisible()) await pause.click(); const section = tools.getByRole('button', { name: /ONNX 策略运行/ }); if ((await section.getAttribute('aria-expanded')) === 'false') await section.click(); await expect(tools.getByText('81 / 12')).toBeVisible(); const inference = tools.getByText('推理次数', { exact: true }).locator('..'); // Stop policy as well to invalidate any pending inference, leaving a stable baseline. const stop = tools.getByRole('button', { name: '停止', exact: true }); if (await stop.isVisible()) await stop.click(); const before = await inference.textContent(); bytes = readFileSync(resolve(`web_platform/fixtures/obstacle/${failure}.onnx`)); await tools.getByRole('button', { name: '导入策略' }).click(); await expect(tools.getByRole('alert')).toContainText( failure === 'wrong-graph' ? '维度' : 'MissingOperatorForTransactionTest', ); await expect(tools.getByText('81 / 12')).toBeVisible(); await expect(inference).toHaveText(before!); await expect(page.getByLabel('摄像头画面', { exact: true })).toBeVisible(); await expect(page.getByText('训练配套物理地图', { exact: false })).toBeVisible(); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); }); } test('新server默认Flat作业兼容旧无metadata47维ONNX,错误graph拒绝且保留策略', async ({ page }) => { await page.addInitScript(() => sessionStorage.setItem('mujoco-local-training-token', 'test')); const flat = { ...fixture, taskId: 'Unitree-Go2-Flat', observationSize: 47, observationTerms: fixture.observationTerms.slice(0, 7), terrain: undefined, terrainPreset: undefined, terrainParams: undefined, sensorCfg: undefined, navigation: undefined, }; let bytes = readFileSync(resolve('web_platform/fixtures/obstacle/legacy-flat.onnx')); const job = { id: 'd'.repeat(32), taskId: flat.taskId, state: 'succeeded', artifactReady: true, progress: 1, iteration: 1, maxIterations: 1, logs: [], message: '', deployment: flat, }; await page.route('http://127.0.0.1:8765/**', (route) => { const url = route.request().url(); if (url.endsWith('/policy.onnx')) return route.fulfill({ contentType: 'application/octet-stream', body: bytes }); if (url.endsWith('/health')) return route.fulfill({ json: { ready: true, tasks: [job.taskId], activeJobId: job.id } }); if (url.includes('/presets')) return route.fulfill({ json: { presets: [] } }); return route.fulfill({ json: job }); }); const actuators = '' + fixture.jointNames .map((name: string) => ``) .join('') + ''; await page.goto('/'); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(go2.replace('', actuators + '')), }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('tab', { name: '控制台' }).click(); const tools = page.getByRole('tabpanel', { name: '控制台' }); await tools.getByRole('button', { name: /强化学习任务/ }).click(); await tools.getByRole('button', { name: '连接', exact: true }).click(); await tools.getByRole('button', { name: '导入策略' }).click(); const section = tools.getByRole('button', { name: /ONNX 策略运行/ }); if ((await section.getAttribute('aria-expanded')) === 'false') await section.click(); await expect(tools.getByText('47 / 12')).toBeVisible(); bytes = readFileSync(resolve('web_platform/fixtures/obstacle/legacy-wrong-shape.onnx')); await tools.getByRole('button', { name: '导入策略' }).click(); await expect(tools.getByRole('alert')).toContainText('维度'); await expect(tools.getByText('47 / 12')).toBeVisible(); await expect(page.getByText('训练配套物理地图', { exact: false })).toHaveCount(0); });