Files
Mujoco_WASM/web_platform/e2e/builtinRobots.spec.ts
T
chenlin 26bb5634bd
web-platform-ci / Standalone decision service (no cloud credentials) (push) Waiting to run
web-platform-ci / TypeScript, lint, unit, build (push) Waiting to run
web-platform-ci / Playwright E2E (push) Waiting to run
lekiwi-compatibility / cpu-compatibility (push) Waiting to run
refactor: release v1.0.5 安全精简与网站发布
2026-09-29 09:55:08 +08:00

164 lines
8.0 KiB
TypeScript

import { expect, test, type Page, type TestInfo } from '@playwright/test';
import { resolve } from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
import { Quaternion, Vector3 } from 'three';
// Vite dev also serves a tiny JS URL module at .zip?import&url; only count/intercept ZIP bytes.
const zipRequest = /\/[^/?]*lekiwi-v1[^/?]*\.zip$/;
async function verifyBuiltinCameras(page: Page, info: TestInfo) {
await page.getByRole('button', { name: '源代码', exact: true }).click();
const editor = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
const download = page.waitForEvent('download');
await editor.getByRole('button', { name: '下载', exact: true }).click();
const xml = await readFile((await (await download).path())!, 'utf8');
const cameras = await page.evaluate((source) => {
const doc = new DOMParser().parseFromString(source, 'application/xml');
return Array.from(doc.querySelectorAll('camera')).map((camera) => {
const frames = [];
for (
let element: Element | null = camera;
element?.tagName !== 'worldbody';
element = element.parentElement
) {
if (!element) throw new Error('Camera is not mounted in worldbody');
frames.unshift({
name: element.getAttribute('name'),
pos: (element.getAttribute('pos') ?? '0 0 0').split(/\s+/).map(Number),
quat: (element.getAttribute('quat') ?? '1 0 0 0').split(/\s+/).map(Number),
});
}
return { name: camera.getAttribute('name'), frames };
});
}, xml);
expect(cameras).toHaveLength(2);
const tilt = (10 * Math.PI) / 180;
const evidence = [];
for (const camera of cameras) {
const wrist = camera.name === '__platform_camera__wrist';
expect(camera.frames.at(-2)?.name).toBe(wrist ? 'Camera-Model-v3-1' : 'Camera-Model-v3');
const position = new Vector3();
const rotation = new Quaternion();
for (const frame of camera.frames) {
position.add(new Vector3().fromArray(frame.pos).applyQuaternion(rotation));
const [w, x, y, z] = frame.quat;
rotation.multiply(new Quaternion(x, y, z, w).normalize());
}
const forward = new Vector3(0, 0, -1).applyQuaternion(rotation);
const up = new Vector3(0, 1, 0).applyQuaternion(rotation);
// Independent source-URDF oracle, checked against the actual WASM-exported
// model including the profile's CAD yaw and full wrist kinematic chain.
const expectedPosition = wrist
? new Vector3(0.215391977166, 0.004249997308, 0.168290381278)
: new Vector3(0.13095590444, 0, 0.062079251628);
const expectedForward = wrist
? new Vector3(-Math.sin(tilt), 0, -Math.cos(tilt))
: new Vector3(Math.cos(tilt), 0, Math.sin(tilt));
const expectedUp = wrist
? new Vector3(Math.cos(tilt), 0, -Math.sin(tilt))
: new Vector3(-Math.sin(tilt), 0, Math.cos(tilt));
expect(position.distanceTo(expectedPosition)).toBeLessThan(2e-6);
expect(forward.distanceTo(expectedForward)).toBeLessThan(2e-6);
expect(up.distanceTo(expectedUp)).toBeLessThan(2e-6);
evidence.push({ ...camera, position, forward, up });
}
await writeFile(info.outputPath('dual-cameras.json'), JSON.stringify(evidence, null, 2));
await page.getByRole('button', { name: '关闭源代码编辑器' }).click();
}
test('URL 自动下载、配置 profile、加载真实 WASM;刷新可重入且不自动运行', async ({
page,
}, info) => {
const downloads: string[] = [];
const inference: string[] = [];
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(e.message));
page.on('request', (r) => {
if (zipRequest.test(r.url())) downloads.push(r.url());
if (/\/api\/decision\/v1\/(command|plan|decide|test)$/.test(r.url())) inference.push(r.url());
});
await page.goto('/?robot=lekiwi-v1&utm_source=partner');
const play = page.getByRole('button', { name: '▶ 播放' });
await expect(play).toBeEnabled({ timeout: 90000 });
await expect(page.getByRole('dialog', { name: '配置 URDF 仿真组件' })).toHaveCount(0);
await expect(page.getByRole('button', { name: '关节拖动', exact: true })).toBeDisabled();
expect(downloads).toHaveLength(1);
await verifyBuiltinCameras(page, info);
const simulationTime = page.getByLabel('视口状态').getByText(/^时间 /);
await expect(simulationTime).toHaveText('时间 0.000 s');
const selector = page.getByRole('combobox', { name: '摄像头视角' });
const frame = page.getByLabel('摄像头画面', { exact: true });
await expect(selector.locator('option')).toHaveCount(2);
await expect(frame).toHaveAttribute('data-camera-body', 'Camera-Model-v3');
await page.screenshot({ path: info.outputPath('builtin-lekiwi-front.png') });
await selector.selectOption({ label: '腕部摄像头' });
await expect(frame).toHaveAttribute('data-camera-body', 'Camera-Model-v3-1');
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
await page.screenshot({ path: info.outputPath('builtin-lekiwi-wrist.png') });
await page.getByRole('button', { name: '隐藏画面' }).click();
await expect(frame).toHaveCount(0);
await page.getByRole('button', { name: '显示摄像头画面' }).click();
await expect(frame).toHaveAttribute('data-camera-body', 'Camera-Model-v3-1');
await expect(play).toBeEnabled();
expect(downloads).toHaveLength(1); // Switching/hiding never reloads the robot.
await expect(simulationTime).toHaveText('时间 0.000 s'); // 相机选择/隐藏不推进物理。
await page.reload();
await expect(play).toBeEnabled({ timeout: 90000 });
await expect(page.getByRole('dialog', { name: '配置 URDF 仿真组件' })).toHaveCount(0);
await expect(page.getByRole('button', { name: '关节拖动', exact: true })).toBeDisabled();
await expect(selector.locator('option')).toHaveCount(2);
await expect(frame).toHaveAttribute('data-camera-body', 'Camera-Model-v3');
expect(downloads).toHaveLength(2);
expect(inference).toEqual([]);
expect(errors).toEqual([]);
});
test('不带参数保持空白,不下载内置 ZIP', async ({ page }) => {
const downloads: string[] = [];
page.on('request', (r) => {
if (zipRequest.test(r.url())) downloads.push(r.url());
});
await page.goto('/');
await expect(page.getByRole('button', { name: '选择文件', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeDisabled();
expect(downloads).toEqual([]);
});
for (const query of ['robot=unknown', 'robot=lekiwi-v1&robot=lekiwi-v1']) {
test(`非法参数不下载、不加载:${query}`, async ({ page }) => {
const downloads: string[] = [];
page.on('request', (r) => {
if (zipRequest.test(r.url())) downloads.push(r.url());
});
await page.goto(`/?${query}`);
await expect(page.getByRole('alert')).toContainText('内置机器人加载失败');
await page.getByRole('button', { name: '技术详情' }).click();
await expect(page.getByRole('alert')).toContainText('?robot=lekiwi-v1');
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeDisabled();
expect(downloads).toEqual([]);
});
}
for (const status of [404, 200]) {
test(`下载失败或损坏包(${status})可恢复手动导入`, async ({ page }) => {
await page.route(zipRequest, (route) =>
route.fulfill({ status, contentType: 'text/html', body: '<html>not a robot zip</html>' }),
);
await page.goto('/?robot=lekiwi-v1');
await expect(page.getByRole('alert')).toContainText('内置机器人加载失败');
await page.getByRole('button', { name: '技术详情' }).click();
await expect(page.getByRole('alert')).toContainText(status === 404 ? 'HTTP 404' : '文件不完整');
await page
.locator('input[type=file]')
.first()
.setInputFiles(resolve('web_platform/fixtures/python_controller/model.xml'));
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 60000 });
await expect(page.getByRole('alert')).toHaveCount(0);
});
}