Files
Mujoco_WASM/web_platform/e2e/lekiwi.workspace.spec.ts
T
chenlin 3ad29356c9
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
feat(lekiwi): release V0.10.1 初步集成 LeKiwi,优化碰撞模型
集成通用机器人数值接口、本机控制桥、LeRobot 插件和统一键盘遥操作。采用离线 CoACD 全臂碰撞配方 revision 4、局部装配区切分与结构自接触,限制直接关节位姿写入并保留安全看门狗。同步版本号、变更记录、来源许可证和兼容性验证。
2026-09-20 14:42:30 +08:00

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();
}
});