import { expect, test } from '@playwright/test'; import { createHash } from 'node:crypto'; import { readFile, writeFile } from 'node:fs/promises'; import { readdirSync } from 'node:fs'; import { resolve } from 'node:path'; import type {} from '../physics/runner'; import type { ProjectManifest } from '../src/project/types'; const assetBase = `/@fs${resolve(process.cwd(), 'build/lekiwi')}`; const hash = (text: string) => createHash('sha256').update(text).digest('hex'); test('等价 URDF 变体:真实 WASM 控制、输入溯源及 MJCF 导出重载', async ({ page }, info) => { const original = await readFile('robot_profiles/lekiwi-reference.urdf', 'utf8'); const variant = original .replace('name="LeKiwi"', 'name="LeKiwi 换色变体"') .replace('', ''); await page.route('**/URDF/LeKiwi.urdf', (route) => route.fulfill({ body: variant, contentType: 'application/xml' }), ); await page.goto('/physics/runner.html'); await page.waitForFunction(() => Boolean(window.lekiwiPhysics)); const boot = await page.evaluate((path) => window.lekiwiPhysics.boot(path), assetBase); expect(boot.nu).toBe(9); expect(boot.njnt).toBe(46); const stable = await page.evaluate(() => window.lekiwiPhysics.step(3)); expect(stable.finite).toBe(true); const drive = await page.evaluate(() => window.lekiwiPhysics.drive(0.1, 0, 0, 2)); expect(drive.position[0] - stable.position[0]).toBeGreaterThan(0.15); const xml = await page.evaluate(() => window.lekiwiPhysics.xml()); expect(xml).toContain(`name="platform_robot_source_sha256" data="${hash(original)}"`); expect(xml).toContain(`name="platform_robot_input_sha256" data="${hash(variant)}"`); const reloaded = await page.evaluate(() => window.lekiwiPhysics.reimport()); expect(reloaded.robot?.profileId).toBe('lekiwi-v1'); expect(reloaded.nu).toBe(9); expect(await page.evaluate(() => window.lekiwiPhysics.xml())).toContain(hash(variant)); await writeFile(info.outputPath('variant.xml'), xml); await writeFile( info.outputPath('variant-control.json'), JSON.stringify({ boot, stable, drive, reloaded }, null, 2), ); await page.evaluate(() => window.lekiwiPhysics.dispose()); }); test('同名底盘 STL 被改动也拒绝旧 profile,不只校验机械臂网格', async ({ page }) => { await page.route('**/URDF/meshes/base_plate_layer1-v5.stl', (route) => route.fulfill({ body: 'changed mesh bytes' }), ); await page.goto('/physics/runner.html'); await page.waitForFunction(() => Boolean(window.lekiwiPhysics)); await expect(page.evaluate((path) => window.lekiwiPhysics.boot(path), assetBase)).rejects.toThrow( /SHA-256 不匹配.*base_plate_layer1/, ); await page.evaluate(() => window.lekiwiPhysics.dispose()); }); // Optional local evidence: assets stay outside the repository. Setting the env // explicitly makes missing/invalid samples fail, rather than silently skipping. const localDirectory = process.env.LEKIWI_VARIANT_DIR; const localSamples = localDirectory ? readdirSync(localDirectory) .filter((file) => file.endsWith('.zip')) .sort() : []; if (localDirectory && !localSamples.length) throw new Error('LEKIWI_VARIANT_DIR 中没有 ZIP 样本'); for (const name of localSamples) { test(`用户变体包诊断(不适配旧控制):${name}`, async ({ page }, info) => { await page.route('**/__local_variant.zip', (route) => route.fulfill({ path: resolve(localDirectory!, name), contentType: 'application/zip' }), ); await page.goto('/physics/runner.html'); const result = await page.evaluate(async () => { const importerPath = '/src/project/importer.ts'; const profilesPath = '/src/project/robotProfiles/index.ts'; const physicsPath = '/src/simulation/PhysicsAdapter.ts'; const registryPath = '/src/robot/registry.ts'; const { importBrowserFiles, prepareProjectForMujoco } = await import( /* @vite-ignore */ importerPath ); const { prepareRobotProject } = await import(/* @vite-ignore */ profilesPath); const { MainThreadPhysicsAdapter } = await import(/* @vite-ignore */ physicsPath); const { sha256 } = await import(/* @vite-ignore */ registryPath); const bytes = await (await fetch('/__local_variant.zip')).arrayBuffer(); const manifest: ProjectManifest = await importBrowserFiles([ new File([bytes], 'variant.zip'), ]); const entry = manifest.selectedEntry!; const data = manifest.files.find((file) => file.path === entry)!.data; const sourceHash = await sha256(data); const doc = new DOMParser().parseFromString( new TextDecoder().decode(data), 'application/xml', ); const movable = Array.from(doc.querySelectorAll('robot > joint')).filter( (j) => j.getAttribute('type') !== 'fixed', ); let profileError = ''; try { await prepareRobotProject(manifest, entry, 'lekiwi-v1'); } catch (error) { profileError = String(error); } const prepared = await prepareProjectForMujoco(manifest, entry); const fixedText = new TextDecoder().decode( prepared.manifest.files.find((file: { path: string }) => file.path === entry).data, ); const adapter = new MainThreadPhysicsAdapter(); let genericCompileError = ''; try { await adapter.load(manifest, entry, { urdfMode: 'mjcf', baseMode: 'floating' }); } catch (error) { genericCompileError = String(error); } finally { adapter.dispose(); } return { entry, sourceHash, profileError, genericCompileError, links: doc.querySelectorAll('robot > link').length, movable: movable.map((j) => j.getAttribute('name')), warnings: prepared.warnings, unresolvedPackageUri: fixedText.includes('package://'), sourceUnmodified: (await sha256(data)) === sourceHash, }; }); expect(result.links).toBe(35); expect(result.movable).toHaveLength(10); expect(result.profileError).toContain('需要 9 个活动关节,实际 10 个'); expect(result.profileError).toContain('Link7'); expect(result.unresolvedPackageUri).toBe(false); expect(result.sourceUnmodified).toBe(true); // This is an import repair, not an unreviewed wheel-mesh simplification. expect(result.genericCompileError).toMatch(/STL|faces/i); await writeFile(info.outputPath('local-variant-audit.json'), JSON.stringify(result, null, 2)); }); }