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
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:
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
@@ -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>'));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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') });
|
||||
});
|
||||
@@ -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') });
|
||||
});
|
||||
Reference in New Issue
Block a user