f3a8a38acd
web-platform-ci / Standalone decision service (no cloud credentials) (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled
web-platform-ci / Standalone decision service (no cloud credentials) (pull_request) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
lekiwi-compatibility / cpu-compatibility (pull_request) Has been cancelled
集成同源 BYOK 会话隔离、精简模型设置、官方订阅入口和 HTTPS 发布运维;保留本地训练/调参与控制能力。同步 npm 版本及 CHANGELOG,记录公网真实 API 验收仍待用户凭据。
188 lines
8.2 KiB
TypeScript
188 lines
8.2 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import { spawn } from 'node:child_process';
|
|
import { createServer } from 'node:net';
|
|
import { existsSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
|
|
const mobilePython = process.env.MOBILE_TRAINING_PYTHON ?? resolve('build/venvs/mobile/bin/python');
|
|
const bundle =
|
|
process.env.MOBILE_BUNDLE_ZIP ??
|
|
resolve('../Reference_Projects/LeKiwi/New_urdf/robot_urdf_bundle (1).zip');
|
|
for (const [taskId, file] of [
|
|
['MobileManipulator-LeKiwi-v1', resolve('build/lekiwi/lekiwi-v1.zip')],
|
|
['MobileManipulator-LeKiwi-Bundle', bundle],
|
|
]) {
|
|
test(`主控制台真实一键训练及策略导入:${taskId}`, async ({ page }, info) => {
|
|
test.skip(
|
|
!existsSync(mobilePython) || !existsSync(file),
|
|
'需要 MuJoCo 3.11/SB3 独立环境及机器人资产',
|
|
);
|
|
const port = await new Promise<number>((resolvePort) => {
|
|
const listener = createServer();
|
|
listener.listen(0, '127.0.0.1', () => {
|
|
const port = (listener.address() as { port: number }).port;
|
|
listener.close(() => resolvePort(port));
|
|
});
|
|
});
|
|
const root = info.outputPath('training');
|
|
await mkdir(root, { recursive: true });
|
|
const token = 'oneclick-test-token';
|
|
const endpoint = `http://127.0.0.1:${port}`;
|
|
const process = spawn(
|
|
resolve('.venv/bin/python'),
|
|
[
|
|
'-u',
|
|
'training_server/server.py',
|
|
'--port',
|
|
String(port),
|
|
'--trainer-root',
|
|
root,
|
|
'--mobile-python',
|
|
mobilePython,
|
|
'--token',
|
|
token,
|
|
],
|
|
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
);
|
|
let output = '';
|
|
process.stdout.on('data', (data) => {
|
|
output += String(data);
|
|
});
|
|
process.stderr.on('data', (data) => {
|
|
output += String(data);
|
|
});
|
|
const errors: string[] = [];
|
|
page.on('pageerror', (error) => errors.push(error.message));
|
|
try {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
try {
|
|
return (
|
|
await fetch(`${endpoint}/api/training/health`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
})
|
|
).ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
{ timeout: 30000 },
|
|
)
|
|
.toBe(true);
|
|
await page.goto('/');
|
|
await page.locator('input[type="file"]').first().setInputFiles(file);
|
|
const dialog = page.getByRole('dialog', { name: '配置 URDF 仿真组件' });
|
|
await expect(dialog).toBeVisible({ timeout: 30000 });
|
|
// v1 also covers migration from the existing external-control profile at start.
|
|
await dialog
|
|
.getByLabel('URDF 机器人 profile')
|
|
.selectOption(taskId === 'MobileManipulator-LeKiwi-v1' ? 'lekiwi-v1' : taskId);
|
|
await dialog.getByRole('button', { name: '转换并加载' }).click();
|
|
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 60000 });
|
|
await page.getByRole('tab', { name: '控制台', exact: true }).click();
|
|
if (taskId === 'MobileManipulator-LeKiwi-v1') {
|
|
await page.getByRole('button', { name: 'LeKiwi 智能抓放', exact: true }).click();
|
|
const agent = page.getByLabel('LeKiwi 智能抓放任务', { exact: true });
|
|
await agent.getByRole('button', { name: '单技能步进', exact: true }).click();
|
|
await expect(agent.getByTestId('agent-task-state')).toContainText('任务已暂停', {
|
|
timeout: 60000,
|
|
});
|
|
}
|
|
await page.getByRole('button', { name: '强化学习任务', exact: true }).click();
|
|
await page.getByLabel('本地训练服务地址').fill(endpoint);
|
|
await page.getByLabel('训练服务访问令牌').fill(token);
|
|
await page.getByRole('button', { name: '连接', exact: true }).click();
|
|
await expect(page.getByLabel('训练任务')).toHaveValue(taskId);
|
|
await page.getByLabel('训练迭代', { exact: true }).fill('2');
|
|
await page.getByLabel('每环境采样步数').fill('8');
|
|
await expect(page.getByLabel('移动操作训练阶段')).toHaveValue('navigate');
|
|
await page.getByLabel('独立评估回合').fill('2');
|
|
await page.getByLabel('导航启动示教步数(仅初训)').fill('0');
|
|
const jobResponse = page.waitForResponse(
|
|
(response) =>
|
|
response.url() === `${endpoint}/api/training/jobs` &&
|
|
response.request().method() === 'POST',
|
|
);
|
|
await page.getByRole('button', { name: '发起本地训练' }).click();
|
|
const created = (await (await jobResponse).json()) as { id: string };
|
|
await expect(page.getByRole('button', { name: '导入策略', exact: true })).toBeEnabled({
|
|
timeout: 60000,
|
|
});
|
|
await page.getByRole('button', { name: '导入策略', exact: true }).click();
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
page.evaluate(async () => {
|
|
const path = '/src/stores/useAppStore.ts';
|
|
const { useAppStore } = (await import(
|
|
/* @vite-ignore */ path
|
|
)) as typeof import('../src/stores/useAppStore');
|
|
return useAppStore.getState().snapshot?.rlPolicy?.inferenceCount ?? 0;
|
|
}),
|
|
{ timeout: 30000 },
|
|
)
|
|
.toBeGreaterThan(0);
|
|
const job = await (
|
|
await fetch(`${endpoint}/api/training/jobs/${created.id}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
})
|
|
).json();
|
|
expect(job.state).toBe('succeeded');
|
|
expect(job.logs.join('\n')).toContain('Mean reward:');
|
|
expect(job.deployment.input.shape).toEqual([1, 92]);
|
|
expect(job.deployment.trainingStage).toBe('navigate');
|
|
expect(job.deployment.evaluation.episodes).toBe(2);
|
|
expect(job.deployment.evaluation.safetyStops).toBe(0);
|
|
expect(job.deployment.evaluation.maxJointVelocity).toBeLessThan(2);
|
|
expect(job.deployment.output.shape).toEqual([1, 12]);
|
|
if (taskId === 'MobileManipulator-LeKiwi-v1') {
|
|
const scenes = (await readdir(root, { recursive: true })).filter((name) =>
|
|
name.endsWith('.training.xml'),
|
|
);
|
|
expect(scenes.length).toBeGreaterThan(0);
|
|
for (const scene of scenes)
|
|
expect(await readFile(resolve(root, scene), 'utf8')).not.toContain('__agent_');
|
|
await expect(page.getByTestId('agent-task-state')).toContainText('已取消');
|
|
}
|
|
await writeFile(info.outputPath('job.json'), JSON.stringify(job, null, 2));
|
|
await page.screenshot({ path: info.outputPath('unified-training.png') });
|
|
expect(errors).toEqual([]);
|
|
if (taskId === 'MobileManipulator-LeKiwi-v1') {
|
|
await page.getByRole('button', { name: '接续此作业(保留权重)' }).click();
|
|
await expect(page.getByLabel('接续作业 ID')).toHaveValue(created.id);
|
|
const resumedResponse = page.waitForResponse(
|
|
(response) =>
|
|
response.url() === `${endpoint}/api/training/jobs` &&
|
|
response.request().method() === 'POST',
|
|
);
|
|
await page.getByRole('button', { name: '发起本地训练' }).click();
|
|
const response = await resumedResponse;
|
|
expect(response.status()).toBe(202);
|
|
const resumed = await response.json();
|
|
await expect(page.getByRole('button', { name: '导入策略', exact: true })).toBeEnabled({
|
|
timeout: 60000,
|
|
});
|
|
const second = await (
|
|
await fetch(`${endpoint}/api/training/jobs/${resumed.id}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
})
|
|
).json();
|
|
expect(second.state).toBe('succeeded');
|
|
expect(second.deployment.initialization).toBe('resumed-checkpoint');
|
|
expect(second.deployment.trainedTimesteps).toBeGreaterThan(job.deployment.trainedTimesteps);
|
|
await writeFile(info.outputPath('resumed-job.json'), JSON.stringify(second, null, 2));
|
|
}
|
|
} finally {
|
|
await page.close();
|
|
process.kill('SIGTERM');
|
|
await new Promise<void>((resolveExit) => {
|
|
if (process.exitCode !== null) resolveExit();
|
|
else process.once('exit', () => resolveExit());
|
|
});
|
|
await writeFile(info.outputPath('server.log'), output.replaceAll(token, '<redacted>'));
|
|
}
|
|
});
|
|
}
|