import { expect, test } from '@playwright/test'; import { resolve } from 'node:path'; import { existsSync } from 'node:fs'; import { writeFile } from 'node:fs/promises'; import { startBridge } from './fixtures/controlBridge'; import type {} from '../physics/runner'; const python = process.env.LEROBOT_PYTHON ?? resolve('build/venvs/lerobot/bin/python'); test('真实终端输入:底盘与六路臂/夹爪点动、反向、保持和退出撤权', async ({ page }, info) => { expect(existsSync(python), '需要真实的独立 LeRobot 环境').toBe(true); const bridge = await startBridge(); try { await page.goto('/physics/runner.html'); await page.waitForFunction(() => Boolean(window.lekiwiPhysics)); await page.evaluate( (path) => window.lekiwiPhysics.boot(path), `/@fs${resolve('build/lekiwi')}`, ); await page.evaluate( ({ endpoint, token }) => window.lekiwiPhysics.connectBridge(endpoint, token), { endpoint: bridge.endpoint, token: bridge.token }, ); const result = await bridge.python( ` import json, os, pty, select, subprocess, sys, termios, time from urllib.request import Request, urlopen def observe(): request=Request(os.environ['MUJOCO_CONTROL_ENDPOINT']+'/api/control/v1/observation', headers={'Authorization':'Bearer '+os.environ['MUJOCO_CONTROL_TOKEN']}) with urlopen(request,timeout=2) as response: return json.load(response) master,slave=pty.openpty() original_terminal=termios.tcgetattr(slave) # This checks free-space hold, not servo compliance under self contact. The # complete CAD assembly now blocks shoulder_lift near .12rad at this posture. # Use 5 deg/s for this free-space test; keep hold/reversal thresholds unchanged. # armCollision/fullCollision.spec separately exercise physical obstruction. process=subprocess.Popen([sys.executable,'-u',os.environ['KEYBOARD_DEMO'],'--arm-speed','5'],stdin=slave,stdout=subprocess.PIPE,stderr=subprocess.PIPE) try: assert select.select([process.stdout],[],[],10)[0], 'terminal demo did not start' assert 'w/s' in process.stdout.readline().decode() initial=observe() for _ in range(10): os.write(master,b'wuiotyv');time.sleep(.1) # No input for longer than the watchdog: base stops, arm holds, actions continue. time.sleep(.35) held=observe() time.sleep(.4) held_again=observe() for _ in range(6): os.write(master,b'jklghb');time.sleep(.1) os.write(master,b' ') time.sleep(.35) reversed_pose=observe() os.write(master,b'q') _,err=process.communicate(timeout=5) assert process.returncode==0,err.decode() assert termios.tcgetattr(slave)==original_terminal, 'terminal settings were not restored' print(json.dumps({'exit':process.returncode,'initial':initial,'held':held, 'heldAgain':held_again,'reversed':reversed_pose})) finally: if process.poll() is None: process.kill();process.wait() os.close(master);os.close(slave) `, python, { KEYBOARD_DEMO: resolve('examples/lekiwi/teleoperate_sim.py') }, ); const report = JSON.parse(result.stdout.trim()); const reportPath = info.outputPath('keyboard-teleop.json'); await writeFile(reportPath, JSON.stringify(report, null, 2)); await info.attach('keyboard-teleop.json', { path: reportPath, contentType: 'application/json', }); expect(report.exit).toBe(0); expect(report.held.values['base.x'] - report.initial.values['base.x']).toBeGreaterThan(0.01); expect(Math.abs(report.heldAgain.values['x.vel'])).toBeLessThan(0.02); expect(report.heldAgain.appliedActionSeq).toBeGreaterThan(report.held.appliedActionSeq); for (const joint of [ 'arm_shoulder_pan', 'arm_shoulder_lift', 'arm_elbow_flex', 'arm_wrist_flex', 'arm_wrist_roll', 'arm_gripper', ]) { const key = `${joint}.pos`; expect(report.held.values[key] - report.initial.values[key], `${joint} 正向`).toBeGreaterThan( 0.03, ); expect( Math.abs(report.heldAgain.values[key] - report.held.values[key]), `${joint} 保持`, ).toBeLessThan(0.03); expect( report.heldAgain.values[key] - report.reversed.values[key], `${joint} 反向`, ).toBeGreaterThan(0.03); } await expect .poll( async () => (await page.evaluate(() => window.lekiwiPhysics.externalState())).observation?.paused, ) .toBe(true); const final = await page.evaluate(() => window.lekiwiPhysics.externalState()); expect(final.control).toMatchObject({ enabled: false, connected: false }); expect(bridge.errors()).toBe(''); } finally { await page.evaluate(() => window.lekiwiPhysics?.dispose()).catch(() => {}); await bridge.stop(); } }); test('真实 LeRobot 0.6.1 工厂/插件:无硬件 demo 与实际部分动作/限幅回传', async ({ page }) => { expect( existsSync(python), '请先创建隔离 LeRobot 环境或设置 LEROBOT_PYTHON;兼容门槛不能 skipped', ).toBe(true); const bridge = await startBridge(); try { await page.goto('/physics/runner.html'); await page.waitForFunction(() => Boolean(window.lekiwiPhysics)); await page.evaluate( (path) => window.lekiwiPhysics.boot(path), `/@fs${resolve('build/lekiwi')}`, ); await page.evaluate( ({ endpoint, token }) => window.lekiwiPhysics.connectBridge(endpoint, token), { endpoint: bridge.endpoint, token: bridge.token }, ); const run = await bridge.python( ` import os, runpy, sys sys.argv=['demo_control.py','--duration','3'] runpy.run_path(os.environ['LEKIWI_DEMO'],run_name='__main__') `, python, { LEKIWI_DEMO: resolve('examples/lekiwi/demo_control.py') }, ); const result = JSON.parse(run.stdout.trim()); expect(result.lerobot).toBe('0.6.1'); expect(result.steps).toBe(90); expect(result.maxTranslationM).toBeGreaterThan(0.05); expect(result.timeouts).toBe(0); expect(Object.keys(result.observation)).toHaveLength(9); await expect .poll( async () => (await page.evaluate(() => window.lekiwiPhysics.externalState())).observation?.paused, ) .toBe(true); // The previous demo paused the simulation. An old paused sample must explain // the missing play step, not misreport it as a running simulation freeze. const paused = await bridge.python( ` import json, os, time from mujoco_control_bridge import RobotError from lerobot_robot_mujoco import LeKiwiSim, LeKiwiSimConfig time.sleep(.6) robot=LeKiwiSim(LeKiwiSimConfig(endpoint=os.environ['MUJOCO_CONTROL_ENDPOINT'])) try: robot.connect() except RobotError as error: assert not robot.is_connected print(json.dumps({'code':error.code,'message':str(error)})) else: robot.disconnect() raise AssertionError('paused simulation must refuse a new controller') `, python, ); expect(JSON.parse(paused.stdout.trim())).toMatchObject({ code: 'PAUSED', message: expect.stringContaining('播放'), }); await page.evaluate(() => window.lekiwiPhysics.authorize()); const partial = await bridge.python( ` import json, os, torch, sys from lerobot.utils.import_utils import register_third_party_plugins from lerobot.robots.config import RobotConfig from lerobot.robots.utils import make_robot_from_config register_third_party_plugins() robot=make_robot_from_config(RobotConfig.get_choice_class('lekiwi_sim')(endpoint=os.environ['MUJOCO_CONTROL_ENDPOINT'])) robot.connect() try: first=robot.send_action({'arm_shoulder_pan.pos':180,'arm_gripper.pos':150,'x.vel':.1}) second=robot.send_action({'arm_shoulder_lift.pos':5}) observed=robot.get_observation() assert not torch.cuda.is_initialized() assert not any(m in sys.modules for m in ('serial','zmq','scservo_sdk','pyrealsense2')) print(json.dumps({'first':first,'second':second,'observed':observed})) finally: robot.disconnect() `, python, ); const values = JSON.parse(partial.stdout.trim()); expect(values.first['arm_gripper.pos']).toBe(100); expect(values.first['arm_shoulder_pan.pos']).toBeCloseTo((1.57 * 180) / Math.PI); expect(values.second['arm_shoulder_pan.pos']).toBe(values.first['arm_shoulder_pan.pos']); expect(values.second['x.vel']).toBe(0); expect(values.second['arm_gripper.pos']).toBe(100); expect( Math.abs(values.observed['arm_shoulder_pan.pos'] - values.first['arm_shoulder_pan.pos']), ).toBeGreaterThan(1); expect(bridge.errors()).toBe(''); } finally { await page.evaluate(() => window.lekiwiPhysics?.dispose()).catch(() => {}); await bridge.stop(); } });