feat(training): release V0.9.1 避障训练与基础策略迁移
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
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/multiRingDeployment.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, '');
|
||||
let model = readFileSync(
|
||||
process.env.GO2_MULTI_SMOKE_POLICY ??
|
||||
resolve('web_platform/fixtures/obstacle/multi-zero-action.onnx'),
|
||||
);
|
||||
|
||||
test('训练作业一键导入:真实WASM地图+97维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('97 / 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: '导入策略' }).click();
|
||||
await expect(tools.getByRole('alert')).toHaveCount(0);
|
||||
// Switch real valid graph/metadata pairs in both directions, without relaxing transactions.
|
||||
const multiModel = model;
|
||||
job.deployment = JSON.parse(
|
||||
readFileSync(resolve('web_platform/src/rl/fixtures/obstacleDeployment.json'), 'utf8'),
|
||||
);
|
||||
model = readFileSync(resolve('web_platform/fixtures/obstacle/zero-action.onnx'));
|
||||
await tools.getByRole('button', { name: '连接', exact: true }).click();
|
||||
await tools.getByRole('button', { name: '导入策略' }).click();
|
||||
await expect(tools.getByText('81 / 12')).toBeVisible();
|
||||
job.deployment = fixture;
|
||||
model = multiModel;
|
||||
await tools.getByRole('button', { name: '连接', exact: true }).click();
|
||||
await tools.getByRole('button', { name: '导入策略' }).click();
|
||||
await expect(tools.getByText('97 / 12')).toBeVisible();
|
||||
const pauseAgain = page.getByRole('button', { name: '⏸ 暂停' });
|
||||
if (await pauseAgain.isVisible()) await pauseAgain.click();
|
||||
const stopAgain = tools.getByRole('button', { name: '停止', exact: true });
|
||||
if (await stopAgain.isVisible()) await stopAgain.click();
|
||||
// A real 81-feature ONNX graph with valid 97 metadata must fail transactionally.
|
||||
model = readFileSync(resolve('web_platform/fixtures/obstacle/multi-wrong-graph.onnx'));
|
||||
await tools.getByRole('button', { name: '导入策略' }).click();
|
||||
await expect(tools.getByRole('alert')).toContainText('维度');
|
||||
await expect(tools.getByText('97 / 12')).toBeVisible();
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// Opt-in real source-derived actor, not a constant fixture. Run against the Vite dev server.
|
||||
const policyPath = process.env.GO2_PRETRAINED_NAV_POLICY;
|
||||
test('真实warm-start策略连续行走超过20秒并换目标,不重载策略或自动启用', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
!policyPath,
|
||||
'设置GO2_PRETRAINED_NAV_POLICY为真实47→81/97 warm-start导出,并运行Vite dev :4173',
|
||||
);
|
||||
const xml = readFileSync(
|
||||
resolve('training_server/rl/src/assets/robots/unitree_go2/xmls/go2.xml'),
|
||||
'utf8',
|
||||
)
|
||||
.replace(/<mesh\b[^>]*\/>/g, '')
|
||||
.replace(/<geom\b[^>]*\bmesh="[^"]*"[^>]*\/>/g, '');
|
||||
const policy = Array.from(readFileSync(policyPath!));
|
||||
await page.goto(process.env.GO2_PRETRAINED_DEV_URL ?? 'http://127.0.0.1:4174');
|
||||
const result = await page.evaluate(
|
||||
async ({ xml, policy }) => {
|
||||
const adapterPath = '/src/simulation/PhysicsAdapter.ts';
|
||||
const deploymentPath = '/src/rl/deployment.ts';
|
||||
const navigationPath = '/src/rl/tasks/go2ObstacleAvoidance.ts';
|
||||
const { MainThreadPhysicsAdapter } = (await import(
|
||||
adapterPath
|
||||
)) as typeof import('../src/simulation/PhysicsAdapter');
|
||||
const { readPolicyDeployment } = (await import(
|
||||
deploymentPath
|
||||
)) as typeof import('../src/rl/deployment');
|
||||
const { obstacleNavigation } = (await import(
|
||||
navigationPath
|
||||
)) as typeof import('../src/rl/tasks/go2ObstacleAvoidance');
|
||||
const bytes = new Uint8Array(policy),
|
||||
data = new TextEncoder().encode(xml);
|
||||
const deployment = readPolicyDeployment(bytes)!;
|
||||
const adapter = new MainThreadPhysicsAdapter();
|
||||
try {
|
||||
await adapter.load(
|
||||
{
|
||||
id: 'real-pretrained',
|
||||
name: 'go2',
|
||||
files: [
|
||||
{ path: 'go2.xml', data, size: data.length, source: 'file', mimeType: 'text/xml' },
|
||||
],
|
||||
entries: [{ path: 'go2.xml', format: 'mjcf', label: 'Go2' }],
|
||||
maps: [],
|
||||
totalBytes: data.length,
|
||||
},
|
||||
'go2.xml',
|
||||
{
|
||||
trainingDeployment: deployment,
|
||||
trainingPolicy: { data: bytes, path: 'user-warmstart.onnx' },
|
||||
},
|
||||
);
|
||||
const session = adapter.session!;
|
||||
const originalPolicy = (session as unknown as { rlPolicy: object }).rlPolicy;
|
||||
const initial = adapter.snapshot()!;
|
||||
const samples: {
|
||||
time: number;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
enabled?: boolean;
|
||||
error?: string;
|
||||
}[] = [];
|
||||
const run = async (seconds: number) => {
|
||||
const target = Number(session.data.time) + seconds;
|
||||
while (Number(session.data.time) < target) {
|
||||
for (let i = 0; i < 10; i++) adapter.singleStep();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
if (!adapter.snapshot()?.rlPolicy?.enabled) break;
|
||||
}
|
||||
const s = adapter.snapshot()!;
|
||||
samples.push({
|
||||
time: s.time,
|
||||
x: s.qpos[0],
|
||||
y: s.qpos[1],
|
||||
z: s.qpos[2],
|
||||
enabled: s.rlPolicy?.enabled,
|
||||
error: s.rlPolicy?.error,
|
||||
});
|
||||
return s;
|
||||
};
|
||||
const before = await run(21);
|
||||
const navBefore = obstacleNavigation(
|
||||
before.qpos.slice(0, 3),
|
||||
before.qpos.slice(3, 7),
|
||||
before.rlPolicy!.navigation!.target,
|
||||
deployment.terrain!.size,
|
||||
deployment.navigation?.speed,
|
||||
);
|
||||
// Nearby diagonal target exercises both heading and velocity command; no pose teleport.
|
||||
adapter.setNavigationTarget([before.qpos[0] - 2, before.qpos[1] + 2]);
|
||||
const changed = adapter.snapshot()!;
|
||||
const navChanged = obstacleNavigation(
|
||||
changed.qpos.slice(0, 3),
|
||||
changed.qpos.slice(3, 7),
|
||||
changed.rlPolicy!.navigation!.target,
|
||||
deployment.terrain!.size,
|
||||
deployment.navigation?.speed,
|
||||
);
|
||||
const after = await run(4);
|
||||
adapter.setRLPolicyEnabled(false);
|
||||
adapter.setNavigationTarget([0, 0]);
|
||||
const disabled = adapter.snapshot()!;
|
||||
return {
|
||||
initial: { time: initial.time, qpos: initial.qpos },
|
||||
before: {
|
||||
time: before.time,
|
||||
qpos: before.qpos,
|
||||
ctrl: before.ctrl,
|
||||
status: before.rlPolicy,
|
||||
},
|
||||
after: { time: after.time, qpos: after.qpos, ctrl: after.ctrl, status: after.rlPolicy },
|
||||
samples,
|
||||
navBefore,
|
||||
navChanged,
|
||||
sameSession: adapter.session === session,
|
||||
samePolicy: (session as unknown as { rlPolicy: object }).rlPolicy === originalPolicy,
|
||||
heldControlUnchangedOnTarget: changed.ctrl.every((v, i) => v === before.ctrl[i]),
|
||||
countBeforeTarget: before.rlPolicy!.inferenceCount,
|
||||
countAfterTarget: changed.rlPolicy!.inferenceCount,
|
||||
disabledAfterNewTarget: !disabled.rlPolicy!.enabled,
|
||||
};
|
||||
} finally {
|
||||
adapter.dispose();
|
||||
}
|
||||
},
|
||||
{ xml, policy },
|
||||
);
|
||||
const evidence = testInfo.outputPath('real-navigation-measurements.json');
|
||||
writeFileSync(evidence, JSON.stringify(result, null, 2));
|
||||
await testInfo.attach('real-navigation-measurements.json', {
|
||||
path: evidence,
|
||||
contentType: 'application/json',
|
||||
});
|
||||
expect(result.before.time).toBeGreaterThan(20);
|
||||
expect(result.before.status?.enabled).toBe(true);
|
||||
expect(result.after.status?.enabled).toBe(true);
|
||||
expect(
|
||||
Math.hypot(
|
||||
result.before.qpos[0] - result.initial.qpos[0],
|
||||
result.before.qpos[1] - result.initial.qpos[1],
|
||||
),
|
||||
).toBeGreaterThan(0.5);
|
||||
expect(result.navChanged.command).not.toEqual(result.navBefore.command);
|
||||
expect(result.navChanged.targetError).not.toEqual(result.navBefore.targetError);
|
||||
expect(result.after.ctrl).not.toEqual(result.before.ctrl);
|
||||
expect(result.after.status!.navigation!.distance).toBeLessThan(2 * Math.SQRT2);
|
||||
expect(result.countAfterTarget).toBe(result.countBeforeTarget);
|
||||
expect(result.after.status!.inferenceCount).toBeGreaterThan(result.countBeforeTarget);
|
||||
expect(result.sameSession).toBe(true);
|
||||
expect(result.samePolicy).toBe(true);
|
||||
expect(result.heldControlUnchangedOnTarget).toBe(true);
|
||||
expect(result.disabledAfterNewTarget).toBe(true);
|
||||
});
|
||||
|
||||
test('用户原始47维ONNX通过普通Flat面板加载,不新增点击导航模式', async ({ page }) => {
|
||||
const source = process.env.GO2_PRETRAINED_FLAT_POLICY;
|
||||
const robotXml = process.env.GO2_PRETRAINED_FLAT_XML;
|
||||
test.skip(!source || !robotXml, '设置原始47维ONNX与同源带actuator的机器人XML路径');
|
||||
const xml = readFileSync(robotXml!, 'utf8')
|
||||
.replace(/<mesh\b[^>]*\/>/g, '')
|
||||
.replace(/<geom\b[^>]*\bmesh="[^"]*"[^>]*\/>/g, '');
|
||||
await page.goto(process.env.GO2_PRETRAINED_DEV_URL ?? 'http://127.0.0.1:4174');
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(xml) });
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '控制台' }).click();
|
||||
const tools = page.getByRole('tabpanel', { name: '控制台' });
|
||||
const section = tools.getByRole('button', { name: /ONNX 策略运行/ });
|
||||
if ((await section.getAttribute('aria-expanded')) === 'false') await section.click();
|
||||
const chooser = page.waitForEvent('filechooser');
|
||||
await tools.getByRole('button', { name: '导入 ONNX' }).click();
|
||||
await (await chooser).setFiles(source!);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await tools.getByText('47 / 12').count()) +
|
||||
(await page.getByRole('button', { name: '技术详情', exact: true }).count()),
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
const details = page.getByRole('button', { name: '技术详情', exact: true });
|
||||
if (await details.isVisible()) {
|
||||
await details.click();
|
||||
throw new Error((await page.getByRole('alert').allTextContents()).join('\n'));
|
||||
}
|
||||
await expect(tools.getByText('47 / 12')).toBeVisible({ timeout: 30_000 });
|
||||
const enable = tools.getByRole('button', { name: '启用', exact: true });
|
||||
if (await enable.isVisible()) await enable.click();
|
||||
const play = page.getByRole('button', { name: '▶ 播放', exact: true });
|
||||
if (await play.isVisible()) await play.click();
|
||||
await expect
|
||||
.poll(async () =>
|
||||
Number(
|
||||
(await tools.getByText('推理次数', { exact: true }).locator('..').textContent())?.replace(
|
||||
/\D/g,
|
||||
'',
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBeGreaterThan(2);
|
||||
await expect(tools.getByRole('button', { name: '设定目标', exact: true })).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
const realRoot = process.env.GO2_UPLOAD_REAL_DIR;
|
||||
const token = 'local-upload-browser-test-token';
|
||||
async function unusedPort() {
|
||||
const socket = createServer();
|
||||
await new Promise<void>((done) => socket.listen(0, '127.0.0.1', done));
|
||||
const address = socket.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
await new Promise<void>((done) => socket.close(() => done()));
|
||||
return port;
|
||||
}
|
||||
for (const panel of ['ordinary', 'tuning'] as const) {
|
||||
for (const format of ['pt', 'onnx'] as const) {
|
||||
test(`${panel}真实单${format}文件:默认服务空目录上传并选择内容ID`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(!realRoot, '设置GO2_UPLOAD_REAL_DIR;Vite dev提供真实组件,启动独立默认训练服务');
|
||||
const root = mkdtempSync(join(tmpdir(), `go2-browser-upload-${panel}-${format}-`));
|
||||
const port = await unusedPort();
|
||||
const endpoint = `http://127.0.0.1:${port}`;
|
||||
const backend = spawn(
|
||||
resolve('.venv/bin/python'),
|
||||
[
|
||||
'-u',
|
||||
'training_server/server.py',
|
||||
'--port',
|
||||
String(port),
|
||||
'--token',
|
||||
token,
|
||||
'--tuning-data-root',
|
||||
root,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, DEEPSEEK_API_KEY: '', WANDB_MODE: 'disabled' },
|
||||
},
|
||||
);
|
||||
let log = '';
|
||||
backend.stdout.on('data', (data: Buffer) => {
|
||||
log += data.toString();
|
||||
});
|
||||
backend.stderr.on('data', (data: Buffer) => {
|
||||
log += data.toString();
|
||||
});
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
try {
|
||||
return (
|
||||
await fetch(`${endpoint}/api/training/health`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
).status;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(200);
|
||||
await page.goto(process.env.GO2_PRETRAINED_DEV_URL ?? 'http://127.0.0.1:4174');
|
||||
// Real production panel components, isolated from unrelated heavy scene import.
|
||||
await page.evaluate(async (panel) => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
const entry = await (await fetch('/src/main.tsx')).text();
|
||||
const reactPath = entry.match(/from "([^"]+\/react\.js[^"]*)"/)![1];
|
||||
const domPath = entry.match(/from "([^"]+\/react-dom_client\.js[^"]*)"/)![1];
|
||||
const { createElement } = (await import(reactPath)).default;
|
||||
const { createRoot } = (await import(domPath)).default;
|
||||
const host = document.createElement('div');
|
||||
host.id = 'upload-browser-panel';
|
||||
host.style.cssText =
|
||||
'position:fixed;inset:0;z-index:10000;background:#111;overflow:auto;padding:20px';
|
||||
document.body.append(host);
|
||||
if (panel === 'ordinary') {
|
||||
const path = '/src/training/LocalTrainingPanel.tsx';
|
||||
const { LocalTrainingPanel } = await import(path);
|
||||
createRoot(host).render(createElement(LocalTrainingPanel, { onPolicyReady: () => {} }));
|
||||
} else {
|
||||
const path = '/src/tuning/TuningApp.tsx';
|
||||
const { TuningApp } = await import(path);
|
||||
createRoot(host).render(createElement(TuningApp));
|
||||
}
|
||||
}, panel);
|
||||
const ui = page.locator('#upload-browser-panel');
|
||||
await ui
|
||||
.getByLabel(panel === 'ordinary' ? '本地训练服务地址' : '训练服务地址', { exact: true })
|
||||
.fill(endpoint);
|
||||
await ui
|
||||
.getByLabel(panel === 'ordinary' ? '训练服务访问令牌' : '访问令牌(仅当前标签页)', {
|
||||
exact: true,
|
||||
})
|
||||
.fill(token);
|
||||
await ui
|
||||
.getByRole('button', { name: panel === 'ordinary' ? /^连接$/ : '连接/刷新' })
|
||||
.click();
|
||||
await expect(ui.getByLabel('确认Go2 legacy47模板')).toBeEnabled();
|
||||
await expect(ui.getByLabel('基础策略', { exact: true })).toHaveValue('');
|
||||
await ui.getByLabel('确认Go2 legacy47模板').check();
|
||||
const path = join(realRoot!, format === 'pt' ? 'model_10000.pt' : 'policy.onnx');
|
||||
const sha = createHash('sha256').update(readFileSync(path)).digest('hex');
|
||||
const responsePromise = page.waitForResponse((response) =>
|
||||
response.url().startsWith(`${endpoint}/api/training/pretrained-sources/upload?`),
|
||||
);
|
||||
await ui.getByLabel('选择基础策略文件').setInputFiles(path);
|
||||
const response = await responsePromise;
|
||||
expect(response.status()).toBe(201);
|
||||
const catalog = await (
|
||||
await fetch(`${endpoint}/api/training/health`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
).json();
|
||||
const record = catalog.pretrainedSources[0];
|
||||
await expect(ui.getByLabel('基础策略', { exact: true })).toHaveValue(record.id);
|
||||
await expect(ui.getByText(/原文件 SHA256/)).toContainText(sha);
|
||||
await expect(ui.getByText(/上传格式/)).toContainText(format);
|
||||
await expect(ui.getByText(/已选择.*仅继承策略权重/)).toBeVisible();
|
||||
if (format === 'onnx')
|
||||
await expect(ui.getByText(/ONNX统计count合成/)).toContainText('1000000');
|
||||
// Intercept only train dispatch; uploads and catalog are real authenticated HTTP.
|
||||
// Never dispatch 4096-env training or an Agent/three-seed evaluation from this test.
|
||||
let submitted: Record<string, unknown> | undefined;
|
||||
await page.route(
|
||||
`${endpoint}${panel === 'ordinary' ? '/api/training/jobs' : '/api/tuning/sessions'}`,
|
||||
async (route) => {
|
||||
if (route.request().method() !== 'POST') return route.continue();
|
||||
submitted = route.request().postDataJSON() as Record<string, unknown>;
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: '浏览器验收只截获启动请求,未训练或调用Agent' }),
|
||||
});
|
||||
},
|
||||
);
|
||||
if (panel === 'tuning') await ui.getByLabel('Agent 失败时允许 Optuna fallback').check();
|
||||
await ui
|
||||
.getByRole('button', { name: panel === 'ordinary' ? '发起本地训练' : '启动自调参' })
|
||||
.click();
|
||||
await expect.poll(() => submitted?.pretrainedSourceId).toBe(record.id);
|
||||
if (panel === 'tuning') expect(submitted?.mode).toBe('approval');
|
||||
await expect(ui.getByLabel('基础策略', { exact: true })).toHaveValue(record.id);
|
||||
const health = await (
|
||||
await fetch(`${endpoint}/api/training/health`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
).json();
|
||||
expect(health.pretrainedSources).toHaveLength(1);
|
||||
expect(createHash('sha256').update(readFileSync(path)).digest('hex')).toBe(sha);
|
||||
await testInfo.attach('upload-contract', {
|
||||
body: JSON.stringify(
|
||||
{ root, endpoint, record, submitted, sourceUnchanged: true },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath('upload.png') });
|
||||
} finally {
|
||||
backend.kill('SIGTERM');
|
||||
await new Promise<void>((done) => {
|
||||
if (backend.exitCode !== null) done();
|
||||
else backend.once('exit', () => done());
|
||||
});
|
||||
writeFileSync(join(root, 'browser-server.log'), log);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user