f3a8a38acd
web-platform-ci / Standalone decision service (no cloud credentials) (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled
web-platform-ci / Standalone decision service (no cloud credentials) (pull_request) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
lekiwi-compatibility / cpu-compatibility (pull_request) Has been cancelled
集成同源 BYOK 会话隔离、精简模型设置、官方订阅入口和 HTTPS 发布运维;保留本地训练/调参与控制能力。同步 npm 版本及 CHANGELOG,记录公网真实 API 验收仍待用户凭据。
138 lines
6.4 KiB
TypeScript
138 lines
6.4 KiB
TypeScript
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('</visual>', '<material name="red"><color rgba="1 0 0 1"/></material></visual>');
|
|
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));
|
|
});
|
|
}
|