Files
Mujoco_WASM/web_platform/e2e/app.spec.ts
T
chenlin f4b415c54f
web-platform-ci / TypeScript、Lint、Unit、Build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) 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
refactor(web-platform): release V0.6 精简代码
2026-08-28 14:10:16 +08:00

274 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {expect, test} from '@playwright/test';
import {readFileSync} from 'node:fs';
import {fileURLToPath} from 'node:url';
import {zipSync} from 'fflate';
const fixture = (relative: string) => fileURLToPath(new URL(`../fixtures/${relative}`, import.meta.url));
const SIMPLE_MODEL = `
<mujoco model="e2e">
<compiler angle="radian"/>
<worldbody>
<light pos="0 0 3"/>
<body name="box" pos="0 0 1">
<joint name="slide" type="slide" axis="1 0 0" range="-1 1"/>
<joint name="hinge" type="hinge" axis="0 1 0" range="-1.57079632679 1.57079632679"/>
<geom name="box_geom" type="box" size=".2 .2 .2" mass="1"/>
</body>
</worldbody>
<actuator><motor name="motor" joint="slide" ctrlrange="-2 2"/></actuator>
</mujoco>`;
const SLIDE_DIRECTION_MODEL=`<mujoco model="drag-direction"><worldbody><body name="slider" pos="0 0 1"><joint name="screen_x" type="slide" axis="1 0 0" range="-2 2"/><geom type="box" size=".25 .25 .25" mass="1"/></body></worldbody></mujoco>`;
const LARGE_MODEL = `
<mujoco model="soak">
<option timestep=".005"/>
<worldbody>
<replicate count="6" offset=".12 0 0">
<replicate count="6" offset="0 .12 0">
<replicate count="6" offset="0 0 .12">
<body pos="-.3 -.3 .5"><joint type="free"/><geom type="sphere" size=".03" mass=".01"/></body>
</replicate>
</replicate>
</replicate>
<geom type="plane" size="2 2 .1"/>
</worldbody>
</mujoco>`;
test('显示中文平台骨架并加载单文件模型', async ({page}) => {
page.on('console', (message) => console.log(`[browser:${message.type()}] ${message.text()}`));
page.on('pageerror', (error) => console.log(`[browser:error] ${error.message}`));
page.on('requestfailed', (request) => console.log(`[browser:requestfailed] ${request.url()} ${request.failure()?.errorText}`));
page.on('response', (response) => { if (response.status() >= 400 || response.url().endsWith('.wasm')) console.log(`[browser:response] ${response.status()} ${response.url()} ${response.headers()['content-type'] ?? ''}`); });
await page.goto('/');
await page.setViewportSize({width:1024,height:768});
const resetCameraBox=await page.getByRole('button',{name:'相机复位'}).boundingBox(),playBox=await page.getByRole('button',{name:'▶ 播放'}).boundingBox();
expect(resetCameraBox&&playBox&&resetCameraBox.x+resetCameraBox.width<=playBox.x).toBeTruthy();
await page.getByRole('button',{name:'更多工作台操作'}).click();await expect(page.getByRole('menuitem',{name:'工作台设置'})).toBeVisible();await page.keyboard.press('Escape');
await page.setViewportSize({width:1440,height:900});
await expect(page.getByRole('heading', {name: 'MuJoCo Web 仿真平台'})).toBeVisible();
await expect(page.getByRole('main').getByText('拖放模型工程到此处')).toBeVisible();
await expect(page.getByRole('img',{name:'XYZ 方向指示器'})).toBeVisible();
await expect(page.getByRole('button',{name:'切换到白天主题'})).toBeVisible();
await page.getByRole('button',{name:'布局设置'}).click();await expect(page.getByRole('dialog',{name:'布局设置'})).toBeVisible();await page.keyboard.press('Escape');
await page.getByRole('button',{name:'工作台设置'}).click();await expect(page.getByRole('dialog',{name:'工作台设置'})).toBeVisible();await page.keyboard.press('Escape');
await page.keyboard.press('Control+k');
await expect(page.getByRole('dialog',{name:'命令面板'})).toBeVisible();
await page.getByLabel('搜索命令').fill('复位相机');
await expect(page.getByRole('option',{name:/复位相机/})).toBeVisible();
await page.keyboard.press('Escape');
await page.getByRole('button',{name:'进入全屏'}).click();
await expect(page.getByRole('button',{name:'退出全屏'})).toBeVisible();
await page.keyboard.press('Control+k');
await expect(page.getByRole('dialog',{name:'命令面板'})).toBeVisible();
await page.keyboard.press('Escape');
await page.getByRole('button',{name:'退出全屏'}).click();
await page.getByRole('button',{name:'切换到白天主题'}).click();
await expect(page.locator('#root > div')).toHaveClass(/theme-light/);
await expect(page.getByRole('button',{name:'切换到黑夜主题'})).toBeVisible();
await page.getByRole('button',{name:'切换到黑夜主题'}).click();
await expect(page.locator('#root > div')).toHaveClass(/theme-dark/);
await page.locator('input[type="file"]').first().setInputFiles({
name: 'model.xml',
mimeType: 'text/xml',
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
await page.getByRole('button',{name:'显示设置'}).click();
const displayDialog=page.getByRole('dialog',{name:'视图显示设置'});await expect(displayDialog).toBeVisible();await expect(displayDialog.getByRole('switch')).toHaveCount(7);
const centerOfMassSwitch=displayDialog.getByRole('switch',{name:/^质心/});await centerOfMassSwitch.click();await expect(centerOfMassSwitch).toHaveAttribute('aria-checked','true');await page.keyboard.press('Escape');await expect(displayDialog).toBeHidden();
await page.getByRole('button',{name:'通知中心'}).click();await expect(page.getByRole('dialog',{name:'通知中心'})).toContainText('模型加载完成');await page.getByText('事件日志').click();await expect(page.getByRole('dialog',{name:'诊断与事件日志'})).toBeVisible();await page.keyboard.press('Escape');
await page.getByRole('button',{name:/FPS .*物理/}).click();
await expect(page.getByRole('dialog',{name:'性能详情'})).toBeVisible();
await page.keyboard.press('Escape');
await page.getByRole('tab',{name:'控制'}).click();
await page.getByRole('button',{name:'Actuator'}).click();
await expect(page.getByText('motor',{exact:true})).toBeVisible();
await expect(page.getByRole('tabpanel',{name:'控制'}).getByText('slide',{exact:true})).toBeVisible();
await page.getByRole('tab',{name:'模型结构'}).click();
const structure=page.getByRole('navigation',{name:'模型结构树'});await expect(structure).toBeVisible();await structure.getByRole('treeitem',{name:/hinge/}).hover();
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(page.getByRole('button',{name:'重置关节'})).toBeVisible();
await page.getByRole('button',{name:'高级'}).click();
await expect(page.getByText('下限 -1.571 rad')).toBeVisible();
await expect(page.getByText('上限 1.571 rad')).toBeVisible();
await page.getByRole('button',{name:'rad 弧度制'}).click();
await expect(page.getByText('下限 -90.000°')).toBeVisible();
await expect(page.getByText('上限 90.000°')).toBeVisible();
await page.getByRole('button',{name:'忽略关节限位'}).click();
await expect(page.getByRole('button',{name:'忽略关节限位'})).toHaveAttribute('aria-pressed','true');
await expect(page.getByText('已忽略').first()).toBeVisible();
await page.getByRole('button',{name:'重置关节'}).click();
await expect(page.getByRole('button',{name:'▶ 播放'})).toBeVisible();
});
test('窄视口默认保留完整视口并可按需打开侧栏',async({page})=>{
await page.setViewportSize({width:800,height:700});
await page.goto('/');
await expect(page.getByRole('main')).toBeInViewport();
await expect(page.getByRole('button',{name:'显示工程面板'})).toBeVisible();
await expect(page.getByRole('button',{name:'显示属性面板'})).toBeVisible();
await page.getByRole('button',{name:'显示属性面板'}).click();
await expect(page.getByRole('complementary').filter({hasText:'导入模型后显示属性'})).toBeVisible();
});
test('工作区布局与视口显示偏好在刷新后保留',async({page})=>{
await page.setViewportSize({width:1440,height:900});
await page.goto('/');
await page.getByRole('button',{name:'隐藏工程面板'}).click();
await page.getByRole('button',{name:'显示设置'}).click();
await page.getByRole('switch',{name:/^坐标系/}).click();
await page.reload();
await expect(page.getByRole('button',{name:'显示工程面板'})).toBeVisible();
await page.getByRole('button',{name:'显示设置'}).click();
await expect(page.getByRole('switch',{name:/^坐标系/})).toHaveAttribute('aria-checked','true');
});
test('转换后的 MJCF 可编辑并重新载入', async ({page}) => {
await page.goto('/');
await page.locator('input[type="file"]').first().setInputFiles({name:'model.xml',mimeType:'text/xml',buffer:Buffer.from(SIMPLE_MODEL)});
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000});
await page.getByRole('button',{name:'源代码'}).click();
const dialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});await expect(dialog).toBeVisible();
await expect(dialog.getByText('缓存文件 · 可编辑')).toBeVisible();
await expect(dialog.getByText('转换后的 MJCF',{exact:true})).toBeVisible();
const editor=dialog.locator('.monaco-editor');await editor.click({position:{x:240,y:120}});
await page.keyboard.press('Control+a');await page.keyboard.insertText(SIMPLE_MODEL.replace('model="e2e"','model="cached-edit"'));
await dialog.getByRole('button',{name:'保存并重新载入',exact:true}).click();
await expect(dialog.getByRole('button',{name:'保存并重新载入',exact:true})).toBeDisabled({timeout:30_000});
await dialog.getByRole('button',{name:'关闭源代码编辑器'}).click();
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000});
await expect(page.getByRole('button',{name:'导出 URDF'})).toHaveCount(0);
await expect(page.getByRole('button',{name:'导出 MJCF'})).toHaveCount(0);
});
test('关闭已修改的 MJCF 前要求确认',async({page})=>{
await page.goto('/');
await page.locator('input[type="file"]').first().setInputFiles({name:'model.xml',mimeType:'text/xml',buffer:Buffer.from(SIMPLE_MODEL)});
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000});
await page.getByRole('button',{name:'源代码'}).click();
const editorDialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});
await editorDialog.locator('.monaco-editor').click({position:{x:240,y:120}});
await page.keyboard.press('Control+End');await page.keyboard.insertText('\n');
await editorDialog.getByRole('button',{name:'关闭源代码编辑器'}).click();
const confirm=page.getByRole('dialog',{name:'放弃未保存的修改?'});
await expect(confirm).toBeVisible();
await confirm.getByRole('button',{name:'继续编辑'}).click();
await expect(editorDialog).toBeVisible();
await editorDialog.getByRole('button',{name:'关闭源代码编辑器'}).click();
await page.getByRole('dialog',{name:'放弃未保存的修改?'}).getByRole('button',{name:'放弃修改'}).click();
await expect(editorDialog).toHaveCount(0);
});
test('加载包含 include、OBJ、STL 与 PNG 的工程', async ({page}) => {
await page.goto('/');
await page.locator('input[type="file"]').first().setInputFiles([
fixture('mjcf_include/model.xml'),
fixture('mjcf_include/world.xml'),
fixture('mjcf_include/triangle.obj'),
fixture('mjcf_include/triangle.stl'),
fixture('mjcf_include/checker.png'),
]);
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
await expect(page.getByText('5 个文件')).toBeVisible();
});
test('加载引用 OBJ 的 URDF 工程', async ({page}) => {
await page.goto('/');
await page.locator('input[type="file"]').first().setInputFiles([
fixture('urdf_mesh/robot.urdf'),
fixture('urdf_mesh/triangle.obj'),
]);
const options=page.getByRole('dialog',{name:'配置 URDF 仿真组件'});
await expect(options.getByRole('checkbox',{name:/为关节添加驱动器/})).toBeChecked();
await expect(options.getByRole('checkbox',{name:/添加传感器/})).toBeChecked();
await options.getByRole('button',{name:'转换并加载'}).click();
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
await expect(page.getByText('2 个文件')).toBeVisible();
await page.getByRole('button',{name:'URDF 处理方式'}).click();
await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf');
await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating');
await page.getByRole('button',{name:'通知中心'}).click();
const notifications=page.getByRole('dialog',{name:'通知中心'});
await expect(notifications).toContainText(/模型已加载 · \d+ 项兼容调整/);
await expect(notifications).toContainText(/URDF 已转换为 MJCF(浮动基座),并整体平移/);
await page.keyboard.press('Escape');
await expect(page.getByLabel('显示碰撞几何')).not.toBeChecked();
await page.getByRole('button',{name:'源代码'}).click();
const sourceDialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});await expect(sourceDialog.getByText('缓存文件 · 可编辑')).toBeVisible();
await sourceDialog.locator('.monaco-editor').click({position:{x:240,y:120}});await page.keyboard.press('Control+End');await page.keyboard.insertText('\n');
await sourceDialog.getByRole('button',{name:'保存并重新载入',exact:true}).click();await expect(sourceDialog.getByRole('button',{name:'保存并重新载入',exact:true})).toBeDisabled({timeout:30_000});
await expect(page.getByText('WASM 已加载')).toBeVisible();await sourceDialog.getByRole('button',{name:'关闭源代码编辑器'}).click();
});
test('URDF 自动生成的关节驱动器与摄像头可通过 MuJoCo 编译',async({page})=>{
const urdf=`<robot name="jointed"><link name="base"><inertial><mass value="1"/><origin xyz="0 0 0"/><inertia ixx=".1" iyy=".1" izz=".1" ixy="0" ixz="0" iyz="0"/></inertial><visual><geometry><box size=".4 .4 .2"/></geometry></visual></link><link name="arm"><inertial><mass value=".2"/><origin xyz="0 0 .25"/><inertia ixx=".01" iyy=".01" izz=".01" ixy="0" ixz="0" iyz="0"/></inertial><visual><origin xyz="0 0 .25"/><geometry><box size=".1 .1 .5"/></geometry></visual></link><joint name="shoulder" type="revolute"><parent link="base"/><child link="arm"/><origin xyz="0 0 .1"/><axis xyz="0 1 0"/><limit lower="-1" upper="1" effort="10" velocity="2"/></joint></robot>`;
await page.goto('/');await page.locator('input[type="file"]').first().setInputFiles({name:'jointed.urdf',mimeType:'application/xml',buffer:Buffer.from(urdf)});
await page.getByRole('dialog',{name:'配置 URDF 仿真组件'}).getByRole('button',{name:'转换并加载'}).click();
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000});
await expect(page.getByLabel('摄像头画面')).toBeVisible();await page.getByRole('button',{name:'隐藏画面'}).click();await page.getByRole('button',{name:'显示摄像头画面'}).click();await expect(page.getByLabel('摄像头画面')).toBeVisible();
await page.getByRole('button',{name:'通知中心'}).click();
const notifications=page.getByRole('dialog',{name:'通知中心'});
await expect(notifications).toContainText('已为 1 个 hinge/slide 关节生成 motor 驱动器');
await expect(notifications).toContainText('已将 640×480 摄像头固连到 arm');
await page.keyboard.press('Escape');await page.getByRole('tab',{name:'控制'}).click();await page.getByRole('button',{name:'Actuator'}).click();
await expect(page.getByText('shoulder_motor')).toBeVisible();await expect(page.getByText('关节:shoulder')).toBeVisible();await expect(page.getByText('N·m',{exact:true})).toBeVisible();
await page.getByText('常用参数').click();const kp=page.getByLabel(/kpMJCF stiffness/),kv=page.getByLabel(/kvMJCF damping/);await kp.fill('150');await kp.press('Enter');await kv.fill('15');await kv.press('Enter');await expect(kp).toHaveValue('150');await expect(kv).toHaveValue('15');
});
test('转换后的 MJCF 保存时保留 DAE 转换缓存资源',async({page})=>{
const zip=zipSync({'robot/urdf/robot.urdf':new Uint8Array(readFileSync(fixture('urdf_dae/robot/urdf/robot.urdf'))),'robot/dae/triangle.dae':new Uint8Array(readFileSync(fixture('urdf_dae/robot/dae/triangle.dae')))});
await page.goto('/');await page.locator('input[type="file"]').first().setInputFiles({name:'dae.zip',mimeType:'application/zip',buffer:Buffer.from(zip)});await page.getByRole('dialog',{name:'配置 URDF 仿真组件'}).getByRole('button',{name:'转换并加载'}).click();await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000});
await page.getByRole('button',{name:'源代码'}).click();const dialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});await dialog.locator('.monaco-editor').click({position:{x:240,y:120}});await page.keyboard.press('Control+End');await page.keyboard.insertText('\n');await dialog.getByRole('button',{name:'保存并重新载入',exact:true}).click();await expect(dialog.getByRole('button',{name:'保存并重新载入',exact:true})).toBeDisabled({timeout:30_000});await expect(page.getByText('WASM 已加载')).toBeVisible();await expect(page.getByText('模型编译失败')).toHaveCount(0);
});
test('slide 关节向屏幕轴正方向拖动时 qpos 同向增加',async({page})=>{
await page.goto('/');await page.locator('input[type="file"]').first().setInputFiles({name:'slide.xml',mimeType:'text/xml',buffer:Buffer.from(SLIDE_DIRECTION_MODEL)});await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000});
await page.getByRole('button',{name:'关节拖动'}).click();const canvas=page.locator('main canvas').first(),box=await canvas.boundingBox();expect(box).not.toBeNull();const x=box!.x+box!.width/2,y=box!.y+box!.height/2;await page.mouse.move(x,y);await page.mouse.down();await page.mouse.move(x+70,y,{steps:8});await page.mouse.up();
await page.getByRole('tab',{name:'控制'}).click();const jointSection=page.getByRole('button',{name:'关节 1'});if(await jointSection.getAttribute('aria-expanded')==='false')await jointSection.click();const output=page.getByText('screen_x').locator('..').locator('output');await expect.poll(async()=>Number.parseFloat(await output.textContent()||'0')).toBeGreaterThan(0);
});
test('可导入并启用 Python 控制器',async({page})=>{
await page.goto('/');await page.locator('input[type="file"]').first().setInputFiles({name:'model.xml',mimeType:'text/xml',buffer:Buffer.from(SIMPLE_MODEL)});await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000});
await page.getByRole('tab',{name:'控制'}).click();const python=`NAME = "测试 PD 控制器"\nCONTROL_HZ = 100\ndef init(api):\n return {"joint": api.joint("slide"), "actuator": api.actuator("motor"), "body": api.body("box")}\ndef step(ctx, state):\n assert len(ctx.body_quat(state["body"])) == 4\n assert len(ctx.body_position(state["body"])) == 3\n ctx.set_control(state["actuator"], -ctx.qpos(state["joint"]) - 0.1 * ctx.qvel(state["joint"]))\n`;
await page.locator('input[accept=".py,text/x-python"]').setInputFiles({name:'balance.py',mimeType:'text/x-python',buffer:Buffer.from(python)});await expect(page.getByText('测试 PD 控制器',{exact:true})).toBeVisible({timeout:30_000});await expect(page.getByText('Python / Pyodide')).toBeVisible();await page.getByRole('button',{name:'启用',exact:true}).click();await expect(page.getByText('运行中')).toBeVisible();
});
test('中等规模模型持续步进并可重复加载', async ({page}) => {
await page.goto('/');
const input = page.locator('input[type="file"]').first();
const modelFile = {name:'large.xml',mimeType:'text/xml',buffer:Buffer.from(LARGE_MODEL)};
await input.setInputFiles(modelFile);
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
await page.getByRole('button', {name:'▶ 播放'}).click();
await page.waitForTimeout(2_000);
await expect(page.locator('footer')).not.toContainText('时间 0.000 s');
// 播放过程中重置必须同时暂停底层会话,之后仍可正常播放和暂停。
await page.getByRole('button',{name:'重置',exact:true}).click();
await expect(page.getByRole('button',{name:'▶ 播放'})).toBeVisible();
await expect(page.locator('footer')).toContainText('时间 0.000 s');
await page.getByRole('button',{name:'▶ 播放'}).click();
await page.waitForTimeout(500);
await page.getByRole('button',{name:'⏸ 暂停'}).click();
await page.waitForTimeout(200);
const pausedTime=(await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1];
expect(Number(pausedTime)).toBeGreaterThan(0);
await page.waitForTimeout(500);
expect((await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1]).toBe(pausedTime);
await input.setInputFiles(modelFile);
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
await expect(page.getByRole('alert')).toHaveCount(0);
});
test('无效模型显示中文诊断且保留工程树', async ({page}) => {
await page.goto('/');
await page.locator('input[type="file"]').first().setInputFiles(fixture('invalid.xml'));
await expect(page.getByRole('alert')).toContainText('模型编译失败', {timeout: 30_000});
await expect(page.getByText('invalid.xml', {exact: false}).first()).toBeVisible();
});