import { expect, test, type Page } from '@playwright/test'; import { existsSync } from 'node:fs'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import type {} from '../physics/mobile'; const original = resolve('build/lekiwi/lekiwi-v1.zip'); const bundle = process.env.MOBILE_BUNDLE_ZIP ?? resolve('../Reference_Projects/LeKiwi/New_urdf/robot_urdf_bundle (1).zip'); async function exportRollout(page: Page, zipName: string, traceName: string) { await page.evaluate(() => window.mobilePhysics.reset()); await mkdir('build/mobile-validation', { recursive: true }); const downloadPromise = page.waitForEvent('download'); await page.evaluate(() => window.mobilePhysics.download()); await (await downloadPromise).saveAs(`build/mobile-validation/${zipName}`); const actions = Array.from({ length: 12 }, (_, step) => { const a = new Array(12).fill(0); a[0] = 0.1; a[3] = 0.03 * Math.sin(step); a[11] = 1; return a; }); const rollout = []; for (const action of actions) rollout.push(await page.evaluate((a) => window.mobilePhysics.step(1, a), action)); await writeFile( `build/mobile-validation/${traceName}`, JSON.stringify({ actions, rollout }, null, 2), ); } test('真实 WASM:两种机器人、控制、拖动、失败回滚、重复切换与训练包导出', async ({ page }) => { test.skip(!existsSync(original) || !existsSync(bundle), '需要本地两个机器人 ZIP'); const errors: string[] = []; page.on('pageerror', (e) => errors.push(e.message)); await page.route('**/original.zip', (route) => route.fulfill({ path: original })); await page.route('**/bundle.zip', (route) => route.fulfill({ path: bundle })); await page.goto('/physics/mobile.html'); await page.waitForFunction(() => Boolean(window.mobilePhysics)); const traces = []; for (const id of [ 'lekiwi-v1', 'lekiwi-bundle', 'lekiwi-v1', 'lekiwi-bundle', 'lekiwi-v1', 'lekiwi-bundle', 'lekiwi-v1', 'lekiwi-bundle', // Complete CAD meshes increase allocator/cache warm-up; exercise extra // cycles without raising the peak-memory bound or weakening final stability. 'lekiwi-v1', 'lekiwi-bundle', 'lekiwi-v1', 'lekiwi-bundle', ]) { const initial = await page.evaluate( (id) => window.mobilePhysics.boot(id === 'lekiwi-v1' ? '/original.zip' : '/bundle.zip', id), id, ); console.log('mobile heap', id, initial.heap / 1048576); expect(initial.heap).toBeLessThan(1600 * 1048576); expect(initial.observation).toHaveLength(92); expect(initial.observation.every(Number.isFinite)).toBe(true); expect(initial.ctrl).toHaveLength(9); expect(initial.retiredModelDeleted).toBe(true); expect(initial.retiredDataDeleted).toBe(true); const stable = await page.evaluate(() => window.mobilePhysics.step(10)); expect(stable.time).toBeCloseTo(0.2, 8); expect(stable.qpos.every(Number.isFinite)).toBe(true); const action = new Array(12).fill(0); action[0] = 0.3; action[3] = 0.2; action[11] = 1; const moved = await page.evaluate((a) => window.mobilePhysics.step(10, a, true), action); expect(moved.ctrl.some((v, i) => Math.abs(v - initial.ctrl[i]) > 0.01)).toBe(true); expect(moved.qpos.some((v, i) => Math.abs(v - stable.qpos[i]) > 0.001)).toBe(true); action[0] = 0; action[11] = -1; const closed = await page.evaluate((a) => window.mobilePhysics.step(80, a, true), action); expect(closed.state[29]).toBeLessThan(initial.state[29] - 0.2); expect(closed.qpos.every(Number.isFinite)).toBe(true); expect(await page.evaluate(() => window.mobilePhysics.workspaces())).toHaveLength(1); const edited = await page.evaluate(() => window.mobilePhysics.move('goal', [0.4, -0.2, 0.019])); expect(edited.state.slice(47, 50)).toEqual([0.4, -0.2, 0.019]); expect(edited.stage).toBe('reach'); traces.push({ initial, stable, moved, closed, edited }); if (traces.length === 1) await exportRollout(page, 'lekiwi-training.zip', 'lekiwi-rollout.json'); } const previous = await page.evaluate(() => window.mobilePhysics.state()); const failure = await page.evaluate(async () => { try { await window.mobilePhysics.boot('/original.zip', 'lekiwi-bundle'); return ''; } catch (e) { return String(e); } }); expect(failure).toContain('Link1'); expect((await page.evaluate(() => window.mobilePhysics.state())).robotId).toBe(previous.robotId); expect(await page.evaluate(() => window.mobilePhysics.workspaces())).toHaveLength(1); const compileFailure = await page.evaluate(async () => { try { await window.mobilePhysics.invalidCandidate('/bundle.zip'); return ''; } catch (e) { return String(e); } }); expect(compileFailure).toContain('missing_actuator'); expect(await page.evaluate(() => window.mobilePhysics.workspaces())).toHaveLength(1); expect( (await page.evaluate(() => window.mobilePhysics.step(1))).observation.every(Number.isFinite), ).toBe(true); await writeFile( 'build/mobile-validation/heap-warmup.json', JSON.stringify( traces.map((t) => ({ robot: t.initial.robotId, heap: t.initial.heap })), null, 2, ), ); expect(traces[11].initial.heap).toBe(traces[9].initial.heap); await exportRollout(page, 'bundle-training.zip', 'rollout.json'); await writeFile('build/mobile-validation/traces.json', JSON.stringify(traces, null, 2)); await page.evaluate(() => { window.mobilePhysics.reset(); window.mobilePhysics.show(); }); await page.waitForFunction(() => document.querySelector('canvas')!.width > 500); // Use actual camera projection, not hard-coded screenshot pixel coordinates. for (const entity of ['object', 'goal'] as const) { const before = await page.evaluate(() => window.mobilePhysics.state()); const p = await page.evaluate((entity) => window.mobilePhysics.screen(entity), entity); await page.keyboard.down('Shift'); await page.mouse.move(p.x, p.y); await page.mouse.down(); await page.mouse.move(p.x + 45, p.y + 12, { steps: 5 }); await page.mouse.up(); await page.keyboard.up('Shift'); const after = await page.evaluate(() => window.mobilePhysics.state()); const offset = entity === 'object' ? 37 : 47; expect( Math.hypot( after.state[offset] - before.state[offset], after.state[offset + 1] - before.state[offset + 1], ), ).toBeGreaterThan(0.01); } expect(errors).toEqual([]); await page.evaluate(() => window.mobilePhysics.dispose()); }); // 独立产品页已移除;导入/控制/暂停/重置/状态 UI 在主工作台用例验收: // lekiwi.agent.workspace.spec.ts。这里保留两模型物理、训练快照及 ORT 夹具。 test('真实 ORT WASM:导出的 smoke 权重锁步执行(不代表训练成功)', async ({ page }) => { const model = resolve('build/mobile-validation/smoke.onnx'); test.skip(!existsSync(model) || !existsSync(bundle), '先按文档导出 smoke.onnx'); const metadata = JSON.parse(await readFile('build/mobile-validation/smoke.json', 'utf8')); await page.route('**/bundle.zip', (route) => route.fulfill({ path: bundle })); await page.route('**/smoke.onnx', (route) => route.fulfill({ path: model })); await page.goto('/physics/mobile.html'); await page.waitForFunction(() => Boolean(window.mobilePhysics)); await page.evaluate(() => window.mobilePhysics.boot('/bundle.zip', 'lekiwi-bundle')); await page.evaluate((metadata) => window.mobilePhysics.policy('/smoke.onnx', metadata), metadata); await expect .poll( async () => (await page.evaluate(() => window.mobilePhysics.tick(performance.now()))).time, { timeout: 30_000, intervals: [20] }, ) .toBeGreaterThanOrEqual(0.1); const status = await page.evaluate(() => window.mobilePhysics.tick(performance.now())); expect(status.metrics?.error).toBe(''); expect(status.metrics!.inferenceCount).toBeGreaterThanOrEqual(5); await writeFile('build/mobile-validation/onnx-metrics.json', JSON.stringify(status, null, 2)); await page.evaluate(() => window.mobilePhysics.dispose()); });