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((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: 'not a robot zip' }), ); 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); }); }