feat: release v1.0.1 CADWorld 网站与 LeKiwi 智能抓放
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 验收仍待用户凭据。
This commit is contained in:
2026-09-24 09:57:41 +08:00
parent 3ad29356c9
commit f3a8a38acd
194 changed files with 32918 additions and 236 deletions
@@ -0,0 +1,86 @@
import { spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { stopProcess } from './controlBridge';
/** Explicit live-test helper: keys stay in Python memory, only the ephemeral bridge
* token crosses to the browser. Tighten (never bypass) production admission limits. */
export async function startLiveDecisionService() {
const token = randomBytes(32).toString('hex');
const directory = await mkdtemp(join(tmpdir(), 'lekiwi-live-decision-'));
const script = `
import asyncio, json, os, signal, sys
from pathlib import Path
from aiohttp import web
from decision_server.server import create_app, STATE
from decision_server.credentials import deepseek_llm, openrouter_jev
from decision_server.protocol import DecisionError
async def main():
app = create_app(Path(sys.argv[1]), os.environ.pop('DECISION_SMOKE_TOKEN'), origins={'http://127.0.0.1:4176'}, port=0)
service = app[STATE]
service.connections.values['llm'] = deepseek_llm(Path('.env'))
service.connections.values['jev'] = openrouter_jev(Path('.env'))
original = service.admit
counts = {'llm': 0, 'jev': 0}
def admit(role, stamp):
if counts[role] >= {'llm': 1, 'jev': 12}[role]:
raise DecisionError('live_smoke_budget', 429)
original(role, stamp)
counts[role] += 1
service.admit = admit
runner = web.AppRunner(app, access_log=None, handler_cancellation=True)
await runner.setup()
site = web.TCPSite(runner, '127.0.0.1', 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
service.hosts = {f'127.0.0.1:{port}'}
stop = asyncio.Event()
for sig in (signal.SIGTERM, signal.SIGINT):
asyncio.get_running_loop().add_signal_handler(sig, stop.set)
print('DECISION_READY ' + json.dumps({'endpoint': f'http://127.0.0.1:{port}'}), flush=True)
try:
await stop.wait()
finally:
await runner.cleanup()
asyncio.run(main())
`;
const child = spawn(resolve('.venv/bin/python'), ['-u', '-c', script, directory], {
env: { ...process.env, DECISION_SMOKE_TOKEN: token, PYTHONNOUSERSITE: '1', PYTHONPATH: '' },
stdio: ['ignore', 'pipe', 'ignore'],
});
const stop = async () => {
if (child.pid) await stopProcess(child);
await rm(directory, { recursive: true, force: true });
};
try {
const endpoint = await new Promise<string>((resolve, reject) => {
let output = '';
const timer = setTimeout(
() => reject(new Error('真实模型服务启动超时(不输出认证日志)')),
15000,
);
child.once('error', () => {
clearTimeout(timer);
reject(new Error('真实模型服务启动失败'));
});
child.once('exit', (code) => {
clearTimeout(timer);
reject(new Error(`真实模型服务退出:${code}`));
});
child.stdout.on('data', (chunk: Buffer) => {
output = (output + chunk.toString()).slice(-8192);
const match = output.match(/DECISION_READY (\{[^\n]+\})/);
if (match) {
clearTimeout(timer);
resolve((JSON.parse(match[1]) as { endpoint: string }).endpoint);
}
});
});
return { endpoint, token, stop };
} catch (error) {
await stop();
throw error;
}
}
@@ -0,0 +1,43 @@
import { createConnection } from 'node:net';
import { readFile } from 'node:fs/promises';
/** Same-user 0600 Unix channel. Never print the connect response or persist its token. */
export async function subscriptionOperation(operation: 'connect' | 'status' | 'limits' | 'stop') {
const pointer = JSON.parse(
await readFile('build/lekiwi-agent/subscription-session.json', 'utf8'),
) as { socket?: string; status: string };
if (!pointer.socket || pointer.status === 'closed')
throw new Error('隔离订阅会话已关闭,请重新发起官方登录');
return new Promise<Record<string, unknown>>((resolve, reject) => {
const channel = createConnection(pointer.socket!);
let buffer = '';
channel.setTimeout(20000);
channel.once('connect', () => channel.write(JSON.stringify(operation) + '\n'));
channel.on('data', (chunk) => {
buffer += String(chunk);
if (buffer.length > 16384) {
channel.destroy();
reject(new Error('订阅管理响应过大'));
return;
}
if (buffer.includes('\n')) {
try {
const result = JSON.parse(buffer.split('\n')[0]) as Record<string, unknown>;
if (result.error) reject(new Error('订阅管理请求失败'));
else resolve(result);
} catch {
reject(new Error('订阅管理响应非法'));
}
channel.end();
}
});
channel.once('timeout', () => {
channel.destroy();
reject(new Error('订阅管理请求超时'));
});
channel.once('error', () => reject(new Error('无法连接隔离订阅会话')));
channel.once('end', () => {
if (!buffer.includes('\n')) reject(new Error('订阅管理连接提前关闭'));
});
});
}
@@ -0,0 +1,104 @@
import { test, expect } from '@playwright/test';
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import type {} from '../physics/agent';
for (const fault of [
'empty-grasp',
'empty-once',
'alignment',
'slip',
'jam',
'navigation',
'unreachable',
'overspeed',
] as const) {
test(`真实 WASM 故障:${fault} 不可冒充成功/无限恢复`, async ({ page }, info) => {
await page.goto('/physics/agent.html');
await page.waitForFunction(() => Boolean(window.lekiwiAgent));
await page.evaluate(
(url) => window.lekiwiAgent.boot(url),
`/@fs${resolve('build/lekiwi/lekiwi-v1.zip')}`,
);
try {
const r = await page.evaluate(
(fault) => window.lekiwiAgent.task(0, undefined, { fault }),
fault,
);
await writeFile(info.outputPath('fault-evidence.json'), JSON.stringify(r, null, 2));
console.log('WASM fault', {
fault,
state: r.state,
error: r.error,
failures: r.failures,
recoveries: r.recoveries,
simTime: r.simTime,
});
expect(r.mode).toBe('mock');
expect(r.state).toBe(fault === 'empty-once' ? 'succeeded' : 'failed');
expect(r.succeeded).toBe(fault === 'empty-once');
expect(r.simTime).toBeLessThanOrEqual(70.01);
expect(r.llmCalls).toBe(1);
if (fault === 'empty-grasp' || fault === 'empty-once' || fault === 'alignment') {
const skill = fault === 'alignment' ? 'pregrasp' : 'close';
const code = fault === 'alignment' ? 'alignment_timeout' : 'empty_grasp';
const recovered = fault === 'empty-once';
expect(r.recoveries[skill]).toBe(recovered ? 1 : 2);
expect(r.failures.map((f) => f.code)).toEqual(Array(recovered ? 1 : 3).fill(code));
if (!recovered) expect(r.trace.every((s) => !s.secure)).toBe(true);
else {
expect(r.transported).toBeGreaterThanOrEqual(0.5);
expect(r.sample.onGoalSupport).toBe(true);
}
} else {
expect(r.recoveries).toEqual({});
expect(r.failures).toHaveLength(1);
expect(r.failures[0].code).toBe(
{
slip: 'lost_grasp',
jam: 'joint_stall',
navigation: 'navigation_timeout',
unreachable: 'unreachable',
overspeed: 'joint_velocity',
}[fault],
);
}
if (fault === 'slip') {
expect(Math.max(...r.trace.map((s) => s.transported))).toBeGreaterThan(0.12);
expect(r.transported).toBe(0);
}
} finally {
await page.evaluate(() => window.lekiwiAgent.dispose());
}
});
}
for (const goal of [
[0.237, 0.565, 0.128],
[0.277, 0.665, 0.128],
]) {
test(`真实 WASM 受限目标角点 ${goal.slice(0, 2).join(',')}(非任意目标泛化)`, async ({
page,
}, info) => {
await page.goto('/physics/agent.html');
await page.waitForFunction(() => Boolean(window.lekiwiAgent));
await page.evaluate(
(url) => window.lekiwiAgent.boot(url),
`/@fs${resolve('build/lekiwi/lekiwi-v1.zip')}`,
);
try {
const r = await page.evaluate(
(goal) => window.lekiwiAgent.task(0, undefined, { goal }),
goal,
);
await writeFile(info.outputPath('goal-evidence.json'), JSON.stringify(r, null, 2));
expect(r.state, r.error).toBe('succeeded');
expect(r.goal).toEqual(goal);
expect(r.succeeded).toBe(true);
expect(r.transported).toBeGreaterThanOrEqual(0.5);
expect(Math.hypot(...r.sample.object.map((v, i) => v - goal[i]))).toBeLessThan(0.04);
} finally {
await page.evaluate(() => window.lekiwiAgent.dispose());
}
});
}
@@ -0,0 +1,46 @@
import { test, expect } from '@playwright/test';
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import type {} from '../physics/agent';
test('8 次 A 场景与 IK scratch 加载/释放:验证原生句柄,不以 heap 高水位判泄漏', async ({
page,
}, info) => {
await page.goto('/physics/agent.html');
await page.waitForFunction(() => Boolean(window.lekiwiAgent));
await page.evaluate(
(url) => window.lekiwiAgent.boot(url),
`/@fs${resolve('build/lekiwi/lekiwi-v1.zip')}`,
);
const rows = await page.evaluate(() => window.lekiwiAgent.resources());
await writeFile(info.outputPath('resource-evidence.json'), JSON.stringify(rows, null, 2));
expect(rows).toHaveLength(8);
expect(rows.every((row) => row.deleted.length === 6 && row.deleted.every(Boolean))).toBe(true);
console.log(
'A + scratch heap capacity MiB',
rows.map((row) => row.heapBytes / 1024 / 1024),
);
});
for (const action of ['pause-task', 'pause-global', 'stop', 'force', 'reload'] as const) {
test(`真实 WASM 等待规划时 ${action}:迟到结果不写控制/不步进`, async ({ page }, info) => {
await page.goto('/physics/agent.html');
await page.waitForFunction(() => Boolean(window.lekiwiAgent));
await page.evaluate(
(url) => window.lekiwiAgent.boot(url),
`/@fs${resolve('build/lekiwi/lekiwi-v1.zip')}`,
);
try {
const result = await page.evaluate((action) => window.lekiwiAgent.lifecycle(action), action);
await writeFile(
info.outputPath('late-response-evidence.json'),
JSON.stringify(result, null, 2),
);
expect(result.before).toEqual(result.after);
expect(result.before.time).toBe(0);
expect(result.jevCalls).toBe(0);
expect(result.state).toBe(action === 'pause-task' ? 'paused' : 'cancelled');
expect(result.owner).toBe(action === 'pause-task' ? 'agent' : 'manual');
} finally {
await page.evaluate(() => window.lekiwiAgent.dispose());
}
});
}
@@ -0,0 +1,79 @@
import { expect, test } from '@playwright/test';
import { resolve } from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
import { startLiveDecisionService } from './fixtures/decisionService';
test.use({ trace: 'off', video: 'off' });
test('显式 opt-in:主工作台 DeepSeek flash + OpenRouter Jev 真实物理回合', async ({
page,
}, info) => {
test.skip(
process.env.LEKIWI_LIVE_API !== '1',
'需明确授权 .env 两个角色与一次付费回合;不自动运行',
);
const service = await startLiveDecisionService();
try {
await page.goto('/');
await page
.locator('input[type="file"]')
.first()
.setInputFiles(resolve('build/lekiwi/lekiwi-v1.zip'));
const dialog = page.getByRole('dialog', { name: '配置 URDF 仿真组件' });
await expect(dialog).toBeVisible({ timeout: 30000 });
await dialog.getByLabel('URDF 机器人 profile').selectOption('lekiwi-v1');
await dialog.getByRole('button', { name: '转换并加载' }).click();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 60000 });
await page.getByRole('tab', { name: '控制台', exact: true }).click();
await page.getByRole('button', { name: 'LeKiwi 智能抓放', exact: true }).click();
const panel = page.getByLabel('LeKiwi 智能抓放任务', { exact: true });
await panel.getByText('模型连接与官方订阅登录', { exact: true }).click();
await panel.getByLabel('本机模型服务', { exact: true }).fill(service.endpoint);
await panel.getByLabel('本机服务令牌', { exact: true }).fill(service.token);
await panel.getByRole('button', { name: '连接本机模型服务', exact: true }).click();
await expect(panel).toContainText('本机桥已连接');
await expect(panel.getByLabel('LLM 型号', { exact: true })).toHaveValue('deepseek-flash');
await expect(panel.getByLabel('JEV 型号', { exact: true })).toHaveValue('typesafe/jev-1.13');
await expect(panel.getByLabel('LLM Base URL', { exact: true })).toHaveValue(
'https://api.deepseek.com',
);
await expect(panel.getByLabel('JEV Base URL', { exact: true })).toHaveValue(
'https://openrouter.ai/api/alpha/decisions',
);
await panel.getByText('模型连接与官方订阅登录', { exact: true }).click();
await panel.getByLabel('智能任务决策模式').selectOption('api');
await panel.getByRole('button', { name: '开始任务(真实 API)', exact: true }).click();
const status = panel.getByTestId('agent-task-state');
await expect(status).toContainText(/物理验收成功|失败 \/ 安全停止|已取消/, { timeout: 240000 });
const pendingDownload = page.waitForEvent('download');
await panel.getByRole('button', { name: '导出任务证据', exact: true }).click();
const downloaded = await pendingDownload;
const data = JSON.parse(await readFile((await downloaded.path())!, 'utf8'));
const encoded = JSON.stringify(data, null, 2);
if (encoded.includes(service.token)) throw new Error('任务导出包含本机令牌');
await writeFile(info.outputPath('live-physical-evidence.json'), encoded);
console.log('真实 API 回合(非订阅)', {
state: data.state,
error: data.error,
llm: data.llmCalls,
jev: data.jevCalls,
simTime: data.sample.time,
transported: data.transported,
models: [...new Set(data.receipts.map((r: { model: string }) => r.model))],
});
expect(data.mode).toBe('api');
expect(data.state, data.error).toBe('succeeded');
expect(data.succeeded).toBe(true);
expect(data.transported).toBeGreaterThanOrEqual(0.5);
expect(data.llmCalls).toBe(1);
expect(data.jevCalls).toBe(11);
expect(data.receipts).toHaveLength(12);
await page.screenshot({ path: info.outputPath('live-workbench.png') });
} catch (error) {
// Playwright action errors may quote input values. Close before automatic DOM diagnostics.
await page.close().catch(() => undefined);
// eslint-disable-next-line preserve-caught-error -- The original cause may contain the private bridge token.
throw new Error(String(error).replaceAll(service.token, '[redacted]'));
} finally {
await service.stop();
}
});
+103
View File
@@ -0,0 +1,103 @@
import { test, expect } from '@playwright/test';
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import preset from '../../contracts/lekiwi-pick-place-v1.json' with { type: 'json' };
import type {} from '../physics/agent';
for (const seed of preset.frozenSeeds) {
test(`规则物理基线:冻结种子 ${seed.id},双指抓持/搬运/释放/撤离`, async ({ page }, info) => {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto('/physics/agent.html');
await page.waitForFunction(() => Boolean(window.lekiwiAgent));
await page.evaluate(
(url) => window.lekiwiAgent.boot(url),
`/@fs${resolve('build/lekiwi/lekiwi-v1.zip')}`,
);
try {
const result = await page.evaluate((seed) => window.lekiwiAgent.baseline(seed), seed.id);
await writeFile(info.outputPath('physical-evidence.json'), JSON.stringify(result, null, 2));
console.log('baseline', { ...result, trace: `${result.trace.length} control samples` });
expect(result.status, result.error).toBe('succeeded');
expect(result.succeeded).toBe(true);
expect(result.transported).toBeGreaterThanOrEqual(preset.transportMin);
expect(result.maxJointSpeed).toBeLessThanOrEqual(preset.jointSpeedStop);
const goal = preset.goal.position.map((v, i) => v + (seed.offset[i] ?? 0));
expect(Math.hypot(...result.final.object.map((v, i) => v - goal[i]))).toBeLessThan(
preset.placementTolerance,
);
expect(result.final.objectSpeed).toBeLessThan(preset.placementSpeedMax);
expect(result.final.onGoalSupport).toBe(true);
expect(result.final.fingerForces.every((force) => force < preset.fingerForceMin)).toBe(true);
expect(result.final.opening).toBeGreaterThan(0.65);
expect(
Math.hypot(...result.final.object.map((v, i) => v - result.final.tcp[i])),
).toBeGreaterThanOrEqual(preset.retreatMin);
expect(
result.trace.filter((s) => s.phase === 'carry' && s.secure && !s.supported).length,
).toBeGreaterThan(100);
expect(errors).toEqual([]);
} finally {
await page.evaluate(() => window.lekiwiAgent.dispose());
}
});
}
for (const seed of preset.frozenSeeds) {
test(`mock 决策闭环:冻结种子 ${seed.id},逐技能门禁与独立物理成功`, async ({ page }, info) => {
await page.goto('/physics/agent.html');
await page.waitForFunction(() => Boolean(window.lekiwiAgent));
await page.evaluate(
(url) => window.lekiwiAgent.boot(url),
`/@fs${resolve('build/lekiwi/lekiwi-v1.zip')}`,
);
try {
const result = await page.evaluate((id) => window.lekiwiAgent.task(id), seed.id);
await writeFile(info.outputPath('mock-closed-loop.json'), JSON.stringify(result, null, 2));
console.log('mock closed loop', {
state: result.state,
error: result.error,
transported: result.transported,
llm: result.llmCalls,
jev: result.jevCalls,
simTime: result.simTime,
});
expect(result.ownerAtStart).toBe('agent');
expect(result.mode).toBe('mock');
expect(result.state, result.error).toBe('succeeded');
expect(result.succeeded).toBe(true);
expect(result.transported).toBeGreaterThanOrEqual(preset.transportMin);
expect(result.maxJointSpeed).toBeLessThanOrEqual(preset.jointSpeedStop);
expect(result.llmCalls).toBe(1);
expect(result.jevCalls).toBe(11);
expect(result.results).toHaveLength(11);
expect(result.failures).toEqual([]);
expect(result.sample.onGoalSupport).toBe(true);
expect(result.sample.fingerForces.every((v) => v < preset.fingerForceMin)).toBe(true);
expect(result.sample.opening).toBeGreaterThanOrEqual(0.65);
expect(
Math.hypot(...result.sample.object.map((v, i) => v - result.sample.tcp[i])),
).toBeGreaterThanOrEqual(preset.retreatMin);
} finally {
await page.evaluate(() => window.lekiwiAgent.dispose());
}
});
}
test('agent 控制权下同一物理时钟完成基线,不建立第二个环境循环', async ({ page }, info) => {
await page.goto('/physics/agent.html');
await page.waitForFunction(() => Boolean(window.lekiwiAgent));
await page.evaluate(
(url) => window.lekiwiAgent.boot(url),
`/@fs${resolve('build/lekiwi/lekiwi-v1.zip')}`,
);
try {
const result = await page.evaluate(() => window.lekiwiAgent.baseline(0, true));
await writeFile(info.outputPath('governed-baseline.json'), JSON.stringify(result, null, 2));
expect(result.ownerAtStart).toBe('agent');
expect(result.status, result.error).toBe('succeeded');
expect(result.transported).toBeGreaterThanOrEqual(0.5);
} finally {
await page.evaluate(() => window.lekiwiAgent.dispose());
}
});
@@ -0,0 +1,103 @@
import { expect, test } from '@playwright/test';
import { resolve } from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
import { subscriptionOperation } from './fixtures/subscriptionSession';
test.use({ trace: 'off', video: 'off' });
test('显式 opt-in:已人工登录的 ChatGPT 订阅 + Jev 主视口物理验收与退出', async ({
page,
}, info) => {
test.skip(
process.env.LEKIWI_LIVE_SUBSCRIPTION !== '1',
'需要用户在隔离官方页面完成登录;不自动发起登录或使用全局账号',
);
const session = await subscriptionOperation('connect');
const token = String(session.token);
try {
expect(session.llmProtocol).toBe('codex');
const before = await subscriptionOperation('status');
expect(before.loggedIn).toBe(true);
const limitsBefore = await subscriptionOperation('limits');
await page.goto('/');
await page
.locator('input[type="file"]')
.first()
.setInputFiles(resolve('build/lekiwi/lekiwi-v1.zip'));
const dialog = page.getByRole('dialog', { name: '配置 URDF 仿真组件' });
await expect(dialog).toBeVisible({ timeout: 30000 });
await dialog.getByLabel('URDF 机器人 profile').selectOption('lekiwi-v1');
await dialog.getByRole('button', { name: '转换并加载' }).click();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 60000 });
await page.getByRole('tab', { name: '控制台', exact: true }).click();
await page.getByRole('button', { name: 'LeKiwi 智能抓放', exact: true }).click();
const panel = page.getByLabel('LeKiwi 智能抓放任务', { exact: true });
await panel.getByText('模型连接与官方订阅登录', { exact: true }).click();
await panel.getByLabel('本机模型服务', { exact: true }).fill(String(session.address));
await panel.getByLabel('本机服务令牌', { exact: true }).fill(token);
await panel.getByRole('button', { name: '连接本机模型服务', exact: true }).click();
await expect(panel).toContainText('本机桥已连接');
await expect(panel.getByLabel('LLM 协议', { exact: true })).toHaveValue('codex');
await expect(panel.getByLabel('LLM API Key', { exact: true })).toHaveCount(0);
await panel.getByText(/ChatGPT 订阅 · 官方/).click();
await panel.getByRole('button', { name: '刷新账号', exact: true }).click();
await expect(panel).toContainText('ChatGPT 已登录(仅本次服务会话)');
await panel.getByRole('button', { name: '查询官方型号', exact: true }).click();
await expect(panel.getByLabel('LLM 型号', { exact: true })).toHaveValue(
String(session.llmModel),
);
await expect(panel.getByLabel('JEV 型号', { exact: true })).toHaveValue('typesafe/jev-1.13');
await panel.getByText('模型连接与官方订阅登录', { exact: true }).click();
await panel.getByLabel('智能任务决策模式').selectOption('api');
await panel.getByRole('button', { name: '开始任务(真实 API)', exact: true }).click();
await expect(panel.getByTestId('agent-task-state')).toContainText(
/物理验收成功|失败 \/ 安全停止|已取消/,
{ timeout: 240000 },
);
const download = page.waitForEvent('download');
await panel.getByRole('button', { name: '导出任务证据', exact: true }).click();
const data = JSON.parse(await readFile((await (await download).path())!, 'utf8'));
const after = await subscriptionOperation('status');
const evidence = {
source: 'real-chatgpt-subscription-plus-openrouter-jev',
before,
after,
limitsBefore,
...data,
};
const encoded = JSON.stringify(evidence, null, 2);
if (encoded.includes(token) || encoded.includes('auth.openai.com/'))
throw new Error('任务证据包含认证信息');
await writeFile(info.outputPath('subscription-physical-evidence.json'), encoded);
console.log('真实订阅回合', {
state: data.state,
error: data.error,
model: session.llmModel,
llm: data.llmCalls,
jev: data.jevCalls,
simTime: data.sample.time,
transported: data.transported,
});
expect(data.state, data.error).toBe('succeeded');
expect(data.succeeded).toBe(true);
expect(data.transported).toBeGreaterThanOrEqual(0.5);
expect(data.llmCalls).toBe(1);
expect(data.jevCalls).toBe(11);
expect(data.receipts).toHaveLength(12);
expect(data.receipts.find((r: { kind: string }) => r.kind === 'plan').provider).toBe('codex');
expect(after.checkedModels).toContain(session.llmModel);
await page.screenshot({ path: info.outputPath('subscription-workbench.png') });
await panel.getByText('模型连接与官方订阅登录', { exact: true }).click();
await panel.getByRole('button', { name: '退出隔离账号', exact: true }).click();
await expect(panel).toContainText('已退出本次隔离会话');
const loggedOut = await subscriptionOperation('status');
expect(loggedOut.loggedIn).toBe(false);
await writeFile(
info.outputPath('subscription-logout.json'),
JSON.stringify(loggedOut, null, 2),
);
} catch (error) {
await page.close().catch(() => undefined);
// eslint-disable-next-line preserve-caught-error -- Playwright causes can contain the private bridge token.
throw new Error(String(error).replaceAll(token, '[redacted]'));
}
});
@@ -0,0 +1,70 @@
import { expect, test } from '@playwright/test';
import { resolve } from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
test('主工作台:A 导入、mock 单技能暂停/继续、物理成功及证据导出', async ({ page }, info) => {
const errors: string[] = [];
let modelRequests = 0;
page.on('pageerror', (e) => errors.push(e.message));
page.on('request', (r) => {
if (/\/api\/decision\/v1\/(plan|decide)$/.test(r.url())) modelRequests++;
});
await page.goto('/');
await page
.locator('input[type="file"]')
.first()
.setInputFiles(resolve('build/lekiwi/lekiwi-v1.zip'));
const dialog = page.getByRole('dialog', { name: '配置 URDF 仿真组件' });
await expect(dialog).toBeVisible({ timeout: 30000 });
await dialog.getByLabel('URDF 机器人 profile').selectOption('lekiwi-v1');
await dialog.getByRole('button', { name: '转换并加载' }).click();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 60000 });
await page.getByRole('tab', { name: '控制台', exact: true }).click();
await page.getByRole('button', { name: 'LeKiwi 智能抓放', exact: true }).click();
const panel = page.getByLabel('LeKiwi 智能抓放任务', { exact: true });
await expect(panel).toContainText('MOCK(不调用模型 API)');
await panel.getByRole('button', { name: '加载 A 任务场景', exact: true }).click();
await expect(panel).toContainText('A 抓放场景已加载并暂停', { timeout: 60000 });
await panel.getByRole('button', { name: '单技能步进', exact: true }).click();
const status = panel.getByTestId('agent-task-state');
await expect(status).toContainText('任务已暂停', { timeout: 60000 });
await expect(status).toContainText('当前技能:open');
await expect(status).toContainText('Jev 1/60');
await panel.getByRole('button', { name: '继续任务', exact: true }).click();
await expect(status).toContainText('物理验收成功', { timeout: 180000 });
await expect(status).toContainText('Jev 11/60');
const download = page.waitForEvent('download');
await panel.getByRole('button', { name: '导出任务证据', exact: true }).click();
const downloaded = await download;
const evidence = JSON.parse(await readFile((await downloaded.path())!, 'utf8'));
await writeFile(
info.outputPath('workbench-physical-evidence.json'),
JSON.stringify(evidence, null, 2),
);
expect(evidence.mode).toBe('mock');
expect(evidence.succeeded).toBe(true);
expect(evidence.transported).toBeGreaterThanOrEqual(0.5);
expect(evidence.sample.onGoalSupport).toBe(true);
expect(modelRequests).toBe(0);
expect(errors).toEqual([]);
await page.screenshot({ path: info.outputPath('agent-workbench.png') });
await panel.getByRole('button', { name: '重置任务场景', exact: true }).click();
await expect(panel.getByTestId('agent-task-state')).toHaveCount(0, { timeout: 60000 });
await panel.getByLabel('目标 Y', { exact: true }).fill('0.617');
await panel.getByRole('button', { name: '单技能步进', exact: true }).click();
await expect(status).toContainText('任务已暂停', { timeout: 60000 });
const changedDownload = page.waitForEvent('download');
await panel.getByRole('button', { name: '导出任务证据', exact: true }).click();
const changed = JSON.parse(await readFile((await (await changedDownload).path())!, 'utf8'));
expect(changed.goal).toEqual([0.257, 0.617, 0.128]);
expect(changed.runId).not.toBe(evidence.runId);
expect(changed.sample.time).toBeLessThan(2.01);
expect(changed.sample.object[1]).toBeLessThan(0.02); // New source episode, not the old placed cube.
await writeFile(info.outputPath('edited-goal-episode.json'), JSON.stringify(changed, null, 2));
await panel.getByLabel('目标 Y', { exact: true }).fill('0.615');
await expect(status).toContainText('已取消');
await expect(status).toContainText('目标编辑使旧计划失效');
expect(modelRequests).toBe(0);
expect(errors).toEqual([]);
await expect(page.getByRole('button', { name: '强化学习任务', exact: true })).toBeVisible();
});
@@ -0,0 +1,76 @@
import { test, expect } from '@playwright/test';
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
test('完整 CAD 主视口:60 秒渲染/物理性能记录(不承诺固定 FPS)', async ({ page }, info) => {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto('/');
const started = performance.now();
await page
.getByLabel('打开文件', { exact: true })
.setInputFiles(resolve('build/lekiwi/lekiwi-v1.zip'));
const dialog = page.getByRole('dialog', { name: '配置 URDF 仿真组件' });
await expect(dialog).toBeVisible({ timeout: 30000 });
await dialog.getByLabel('URDF 机器人 profile').selectOption('lekiwi-v1');
await dialog.getByRole('button', { name: '转换并加载' }).click();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 60000 });
const loadMs = performance.now() - started;
await page.getByRole('button', { name: '▶ 播放' }).click();
const result = await page.evaluate(async () => {
const path = '/src/stores/useAppStore.ts';
const { useAppStore } = (await import(
/* @vite-ignore */ path
)) as typeof import('../src/stores/useAppStore');
const canvas = document.querySelector('main canvas') as HTMLCanvasElement;
const gl = canvas.getContext('webgl2')!,
ext = gl.getExtension('WEBGL_debug_renderer_info');
const graphics = {
size: [canvas.width, canvas.height],
renderer: ext ? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)) : 'unknown',
};
const start = performance.now(),
frames: number[] = [],
steps: number[] = [];
const longTasks: number[] = [];
const observer = new PerformanceObserver((list) => {
longTasks.push(...list.getEntries().map((e) => e.duration));
});
observer.observe({ type: 'longtask' });
let previous = start;
await new Promise<void>((resolve) => {
const tick = (now: number) => {
frames.push(now - previous);
previous = now;
steps.push(useAppStore.getState().stepMs);
if (now - start >= 60000) resolve();
else requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
observer.disconnect();
const p95 = (values: number[]) =>
[...values].sort((a, b) => a - b)[Math.floor(values.length * 0.95)] ?? 0;
const state = useAppStore.getState();
return {
graphics,
frames: frames.length,
fps: (1000 * frames.length) / (previous - start),
frameP95Ms: p95(frames),
stepP95Ms: p95(steps),
longTasks,
heap: state.snapshot?.wasmHeapBytes,
simTime: state.snapshot?.time,
diagnostic: state.diagnostic?.summary,
};
});
await writeFile(
info.outputPath('full-cad-performance.json'),
JSON.stringify({ loadMs, ...result }, null, 2),
);
console.log('full CAD performance', { loadMs, ...result });
await page.screenshot({ path: info.outputPath('full-cad.png') });
expect(errors).toEqual([]);
expect(result.diagnostic).toBeUndefined();
expect(result.simTime).toBeGreaterThan(0);
});
+58
View File
@@ -0,0 +1,58 @@
import { expect, test } from '@playwright/test';
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import type {} from '../physics/runner';
test('完整 314244 面三轮:真实 WASM、随轴运动、ZIP 重导与有界资源', async ({ page }, info) => {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto('/physics/runner.html');
await page.waitForFunction(() => Boolean(window.lekiwiPhysics));
await page.evaluate((path) => window.lekiwiPhysics.boot(path), `/@fs${resolve('build/lekiwi')}`);
try {
const before = await page.evaluate(() => window.lekiwiPhysics.visualStats());
// The three original filenames contain identical bytes; one content-addressed
// OBJ is shared by three independent visual instances.
expect(before.generatedMeshes).toHaveLength(1);
expect(before.wheels).toHaveLength(3);
for (const wheel of before.wheels) {
expect(wheel.faces).toBe(314244);
expect(wheel.vertices).toBeGreaterThan(150000);
expect(wheel.centerError).toBeLessThan(1e-6);
expect(wheel.scale).toEqual([0.001, 0.001, 0.001]);
expect(wheel.collision).toBe(false);
expect(wheel.mass).toBe(0);
}
await page.evaluate(() => window.lekiwiPhysics.drive(0, 0.1, 0, 0.25));
const after = await page.evaluate(() => window.lekiwiPhysics.visualStats());
for (let i = 0; i < 3; i++) {
expect(after.wheels[i].centerError).toBeLessThan(1e-6);
expect(
after.wheels[i].rotation.some((v, j) => Math.abs(v - before.wheels[i].rotation[j]) > 0.01),
).toBe(true);
}
const traces: Awaited<ReturnType<typeof window.lekiwiPhysics.packageRoundtrip>>[] = [];
for (let i = 0; i < 8; i++) {
const result = await page.evaluate(() => window.lekiwiPhysics.packageRoundtrip());
expect(result.oldModelDeleted).toBe(true);
expect(result.oldDataDeleted).toBe(true);
expect(result.workspaces).toHaveLength(1);
expect(result.generatedMeshes).toHaveLength(1);
expect(result.wheels.every((wheel) => wheel.faces === 314244 && !wheel.collision)).toBe(true);
traces.push(result);
}
console.log(
'mesh resources',
traces.map((t) => ({ heap: t.heap, bytes: t.projectBytes, xml: t.xmlFiles })),
);
await writeFile(
info.outputPath('full-mesh-roundtrip.json'),
JSON.stringify({ before, after, traces }, null, 2),
);
expect(traces.every((t) => t.projectBytes === traces[0].projectBytes)).toBe(true);
expect(traces[7].heap).toBeLessThanOrEqual(traces[5].heap + 32 * 1024 * 1024);
expect(errors).toEqual([]);
} finally {
await page.evaluate(() => window.lekiwiPhysics.dispose());
}
});
+187
View File
@@ -0,0 +1,187 @@
import { expect, test } from '@playwright/test';
import { spawn } from 'node:child_process';
import { createServer } from 'node:net';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
const mobilePython = process.env.MOBILE_TRAINING_PYTHON ?? resolve('build/venvs/mobile/bin/python');
const bundle =
process.env.MOBILE_BUNDLE_ZIP ??
resolve('../Reference_Projects/LeKiwi/New_urdf/robot_urdf_bundle (1).zip');
for (const [taskId, file] of [
['MobileManipulator-LeKiwi-v1', resolve('build/lekiwi/lekiwi-v1.zip')],
['MobileManipulator-LeKiwi-Bundle', bundle],
]) {
test(`主控制台真实一键训练及策略导入:${taskId}`, async ({ page }, info) => {
test.skip(
!existsSync(mobilePython) || !existsSync(file),
'需要 MuJoCo 3.11/SB3 独立环境及机器人资产',
);
const port = await new Promise<number>((resolvePort) => {
const listener = createServer();
listener.listen(0, '127.0.0.1', () => {
const port = (listener.address() as { port: number }).port;
listener.close(() => resolvePort(port));
});
});
const root = info.outputPath('training');
await mkdir(root, { recursive: true });
const token = 'oneclick-test-token';
const endpoint = `http://127.0.0.1:${port}`;
const process = spawn(
resolve('.venv/bin/python'),
[
'-u',
'training_server/server.py',
'--port',
String(port),
'--trainer-root',
root,
'--mobile-python',
mobilePython,
'--token',
token,
],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
let output = '';
process.stdout.on('data', (data) => {
output += String(data);
});
process.stderr.on('data', (data) => {
output += String(data);
});
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
try {
await expect
.poll(
async () => {
try {
return (
await fetch(`${endpoint}/api/training/health`, {
headers: { Authorization: `Bearer ${token}` },
})
).ok;
} catch {
return false;
}
},
{ timeout: 30000 },
)
.toBe(true);
await page.goto('/');
await page.locator('input[type="file"]').first().setInputFiles(file);
const dialog = page.getByRole('dialog', { name: '配置 URDF 仿真组件' });
await expect(dialog).toBeVisible({ timeout: 30000 });
// v1 also covers migration from the existing external-control profile at start.
await dialog
.getByLabel('URDF 机器人 profile')
.selectOption(taskId === 'MobileManipulator-LeKiwi-v1' ? 'lekiwi-v1' : taskId);
await dialog.getByRole('button', { name: '转换并加载' }).click();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 60000 });
await page.getByRole('tab', { name: '控制台', exact: true }).click();
if (taskId === 'MobileManipulator-LeKiwi-v1') {
await page.getByRole('button', { name: 'LeKiwi 智能抓放', exact: true }).click();
const agent = page.getByLabel('LeKiwi 智能抓放任务', { exact: true });
await agent.getByRole('button', { name: '单技能步进', exact: true }).click();
await expect(agent.getByTestId('agent-task-state')).toContainText('任务已暂停', {
timeout: 60000,
});
}
await page.getByRole('button', { name: '强化学习任务', exact: true }).click();
await page.getByLabel('本地训练服务地址').fill(endpoint);
await page.getByLabel('训练服务访问令牌').fill(token);
await page.getByRole('button', { name: '连接', exact: true }).click();
await expect(page.getByLabel('训练任务')).toHaveValue(taskId);
await page.getByLabel('训练迭代', { exact: true }).fill('2');
await page.getByLabel('每环境采样步数').fill('8');
await expect(page.getByLabel('移动操作训练阶段')).toHaveValue('navigate');
await page.getByLabel('独立评估回合').fill('2');
await page.getByLabel('导航启动示教步数(仅初训)').fill('0');
const jobResponse = page.waitForResponse(
(response) =>
response.url() === `${endpoint}/api/training/jobs` &&
response.request().method() === 'POST',
);
await page.getByRole('button', { name: '发起本地训练' }).click();
const created = (await (await jobResponse).json()) as { id: string };
await expect(page.getByRole('button', { name: '导入策略', exact: true })).toBeEnabled({
timeout: 60000,
});
await page.getByRole('button', { name: '导入策略', exact: true }).click();
await expect
.poll(
async () =>
page.evaluate(async () => {
const path = '/src/stores/useAppStore.ts';
const { useAppStore } = (await import(
/* @vite-ignore */ path
)) as typeof import('../src/stores/useAppStore');
return useAppStore.getState().snapshot?.rlPolicy?.inferenceCount ?? 0;
}),
{ timeout: 30000 },
)
.toBeGreaterThan(0);
const job = await (
await fetch(`${endpoint}/api/training/jobs/${created.id}`, {
headers: { Authorization: `Bearer ${token}` },
})
).json();
expect(job.state).toBe('succeeded');
expect(job.logs.join('\n')).toContain('Mean reward:');
expect(job.deployment.input.shape).toEqual([1, 92]);
expect(job.deployment.trainingStage).toBe('navigate');
expect(job.deployment.evaluation.episodes).toBe(2);
expect(job.deployment.evaluation.safetyStops).toBe(0);
expect(job.deployment.evaluation.maxJointVelocity).toBeLessThan(2);
expect(job.deployment.output.shape).toEqual([1, 12]);
if (taskId === 'MobileManipulator-LeKiwi-v1') {
const scenes = (await readdir(root, { recursive: true })).filter((name) =>
name.endsWith('.training.xml'),
);
expect(scenes.length).toBeGreaterThan(0);
for (const scene of scenes)
expect(await readFile(resolve(root, scene), 'utf8')).not.toContain('__agent_');
await expect(page.getByTestId('agent-task-state')).toContainText('已取消');
}
await writeFile(info.outputPath('job.json'), JSON.stringify(job, null, 2));
await page.screenshot({ path: info.outputPath('unified-training.png') });
expect(errors).toEqual([]);
if (taskId === 'MobileManipulator-LeKiwi-v1') {
await page.getByRole('button', { name: '接续此作业(保留权重)' }).click();
await expect(page.getByLabel('接续作业 ID')).toHaveValue(created.id);
const resumedResponse = page.waitForResponse(
(response) =>
response.url() === `${endpoint}/api/training/jobs` &&
response.request().method() === 'POST',
);
await page.getByRole('button', { name: '发起本地训练' }).click();
const response = await resumedResponse;
expect(response.status()).toBe(202);
const resumed = await response.json();
await expect(page.getByRole('button', { name: '导入策略', exact: true })).toBeEnabled({
timeout: 60000,
});
const second = await (
await fetch(`${endpoint}/api/training/jobs/${resumed.id}`, {
headers: { Authorization: `Bearer ${token}` },
})
).json();
expect(second.state).toBe('succeeded');
expect(second.deployment.initialization).toBe('resumed-checkpoint');
expect(second.deployment.trainedTimesteps).toBeGreaterThan(job.deployment.trainedTimesteps);
await writeFile(info.outputPath('resumed-job.json'), JSON.stringify(second, null, 2));
}
} finally {
await page.close();
process.kill('SIGTERM');
await new Promise<void>((resolveExit) => {
if (process.exitCode !== null) resolveExit();
else process.once('exit', () => resolveExit());
});
await writeFile(info.outputPath('server.log'), output.replaceAll(token, '<redacted>'));
}
});
}
+137
View File
@@ -0,0 +1,137 @@
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));
});
}
+181
View File
@@ -0,0 +1,181 @@
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<number>(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<number>(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());
});
+141
View File
@@ -0,0 +1,141 @@
import { expect, test } from '@playwright/test';
import { resolve } from 'node:path';
import { existsSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
test.skip(
process.env.CADWORLD_PRODUCTION_TEST !== '1',
'真实站点检查须显式 opt-in;不使用任何现存密钥',
);
test('生产 HTTPS:模型导入、Pyodide、精简设置、安全会话与订阅不可用边界', async ({
page,
}, info) => {
const errors: string[] = [];
const external: string[] = [];
page.on('pageerror', (e) => errors.push(e.message));
page.on('request', (r) => {
if (
r.url().startsWith('http') &&
new URL(r.url()).origin !== 'https://cadworld-sim.robotquan.com'
)
external.push(r.url());
});
const response = await page.goto('/');
expect(response?.status()).toBe(200);
await expect(page).toHaveTitle(/CADWorld Sim/);
await page
.locator('input[type=file]')
.first()
.setInputFiles(resolve('web_platform/fixtures/python_controller/model.xml'));
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 90000 });
await page.getByRole('tab', { name: '控制台', exact: true }).click();
await expect(page.getByRole('button', { name: '强化学习任务', exact: true })).toHaveCount(0);
await page.getByRole('button', { name: /Python 脚本控制/ }).click();
await page
.locator('input[accept=".py,text/x-python"]')
.setInputFiles(resolve('web_platform/fixtures/python_controller/balance.py'));
await expect(page.getByText('倒立摆 PD 平衡控制', { exact: true })).toBeVisible({
timeout: 90000,
});
await page.getByRole('button', { name: '启用', exact: true }).click();
await expect(page.getByRole('button', { name: /Python 脚本控制/ })).toContainText('运行');
await page.getByRole('button', { name: 'LeKiwi 智能抓放', exact: true }).click();
await page.getByText('模型设置 · API_KEY / ChatGPT 订阅', { exact: true }).click();
await expect(page.getByRole('button', { name: '保存设置' })).toBeEnabled({ timeout: 20000 });
await expect(page.getByLabel('本机服务令牌')).toHaveCount(0);
const cookie = (await page.context().cookies()).find((c) => c.name === '__Host-cadworld-session');
expect(cookie?.secure).toBe(true);
expect(cookie?.httpOnly).toBe(true);
expect(cookie?.sameSite).toBe('Strict');
await page.screenshot({ path: info.outputPath('website-api-settings.png') });
await page.getByRole('button', { name: 'ChatGPT 订阅', exact: true }).click();
// No login or model invocation. Availability must be explicit; user's login stays interactive.
await expect(page.getByText(/当前服务器可能无法连接官方服务/)).toBeVisible();
await expect(page.getByRole('button', { name: '登录 ChatGPT 订阅', exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('website-subscription-entry.png') });
await page.getByText('连接测试与凭据管理', { exact: true }).click();
await page.getByRole('button', { name: '清除本次凭据', exact: true }).click();
await expect(page.getByText(/本次凭据已清除/)).toBeVisible();
expect(errors).toEqual([]);
expect(external).toEqual([]);
});
test('官方订阅入口网络探测:不登录账号、不推理,完成后销毁会话', async ({ page }, info) => {
await page.goto('/');
const evidence = await page.evaluate(async () => {
const prefix = '/api/decision/v1';
const headers = { 'Content-Type': 'application/json' };
const bootstrap = await fetch(prefix + '/session', { method: 'POST', headers, body: '{}' });
const session = await bootstrap.json();
const protectedHeaders = {
...headers,
'X-CSRF-Token': session.csrfToken,
'X-Config-Version': String(session.configVersion),
};
const response = await fetch(prefix + '/codex/login', {
method: 'POST',
headers: protectedHeaders,
body: '{}',
});
const result = await response.json();
const cleared = await fetch(prefix + '/session', {
method: 'DELETE',
headers: protectedHeaders,
body: '{}',
});
return {
status: response.status,
error: typeof result.error === 'string' ? result.error : undefined,
deviceFlowOffered: Boolean(result.verificationUrl && result.userCode),
cleared: cleared.status,
accountLoginPerformed: false,
inferenceCalls: 0,
};
});
expect(evidence.cleared).toBe(200);
expect([200, 409, 429, 502, 504]).toContain(evidence.status);
if (evidence.status !== 200) expect(evidence.error).toMatch(/^[a-z0-9_]+$/);
await writeFile(
info.outputPath('subscription-network-status.json'),
JSON.stringify(evidence, null, 2),
);
});
test('公网真实 WASM A 抓放(明确 mock,不调用付费 API)', async ({ page }, info) => {
test.skip(!existsSync('build/lekiwi/lekiwi-v1.zip'), '无本地 A 资产');
let modelCalls = 0;
page.on('request', (r) => {
if (/\/api\/decision\/v1\/(plan|decide|test)$/.test(r.url())) modelCalls++;
});
await page.goto('/');
await page
.locator('input[type=file]')
.first()
.setInputFiles(resolve('build/lekiwi/lekiwi-v1.zip'));
const dialog = page.getByRole('dialog', { name: '配置 URDF 仿真组件' });
await expect(dialog).toBeVisible({ timeout: 30000 });
await dialog.getByLabel('URDF 机器人 profile').selectOption('lekiwi-v1');
await dialog.getByRole('button', { name: '转换并加载' }).click();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 90000 });
await page.getByRole('tab', { name: '控制台', exact: true }).click();
await page.getByRole('button', { name: 'LeKiwi 智能抓放', exact: true }).click();
const panel = page.getByLabel('LeKiwi 智能抓放任务', { exact: true });
await panel.getByRole('button', { name: '开始任务(mock)', exact: true }).click();
await expect(panel.getByTestId('agent-task-state')).toContainText('物理验收成功', {
timeout: 180000,
});
await panel.getByText('高级操作与证据导出', { exact: true }).click();
const download = page.waitForEvent('download');
await panel.getByRole('button', { name: '导出任务证据', exact: true }).click();
const data = JSON.parse(await readFile((await (await download).path())!, 'utf8'));
expect(data.succeeded).toBe(true);
expect(data.transported).toBeGreaterThanOrEqual(0.5);
expect(data.mode).toBe('mock');
expect(modelCalls).toBe(0);
await writeFile(
info.outputPath('production-mock-physical-evidence.json'),
JSON.stringify(data, null, 2),
);
await page.screenshot({ path: info.outputPath('production-physical-success.png') });
});
+119
View File
@@ -0,0 +1,119 @@
import { expect, test } from '@playwright/test';
import { resolve } from 'node:path';
import { existsSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
const prefix = '/api/decision/v1';
test('网站生产构建:精简设置、同源 Cookie/CSRF、双浏览器隔离与假 HTTP 上游', async ({
page,
browser,
}, info) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto('/');
await expect(page).toHaveTitle(/CADWorld Sim/);
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 page.getByRole('tab', { name: '控制台', exact: true }).click();
await expect(page.getByRole('button', { name: '强化学习任务', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: '开源项目 / 外部控制', exact: true })).toHaveCount(
0,
);
await page.getByRole('button', { name: 'LeKiwi 智能抓放', exact: true }).click();
await page.getByText('模型设置 · API_KEY / ChatGPT 订阅', { exact: true }).click();
await expect(page.getByRole('button', { name: '保存设置' })).toBeEnabled();
await expect(page.getByLabel('本机服务令牌')).toHaveCount(0);
await page.getByLabel('LLM API_KEY', { exact: true }).fill('e2e-llm-fixture-not-real');
await page.getByLabel('Jev API_KEY(OpenRouter)').fill('e2e-jev-fixture-not-real');
await page.getByRole('button', { name: '保存设置', exact: true }).click();
await expect(page.getByRole('status').filter({ hasText: '设置已保存' })).toBeVisible();
await expect(page.getByLabel('LLM API_KEY', { exact: true })).toHaveValue('');
await page.getByText('连接测试与凭据管理', { exact: true }).click();
await page.getByRole('button', { name: '测试 LLM(可能计费)', exact: true }).click();
await expect(page.getByText(/LLM 连接测试通过/)).toBeVisible();
const other = await browser.newContext();
try {
const second = await other.newPage();
await second.goto('/');
const fresh = await second.evaluate(async (prefix) => {
const response = await fetch(prefix + '/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
return response.json();
}, prefix);
expect(fresh.ready).toBe(false);
const state = await page.evaluate(
async (prefix) => (await fetch(prefix + '/status')).json(),
prefix,
);
expect(state.ready).toBe(true);
const forbidden = await page.evaluate(
async (prefix) =>
(
await fetch(prefix + '/configuration', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
).status,
prefix,
);
expect(forbidden).toBe(403);
expect(await page.evaluate(() => document.cookie)).not.toContain('cadworld');
expect(
await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage })),
).not.toContain('e2e-llm');
} finally {
await other.close();
}
await page.getByRole('button', { name: 'ChatGPT 订阅', exact: true }).click();
await expect(page.getByRole('button', { name: '登录 ChatGPT 订阅', exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('website-settings.png') });
expect(errors).toEqual([]);
});
test('网站 A 模型:真实 WASM、显式 mock 抓放和证据导出(无远端模型调用)', async ({
page,
}, info) => {
test.skip(!existsSync('build/lekiwi/lekiwi-v1.zip'), '需要本地 A 模型,不自动公开或下载资产');
let calls = 0;
page.on('request', (r) => {
if (/\/api\/decision\/v1\/(plan|decide)$/.test(r.url())) calls++;
});
await page.goto('/');
await page
.locator('input[type=file]')
.first()
.setInputFiles(resolve('build/lekiwi/lekiwi-v1.zip'));
const dialog = page.getByRole('dialog', { name: '配置 URDF 仿真组件' });
await expect(dialog).toBeVisible({ timeout: 30000 });
await dialog.getByLabel('URDF 机器人 profile').selectOption('lekiwi-v1');
await dialog.getByRole('button', { name: '转换并加载' }).click();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 60000 });
await page.getByRole('tab', { name: '控制台', exact: true }).click();
await page.getByRole('button', { name: 'LeKiwi 智能抓放', exact: true }).click();
const panel = page.getByLabel('LeKiwi 智能抓放任务', { exact: true });
await panel.getByRole('button', { name: '开始任务(mock)', exact: true }).click();
await expect(panel.getByTestId('agent-task-state')).toContainText('物理验收成功', {
timeout: 180000,
});
await panel.getByText('高级操作与证据导出', { exact: true }).click();
const downloaded = page.waitForEvent('download');
await panel.getByRole('button', { name: '导出任务证据', exact: true }).click();
const evidence = JSON.parse(await readFile((await (await downloaded).path())!, 'utf8'));
expect(evidence.mode).toBe('mock');
expect(evidence.succeeded).toBe(true);
expect(evidence.transported).toBeGreaterThanOrEqual(0.5);
expect(evidence.sample.onGoalSupport).toBe(true);
expect(calls).toBe(0);
await writeFile(
info.outputPath('website-physical-evidence.json'),
JSON.stringify(evidence, null, 2),
);
await page.screenshot({ path: info.outputPath('website-grasp.png') });
});