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
164 lines
8.0 KiB
TypeScript
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);
|
|
});
|
|
}
|