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>) { 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>[] = []; 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('\n \n\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( '', ), }); 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((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(); } });