3ad29356c9
集成通用机器人数值接口、本机控制桥、LeRobot 插件和统一键盘遥操作。采用离线 CoACD 全臂碰撞配方 revision 4、局部装配区切分与结构自接触,限制直接关节位姿写入并保留安全看门狗。同步版本号、变更记录、来源许可证和兼容性验证。
318 lines
14 KiB
TypeScript
318 lines
14 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test';
|
|
import { resolve } from 'node:path';
|
|
import { writeFile } from 'node:fs/promises';
|
|
import { startBridge } from './fixtures/controlBridge';
|
|
|
|
async function loadWorkspace(page: Page) {
|
|
await page.goto('/');
|
|
await page
|
|
.locator('input[type="file"]')
|
|
.first()
|
|
.setInputFiles(resolve('build/lekiwi/lekiwi-v1.zip'));
|
|
const dialog = page.getByRole('dialog', { name: '配置 URDF 仿真组件' });
|
|
await expect(dialog).toBeVisible({ timeout: 30000 });
|
|
await expect(dialog.getByLabel('URDF 机器人 profile')).toHaveValue('');
|
|
await dialog.getByLabel('URDF 机器人 profile').selectOption('lekiwi-v1');
|
|
await expect(dialog.getByRole('checkbox', { name: /添加传感器/ })).toBeDisabled();
|
|
await dialog.getByRole('button', { name: '转换并加载' }).click();
|
|
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 60000 });
|
|
await page.getByRole('tab', { name: '控制台', exact: true }).click();
|
|
await expect(page.getByRole('region', { name: '开源机器人控制' })).toContainText('lekiwi-v1');
|
|
}
|
|
async function connect(page: Page, bridge: Awaited<ReturnType<typeof startBridge>>) {
|
|
await page.getByLabel('本机桥接地址', { exact: true }).fill(bridge.endpoint);
|
|
await page.getByLabel('控制 token', { exact: true }).fill(bridge.token);
|
|
await page.getByRole('button', { name: '连接桥接', exact: true }).click();
|
|
await expect(page.getByLabel('桥接连接状态', { exact: true })).toHaveText('已连接');
|
|
}
|
|
async function authorize(page: Page) {
|
|
await page.getByRole('button', { name: '▶ 播放' }).click();
|
|
await page.getByRole('button', { name: '允许外部控制', exact: true }).click();
|
|
await expect(page.getByLabel('当前控制所有者', { exact: true })).toHaveText('外部');
|
|
}
|
|
async function state(page: Page) {
|
|
return page.evaluate(async () => {
|
|
const path = '/src/stores/useAppStore.ts';
|
|
const { useAppStore } = (await import(
|
|
/* @vite-ignore */ path
|
|
)) as typeof import('../src/stores/useAppStore');
|
|
const s = useAppStore.getState();
|
|
return {
|
|
paused: s.paused,
|
|
owner: s.snapshot?.controlOwner,
|
|
time: s.snapshot?.time ?? 0,
|
|
epoch: s.snapshot?.externalControl?.modelEpoch,
|
|
sessionId: s.snapshot?.externalControl?.sessionId,
|
|
wasmHeapBytes: s.snapshot?.wasmHeapBytes,
|
|
fps: s.fps,
|
|
stepMs: s.stepMs,
|
|
memoryMb: s.memoryMb,
|
|
overBudget: s.overBudget,
|
|
actuators: s.snapshot?.actuators.map((a) => ({ name: a.jointName, target: a.value })),
|
|
joints: s.snapshot?.joints.map((j) => ({ name: j.name, value: j.value })),
|
|
diagnostic: s.diagnostic?.summary,
|
|
};
|
|
});
|
|
}
|
|
|
|
test('完整工作台 + 真实 LeRobot:文件导入、profile、授权和60秒30Hz控制', async ({ page }, info) => {
|
|
const bridge = await startBridge(),
|
|
errors: string[] = [];
|
|
page.on('pageerror', (error) => errors.push(error.message));
|
|
try {
|
|
await loadWorkspace(page);
|
|
// Profile pose editing is disabled even before an external controller owns it.
|
|
await expect(page.getByRole('button', { name: '关节拖动', exact: true })).toBeDisabled();
|
|
await connect(page, bridge);
|
|
await authorize(page);
|
|
await expect(page.getByLabel('仿真速度', { exact: true })).toBeDisabled();
|
|
await expect(page.getByLabel('仿真速度', { exact: true })).toHaveValue('1');
|
|
await expect(page.getByRole('button', { name: '关节拖动', exact: true })).toBeDisabled();
|
|
const consolePanel = page.getByRole('tabpanel', { name: '控制台' });
|
|
await consolePanel.getByRole('button', { name: /执行器实时控制/ }).click();
|
|
await expect(consolePanel.getByRole('slider').first()).toBeDisabled();
|
|
await consolePanel.getByRole('button', { name: /执行器实时控制/ }).click();
|
|
const metrics: Awaited<ReturnType<typeof state>>[] = [];
|
|
let done = false;
|
|
const run = bridge
|
|
.python(
|
|
`
|
|
import os, runpy, sys
|
|
sys.argv=['demo_control.py','--duration','60']
|
|
runpy.run_path(os.environ['LEKIWI_DEMO'],run_name='__main__')
|
|
`,
|
|
process.env.LEROBOT_PYTHON ?? resolve('build/venvs/lerobot/bin/python'),
|
|
{ LEKIWI_DEMO: resolve('examples/lekiwi/demo_control.py') },
|
|
)
|
|
.finally(() => {
|
|
done = true;
|
|
});
|
|
const monitor = (async () => {
|
|
while (!done) {
|
|
await page.waitForTimeout(1000);
|
|
metrics.push(await state(page));
|
|
}
|
|
})();
|
|
const [result] = await Promise.all([run, monitor]).catch(async (error: unknown) => {
|
|
const path = info.outputPath('partial-metrics.json');
|
|
await writeFile(path, JSON.stringify(metrics));
|
|
await info.attach('partial-metrics.json', { path, contentType: 'application/json' });
|
|
throw error;
|
|
});
|
|
const report = JSON.parse(result.stdout.trim());
|
|
const path = info.outputPath('lekiwi-60s.json');
|
|
const graphics = await page.evaluate(() => {
|
|
const canvas = document.querySelector('main canvas') as HTMLCanvasElement;
|
|
const gl = canvas.getContext('webgl2')!,
|
|
ext = gl.getExtension('WEBGL_debug_renderer_info');
|
|
return {
|
|
size: [canvas.width, canvas.height],
|
|
renderer: ext ? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)) : 'unknown',
|
|
};
|
|
});
|
|
await writeFile(path, JSON.stringify({ report, metrics, graphics }, null, 2));
|
|
await info.attach('lekiwi-60s.json', { path, contentType: 'application/json' });
|
|
console.log('LeKiwi 60s evidence', report);
|
|
expect(report.steps).toBe(1800);
|
|
expect(report.elapsedSeconds).toBeGreaterThanOrEqual(60);
|
|
expect(report.elapsedSeconds).toBeLessThan(63);
|
|
expect(report.simSeconds).toBeGreaterThan(55);
|
|
expect(report.maxTranslationM).toBeGreaterThan(0.05);
|
|
expect(report.rttMaxMs).toBeLessThan(500);
|
|
expect(report.maxObservationSimGap).toBeLessThan(0.5);
|
|
expect(report.timeouts).toBe(0);
|
|
expect(report.droppedRequests).toBe(0);
|
|
expect(report.appliedActionSeq).toBeGreaterThan(1790);
|
|
expect(metrics.every((m) => Number.isFinite(m.time) && !m.diagnostic)).toBe(true);
|
|
const heaps = metrics.slice(10).flatMap((m) => (m.memoryMb === undefined ? [] : [m.memoryMb]));
|
|
if (heaps.length > 10) expect(heaps.at(-1)! - heaps[0]).toBeLessThan(64);
|
|
const wasm = metrics.slice(10).map((m) => m.wasmHeapBytes!);
|
|
expect(wasm.every((v) => Number.isFinite(v) && v > 0)).toBe(true);
|
|
expect(wasm.at(-1)! - wasm[0]).toBeLessThan(16 * 1024 * 1024);
|
|
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
|
|
await expect(page.getByLabel('当前控制所有者', { exact: true })).toHaveText('手动');
|
|
await page.screenshot({ path: info.outputPath('lekiwi-workspace.png') });
|
|
expect(bridge.errors()).toBe('');
|
|
expect(errors).toEqual([]);
|
|
} finally {
|
|
await page.close();
|
|
await bridge.stop();
|
|
}
|
|
});
|
|
|
|
test('工作台重编译与缓存编辑失败回滚不会恢复外部授权', async ({ page }) => {
|
|
page.setDefaultTimeout(15000);
|
|
const bridge = await startBridge();
|
|
try {
|
|
await loadWorkspace(page);
|
|
await connect(page, bridge);
|
|
await authorize(page);
|
|
const old = await state(page);
|
|
const driving = bridge.python(
|
|
genericDrive +
|
|
`
|
|
try:
|
|
for i in range(900):
|
|
robot.send_action(values);time.sleep(1/30)
|
|
raise AssertionError('reload must disconnect the controller')
|
|
except RobotError as error:
|
|
print(json.dumps({'stopped':error.code}))
|
|
finally:
|
|
robot.disconnect()
|
|
`,
|
|
);
|
|
await expect(page.getByLabel('外部控制状态', { exact: true })).toHaveText('外部控制者已连接');
|
|
await page.getByRole('button', { name: '校验并重新编译 profile' }).click();
|
|
await expect.poll(async () => (await state(page)).sessionId).not.toBe(old.sessionId);
|
|
expect(JSON.parse((await driving).stdout.trim()).stopped).toBeTruthy();
|
|
await expect(page.getByLabel('桥接连接状态', { exact: true })).toHaveText('未连接');
|
|
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
|
|
await connect(page, bridge);
|
|
await authorize(page);
|
|
const before = await state(page);
|
|
await page.getByRole('button', { name: '源代码', exact: true }).click();
|
|
const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
|
|
const editor = dialog.locator('.monaco-editor');
|
|
await editor.click({ position: { x: 240, y: 120 } });
|
|
await page.keyboard.press('Control+a');
|
|
await page.keyboard.press('Backspace');
|
|
await page.keyboard.insertText('<mujoco>\n <worldbody/>\n</mujoco>\n');
|
|
await dialog.getByRole('button', { name: '保存并重新载入', exact: true }).click();
|
|
await expect.poll(async () => (await state(page)).diagnostic).toBeTruthy();
|
|
expect(await state(page)).toMatchObject({
|
|
sessionId: before.sessionId,
|
|
paused: true,
|
|
owner: 'manual',
|
|
});
|
|
await dialog.getByRole('button', { name: '关闭源代码编辑器' }).click();
|
|
await expect(page.getByLabel('桥接连接状态', { exact: true })).toHaveText('未连接');
|
|
await expect(page.getByLabel('外部控制状态', { exact: true })).toHaveText('未授权');
|
|
} finally {
|
|
await bridge.stop();
|
|
}
|
|
});
|
|
|
|
test('普通模型不自动套用 profile,错误 profile 保留原场景', async ({ page }) => {
|
|
await page.goto('/');
|
|
await page
|
|
.locator('input[type="file"]')
|
|
.first()
|
|
.setInputFiles({
|
|
name: 'generic.xml',
|
|
mimeType: 'text/xml',
|
|
buffer: Buffer.from(
|
|
'<mujoco><worldbody><body><joint name="j" type="slide" axis="1 0 0"/><geom type="sphere" size=".1"/></body></worldbody><actuator><motor joint="j"/></actuator></mujoco>',
|
|
),
|
|
});
|
|
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
|
|
await page.getByRole('tab', { name: '控制台', exact: true }).click();
|
|
await page.getByRole('button', { name: '开源项目 / 外部控制' }).click();
|
|
await expect(page.getByLabel('控制接口 profile', { exact: true })).toHaveValue('');
|
|
await expect(page.getByRole('button', { name: '连接桥接', exact: true })).toBeDisabled();
|
|
const before = await state(page);
|
|
await page.getByLabel('控制接口 profile', { exact: true }).selectOption('lekiwi-v1');
|
|
await page.getByRole('button', { name: '校验并重新编译 profile' }).click();
|
|
await expect.poll(async () => (await state(page)).diagnostic).toBeTruthy();
|
|
expect((await state(page)).actuators).toEqual(before.actuators);
|
|
await expect(page.getByRole('button', { name: '连接桥接', exact: true })).toBeDisabled();
|
|
});
|
|
|
|
const genericDrive = `
|
|
import json, os, time
|
|
from mujoco_control_bridge import SimRobotClient, RobotError
|
|
robot=SimRobotClient(os.environ['MUJOCO_CONTROL_ENDPOINT']);robot.connect()
|
|
values={c['id']:0 for c in robot.describe()['actionChannels']}
|
|
values.update({'arm_shoulder_pan.pos':.2,'arm_gripper.pos':.7,'x.vel':.1})
|
|
`;
|
|
|
|
test('工作台安全生命周期:真实 reset、新 epoch、控制者崩溃、页面隐藏与冻结恢复', async ({
|
|
page,
|
|
}) => {
|
|
const bridge = await startBridge();
|
|
try {
|
|
await loadWorkspace(page);
|
|
await connect(page, bridge);
|
|
await authorize(page);
|
|
const reset = await bridge.python(
|
|
genericDrive +
|
|
`
|
|
robot.send_action(values)
|
|
result=robot.reset()
|
|
print(json.dumps({'epoch':result['modelEpoch'],'paused':result['paused']}))
|
|
`,
|
|
);
|
|
expect(JSON.parse(reset.stdout.trim())).toEqual({ epoch: 1, paused: true });
|
|
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
|
|
await expect(page.getByLabel('桥接连接状态', { exact: true })).toHaveText('已连接');
|
|
expect((await state(page)).epoch).toBe(1);
|
|
await authorize(page);
|
|
await bridge.python(
|
|
genericDrive +
|
|
`
|
|
robot.send_action(values)
|
|
os._exit(0) # Deliberately crash without a finalizer; watchdog must stop it.
|
|
`,
|
|
);
|
|
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 3000 });
|
|
const stopped = await state(page);
|
|
expect(stopped.owner).toBe('manual');
|
|
expect(
|
|
stopped.actuators?.filter((a) => a.name?.startsWith('base_')).map((a) => a.target),
|
|
).toEqual([0, 0, 0]);
|
|
const held = stopped.actuators?.find((a) => a.name === 'arm_shoulder_pan')?.target;
|
|
expect(held).toBeGreaterThan(0.05);
|
|
expect(held).toBeCloseTo(stopped.joints!.find((j) => j.name === 'arm_shoulder_pan')!.value, 8);
|
|
await authorize(page);
|
|
await page.evaluate(() => {
|
|
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
|
|
document.dispatchEvent(new Event('visibilitychange'));
|
|
});
|
|
await expect(page.getByLabel('桥接连接状态', { exact: true })).toHaveText('未连接');
|
|
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
|
|
await page.evaluate(() => {
|
|
Reflect.deleteProperty(document, 'visibilityState');
|
|
document.dispatchEvent(new Event('visibilitychange'));
|
|
});
|
|
await expect(page.getByLabel('外部控制状态', { exact: true })).toHaveText('未授权');
|
|
await connect(page, bridge);
|
|
await authorize(page);
|
|
const driving = bridge.python(
|
|
genericDrive +
|
|
`
|
|
try:
|
|
for i in range(900):
|
|
robot.send_action(values);time.sleep(1/30)
|
|
raise AssertionError('Frozen browser must revoke the lease')
|
|
except RobotError as error:
|
|
print(json.dumps({'stopped':error.code}))
|
|
finally:
|
|
robot.disconnect()
|
|
`,
|
|
);
|
|
await expect(page.getByLabel('外部控制状态', { exact: true })).toHaveText('外部控制者已连接');
|
|
const before = await state(page),
|
|
cdp = await page.context().newCDPSession(page);
|
|
await cdp.send('Debugger.enable');
|
|
const paused = new Promise<void>((resolve) => cdp.once('Debugger.paused', () => resolve()));
|
|
await cdp.send('Debugger.pause');
|
|
await paused;
|
|
try {
|
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
} finally {
|
|
await cdp.send('Debugger.resume');
|
|
await cdp.detach();
|
|
}
|
|
const frozen = await driving;
|
|
expect(JSON.parse(frozen.stdout.trim()).stopped).toBeTruthy();
|
|
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
|
|
const after = await state(page);
|
|
expect(after.time - before.time).toBeLessThan(0.3);
|
|
expect(
|
|
after.actuators?.filter((a) => a.name?.startsWith('base_')).map((a) => a.target),
|
|
).toEqual([0, 0, 0]);
|
|
} finally {
|
|
await page.close();
|
|
await bridge.stop();
|
|
}
|
|
});
|