Files
Mujoco_WASM/web_platform/e2e/obstacle.spec.ts
T
chenlin 438e56bcc8
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
feat(training): release V0.9.1 避障训练与基础策略迁移
2026-09-08 10:50:13 +08:00

292 lines
13 KiB
TypeScript

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(/<mesh\b[^>]*\/>/g, '')
.replace(/<geom\b[^>]*\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.getByText('WASM 已加载')).toBeVisible({ 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)', { 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.getByText('WASM 已加载')).toBeVisible({ 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.getByText('WASM 已加载')).toBeVisible({ 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: '▶ 播放' })).toBeVisible();
});
}
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 =
'<actuator>' +
fixture.jointNames
.map((name: string) => `<motor name="${name}_motor" joint="${name}"/>`)
.join('') +
'</actuator>';
await page.goto('/');
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'go2.xml',
mimeType: 'text/xml',
buffer: Buffer.from(go2.replace('</mujoco>', actuators + '</mujoco>')),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ 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);
});