feat: release v1.0.2 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) (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
集成服务器托管模型、自然语言移动与有界抓放、内置 LeKiwi URL 导入和双摄像头;同步部署契约与指定域名 iframe 白名单,保留原有物理安全、会话及调用预算防护。 更新 npm 包及锁文件版本、CHANGELOG 与发布文档。提交前 typecheck、120 项定向前端测试和 44 项后端测试通过(3 项可选跳过);真实 v2 云模型抓放仍待单独验收,不包含运行密钥或构建产物。
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import { expect, test, type Page, type TestInfo } from '@playwright/test';
|
||||
import { resolve } from 'node:path';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { Quaternion, Vector3 } from 'three';
|
||||
|
||||
// Vite dev also serves a tiny JS URL module at .zip?import&url; only count/intercept ZIP bytes.
|
||||
const zipRequest = /\/[^/?]*lekiwi-v1[^/?]*\.zip$/;
|
||||
|
||||
async function verifyBuiltinCameras(page: Page, info: TestInfo) {
|
||||
await page.getByRole('button', { name: '源代码', exact: true }).click();
|
||||
const editor = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
|
||||
const download = page.waitForEvent('download');
|
||||
await editor.getByRole('button', { name: '下载', exact: true }).click();
|
||||
const xml = await readFile((await (await download).path())!, 'utf8');
|
||||
const cameras = await page.evaluate((source) => {
|
||||
const doc = new DOMParser().parseFromString(source, 'application/xml');
|
||||
return Array.from(doc.querySelectorAll('camera')).map((camera) => {
|
||||
const frames = [];
|
||||
for (
|
||||
let element: Element | null = camera;
|
||||
element?.tagName !== 'worldbody';
|
||||
element = element.parentElement
|
||||
) {
|
||||
if (!element) throw new Error('Camera is not mounted in worldbody');
|
||||
frames.unshift({
|
||||
name: element.getAttribute('name'),
|
||||
pos: (element.getAttribute('pos') ?? '0 0 0').split(/\s+/).map(Number),
|
||||
quat: (element.getAttribute('quat') ?? '1 0 0 0').split(/\s+/).map(Number),
|
||||
});
|
||||
}
|
||||
return { name: camera.getAttribute('name'), frames };
|
||||
});
|
||||
}, xml);
|
||||
expect(cameras).toHaveLength(2);
|
||||
const tilt = (10 * Math.PI) / 180;
|
||||
const evidence = [];
|
||||
for (const camera of cameras) {
|
||||
const wrist = camera.name === '__platform_camera__wrist';
|
||||
expect(camera.frames.at(-2)?.name).toBe(wrist ? 'Camera-Model-v3-1' : 'Camera-Model-v3');
|
||||
const position = new Vector3();
|
||||
const rotation = new Quaternion();
|
||||
for (const frame of camera.frames) {
|
||||
position.add(new Vector3().fromArray(frame.pos).applyQuaternion(rotation));
|
||||
const [w, x, y, z] = frame.quat;
|
||||
rotation.multiply(new Quaternion(x, y, z, w).normalize());
|
||||
}
|
||||
const forward = new Vector3(0, 0, -1).applyQuaternion(rotation);
|
||||
const up = new Vector3(0, 1, 0).applyQuaternion(rotation);
|
||||
// Independent source-URDF oracle, checked against the actual WASM-exported
|
||||
// model including the profile's CAD yaw and full wrist kinematic chain.
|
||||
const expectedPosition = wrist
|
||||
? new Vector3(0.215391977166, 0.004249997308, 0.168290381278)
|
||||
: new Vector3(0.13095590444, 0, 0.062079251628);
|
||||
const expectedForward = wrist
|
||||
? new Vector3(-Math.sin(tilt), 0, -Math.cos(tilt))
|
||||
: new Vector3(Math.cos(tilt), 0, Math.sin(tilt));
|
||||
const expectedUp = wrist
|
||||
? new Vector3(Math.cos(tilt), 0, -Math.sin(tilt))
|
||||
: new Vector3(-Math.sin(tilt), 0, Math.cos(tilt));
|
||||
expect(position.distanceTo(expectedPosition)).toBeLessThan(2e-6);
|
||||
expect(forward.distanceTo(expectedForward)).toBeLessThan(2e-6);
|
||||
expect(up.distanceTo(expectedUp)).toBeLessThan(2e-6);
|
||||
evidence.push({ ...camera, position, forward, up });
|
||||
}
|
||||
await writeFile(info.outputPath('dual-cameras.json'), JSON.stringify(evidence, null, 2));
|
||||
await page.getByRole('button', { name: '关闭源代码编辑器' }).click();
|
||||
}
|
||||
|
||||
test('URL 自动下载、配置 profile、加载真实 WASM;刷新可重入且不自动运行', async ({
|
||||
page,
|
||||
}, info) => {
|
||||
const downloads: string[] = [];
|
||||
const inference: string[] = [];
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
page.on('request', (r) => {
|
||||
if (zipRequest.test(r.url())) downloads.push(r.url());
|
||||
if (/\/api\/decision\/v1\/(command|plan|decide|test)$/.test(r.url())) inference.push(r.url());
|
||||
});
|
||||
await page.goto('/?robot=lekiwi-v1&utm_source=partner');
|
||||
const play = page.getByRole('button', { name: '▶ 播放' });
|
||||
await expect(play).toBeEnabled({ timeout: 90000 });
|
||||
await expect(page.getByRole('dialog', { name: '配置 URDF 仿真组件' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '关节拖动', exact: true })).toBeDisabled();
|
||||
expect(downloads).toHaveLength(1);
|
||||
await verifyBuiltinCameras(page, info);
|
||||
const selector = page.getByRole('combobox', { name: '摄像头视角' });
|
||||
const frame = page.getByLabel('摄像头画面', { exact: true });
|
||||
await expect(selector.locator('option')).toHaveCount(2);
|
||||
await expect(frame).toHaveAttribute('data-camera-body', 'Camera-Model-v3');
|
||||
await page.screenshot({ path: info.outputPath('builtin-lekiwi-front.png') });
|
||||
await selector.selectOption({ label: '腕部摄像头' });
|
||||
await expect(frame).toHaveAttribute('data-camera-body', 'Camera-Model-v3-1');
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
await page.screenshot({ path: info.outputPath('builtin-lekiwi-wrist.png') });
|
||||
await page.getByRole('button', { name: '隐藏画面' }).click();
|
||||
await expect(frame).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '显示摄像头画面' }).click();
|
||||
await expect(frame).toHaveAttribute('data-camera-body', 'Camera-Model-v3-1');
|
||||
await expect(play).toBeEnabled();
|
||||
expect(downloads).toHaveLength(1); // Switching/hiding never reloads the robot.
|
||||
await page.reload();
|
||||
await expect(play).toBeEnabled({ timeout: 90000 });
|
||||
await expect(page.getByRole('dialog', { name: '配置 URDF 仿真组件' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '关节拖动', exact: true })).toBeDisabled();
|
||||
await expect(selector.locator('option')).toHaveCount(2);
|
||||
await expect(frame).toHaveAttribute('data-camera-body', 'Camera-Model-v3');
|
||||
expect(downloads).toHaveLength(2);
|
||||
expect(inference).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('不带参数保持空白,不下载内置 ZIP', async ({ page }) => {
|
||||
const downloads: string[] = [];
|
||||
page.on('request', (r) => {
|
||||
if (zipRequest.test(r.url())) downloads.push(r.url());
|
||||
});
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('button', { name: '选择文件', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeDisabled();
|
||||
expect(downloads).toEqual([]);
|
||||
});
|
||||
|
||||
for (const query of ['robot=unknown', 'robot=lekiwi-v1&robot=lekiwi-v1']) {
|
||||
test(`非法参数不下载、不加载:${query}`, async ({ page }) => {
|
||||
const downloads: string[] = [];
|
||||
page.on('request', (r) => {
|
||||
if (zipRequest.test(r.url())) downloads.push(r.url());
|
||||
});
|
||||
await page.goto(`/?${query}`);
|
||||
await expect(page.getByRole('alert')).toContainText('内置机器人加载失败');
|
||||
await page.getByRole('button', { name: '技术详情' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('?robot=lekiwi-v1');
|
||||
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeDisabled();
|
||||
expect(downloads).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [404, 200]) {
|
||||
test(`下载失败或损坏包(${status})可恢复手动导入`, async ({ page }) => {
|
||||
await page.route(zipRequest, (route) =>
|
||||
route.fulfill({ status, contentType: 'text/html', body: '<html>not a robot zip</html>' }),
|
||||
);
|
||||
await page.goto('/?robot=lekiwi-v1');
|
||||
await expect(page.getByRole('alert')).toContainText('内置机器人加载失败');
|
||||
await page.getByRole('button', { name: '技术详情' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText(status === 404 ? 'HTTP 404' : '文件不完整');
|
||||
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 expect(page.getByRole('alert')).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
@@ -1,77 +1,36 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { resolve } from 'node:path';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { startLiveDecisionService } from './fixtures/decisionService';
|
||||
import type {} from '../physics/agent';
|
||||
|
||||
test.use({ trace: 'off', video: 'off' });
|
||||
test('显式 opt-in:主工作台 DeepSeek flash + OpenRouter Jev 真实物理回合', async ({
|
||||
test('显式 opt-in:旧 v1 Bearer 客户端真实 API 物理兼容(非 v2 产品验收)', async ({
|
||||
page,
|
||||
}, info) => {
|
||||
test.skip(
|
||||
process.env.LEKIWI_LIVE_API !== '1',
|
||||
'需明确授权 .env 两个角色与一次付费回合;不自动运行',
|
||||
);
|
||||
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 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')}`,
|
||||
);
|
||||
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))],
|
||||
const data = await page.evaluate((connection) => window.lekiwiAgent.task(0, connection), {
|
||||
address: service.endpoint,
|
||||
token: service.token,
|
||||
});
|
||||
expect(data.mode).toBe('api');
|
||||
const encoded = JSON.stringify(data, null, 2);
|
||||
if (encoded.includes(service.token)) throw new Error('证据包含服务令牌');
|
||||
await writeFile(info.outputPath('v1-live-physical-evidence.json'), encoded);
|
||||
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.
|
||||
// eslint-disable-next-line preserve-caught-error -- Private bridge credentials must not enter errors.
|
||||
throw new Error(String(error).replaceAll(service.token, '[redacted]'));
|
||||
} finally {
|
||||
await service.stop();
|
||||
|
||||
@@ -1,103 +1,44 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { resolve } from 'node:path';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { subscriptionOperation } from './fixtures/subscriptionSession';
|
||||
import type {} from '../physics/agent';
|
||||
|
||||
test.use({ trace: 'off', video: 'off' });
|
||||
test('显式 opt-in:已人工登录的 ChatGPT 订阅 + Jev 主视口物理验收与退出', async ({
|
||||
page,
|
||||
}, info) => {
|
||||
test.skip(
|
||||
process.env.LEKIWI_LIVE_SUBSCRIPTION !== '1',
|
||||
'需要用户在隔离官方页面完成登录;不自动发起登录或使用全局账号',
|
||||
);
|
||||
test('显式 opt-in:旧 v1 隔离订阅 API 兼容,非新版语言面板功能', async ({ page }, info) => {
|
||||
test.skip(process.env.LEKIWI_LIVE_SUBSCRIPTION !== '1', '需人工完成隔离官方登录;不使用全局账号');
|
||||
const session = await subscriptionOperation('connect');
|
||||
const token = String(session.token);
|
||||
const connection = { address: String(session.address), 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),
|
||||
expect((await subscriptionOperation('status')).loggedIn).toBe(true);
|
||||
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')}`,
|
||||
);
|
||||
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,
|
||||
});
|
||||
const data = await page.evaluate((c) => window.lekiwiAgent.task(0, c), connection);
|
||||
const encoded = JSON.stringify(data, null, 2);
|
||||
if (encoded.includes(connection.token) || encoded.includes('auth.openai.com/'))
|
||||
throw new Error('证据包含认证信息');
|
||||
await writeFile(info.outputPath('v1-subscription-evidence.json'), encoded);
|
||||
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),
|
||||
);
|
||||
expect(data.receipts.find((r) => r.kind === 'plan')?.provider).toBe('codex');
|
||||
const loggedOut = await page.evaluate(async (c) => {
|
||||
const response = await fetch(c.address + '/api/decision/v1/codex/logout', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + c.token, 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
return response.ok;
|
||||
}, connection);
|
||||
expect(loggedOut).toBe(true);
|
||||
expect((await subscriptionOperation('status')).loggedIn).toBe(false);
|
||||
} 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]'));
|
||||
// eslint-disable-next-line preserve-caught-error -- Avoid exposing private bridge tokens.
|
||||
throw new Error(String(error).replaceAll(connection.token, '[redacted]'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,70 +1,32 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { resolve } from 'node:path';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { collectRobotResults, runWebsiteCommand } from './website.helpers';
|
||||
|
||||
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++;
|
||||
});
|
||||
test('主工作台:加载预设、语言命名目标及连续自由坐标抓放', async ({ page }, info) => {
|
||||
await collectRobotResults(page);
|
||||
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),
|
||||
await page.getByRole('button', { name: '机器人语言控制', exact: true }).click();
|
||||
const panel = page.getByLabel('机器人语言控制', { exact: true });
|
||||
await expect(panel.locator('input[type=password], details')).toHaveCount(0);
|
||||
await panel.getByRole('button', { name: '载入预设场景' }).click();
|
||||
await expect(panel.getByRole('status')).toContainText('预设已载入并暂停', { timeout: 90000 });
|
||||
const first = await runWebsiteCommand(page, '把方块搬到 B 区', info, 'named-target');
|
||||
expect(first.goal).toEqual([0.257, 0.75, 0.128]);
|
||||
expect(first.sample.onGoalSupport).toBe(true);
|
||||
expect(first.transported).toBeGreaterThan(0.7);
|
||||
expect(first.llmCalls).toBe(2);
|
||||
expect(first.jevCalls).toBe(14);
|
||||
expect(first.qualityScore).toBe(75);
|
||||
const second = await runWebsiteCommand(
|
||||
page,
|
||||
'把方块搬到 (0.25,0.50) 米',
|
||||
info,
|
||||
'coordinate-target',
|
||||
);
|
||||
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([]);
|
||||
expect(second.goal).toEqual([0.25, 0.5, 0.128]);
|
||||
expect(second.sample.time).toBeGreaterThan(first.sample.time);
|
||||
expect(second.transported).toBeGreaterThan(0.2);
|
||||
expect(second.runId).not.toBe(first.runId);
|
||||
await expect(page.getByRole('button', { name: '强化学习任务', exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('language-workbench.png') });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
||||
// Isolated development-only kinematic oracle; no UI/production pose-write API.
|
||||
test('真实 WASM 双相机:腕部随臂运动,前置保持底盘固连,转动底盘两者同步', async ({
|
||||
page,
|
||||
}, info) => {
|
||||
await page.goto('/physics/runner.html');
|
||||
const result = await page.evaluate(async () => {
|
||||
const paths = [
|
||||
'/src/simulation/PhysicsAdapter.ts',
|
||||
'/src/project/importer.ts',
|
||||
'/src/project/builtinRobots.ts',
|
||||
];
|
||||
const { MainThreadPhysicsAdapter } = (await import(
|
||||
/* @vite-ignore */ paths[0]
|
||||
)) as typeof import('../src/simulation/PhysicsAdapter');
|
||||
const { importBrowserFiles } = (await import(
|
||||
/* @vite-ignore */ paths[1]
|
||||
)) as typeof import('../src/project/importer');
|
||||
const { BUILTIN_ROBOTS, builtinRobotEnhancements, downloadBuiltinRobot } = (await import(
|
||||
/* @vite-ignore */ paths[2]
|
||||
)) as typeof import('../src/project/builtinRobots');
|
||||
const robot = BUILTIN_ROBOTS[0];
|
||||
const source = await importBrowserFiles([
|
||||
await downloadBuiltinRobot(robot, new AbortController().signal),
|
||||
]);
|
||||
const adapter = new MainThreadPhysicsAdapter();
|
||||
try {
|
||||
await adapter.load(source, robot.entryPath, {
|
||||
robotProfileId: robot.profileId,
|
||||
enhancements: builtinRobotEnhancements(robot.id),
|
||||
});
|
||||
const session = adapter.session!;
|
||||
const { model, data, module } = session;
|
||||
const jointAddress = (name: string) => {
|
||||
for (let id = 0; id < model.njnt; id++) {
|
||||
const joint = model.jnt(id);
|
||||
try {
|
||||
if (joint.name === name) return Number(model.jnt_qposadr[id]);
|
||||
} finally {
|
||||
joint.delete();
|
||||
}
|
||||
}
|
||||
throw new Error(`Missing joint ${name}`);
|
||||
};
|
||||
const read = () =>
|
||||
Array.from({ length: model.ncam }, (_, id) => {
|
||||
const camera = model.cam(id);
|
||||
const body = model.body(Number(model.cam_bodyid[id]));
|
||||
try {
|
||||
return {
|
||||
name: camera.name,
|
||||
body: body.name,
|
||||
position: Array.from(data.cam_xpos.slice(id * 3, id * 3 + 3), Number),
|
||||
forward: [2, 5, 8].map((offset) => -Number(data.cam_xmat[id * 9 + offset])),
|
||||
};
|
||||
} finally {
|
||||
camera.delete();
|
||||
body.delete();
|
||||
}
|
||||
});
|
||||
const before = read();
|
||||
data.qpos[jointAddress('arm_wrist_flex')] += 0.35;
|
||||
module.mj_forward(model, data);
|
||||
const wristMoved = read();
|
||||
const base = jointAddress('__platform_base_freejoint__');
|
||||
data.qpos[base + 3] = Math.SQRT1_2;
|
||||
data.qpos[base + 4] = 0;
|
||||
data.qpos[base + 5] = 0;
|
||||
data.qpos[base + 6] = Math.SQRT1_2;
|
||||
module.mj_forward(model, data);
|
||||
return { before, wristMoved, baseTurned: read(), time: Number(data.time) };
|
||||
} finally {
|
||||
adapter.dispose();
|
||||
}
|
||||
});
|
||||
expect(result.before).toHaveLength(2);
|
||||
const front = (items: typeof result.before) =>
|
||||
items.find((camera) => camera.body === 'Camera-Model-v3')!;
|
||||
const wrist = (items: typeof result.before) =>
|
||||
items.find((camera) => camera.body === 'Camera-Model-v3-1')!;
|
||||
const distance = (a: number[], b: number[]) => Math.hypot(...a.map((value, i) => value - b[i]));
|
||||
expect(distance(front(result.before).position, front(result.wristMoved).position)).toBeLessThan(
|
||||
1e-10,
|
||||
);
|
||||
expect(distance(front(result.before).forward, front(result.wristMoved).forward)).toBeLessThan(
|
||||
1e-10,
|
||||
);
|
||||
expect(
|
||||
distance(wrist(result.before).position, wrist(result.wristMoved).position),
|
||||
).toBeGreaterThan(0.005);
|
||||
expect(distance(wrist(result.before).forward, wrist(result.wristMoved).forward)).toBeGreaterThan(
|
||||
0.1,
|
||||
);
|
||||
for (const before of result.wristMoved) {
|
||||
const after = result.baseTurned.find((camera) => camera.name === before.name)!;
|
||||
const turn = ([x, y, z]: number[]) => [-y, x, z];
|
||||
expect(distance(after.position, turn(before.position))).toBeLessThan(1e-8);
|
||||
expect(distance(after.forward, turn(before.forward))).toBeLessThan(1e-8);
|
||||
}
|
||||
expect(result.time).toBe(0);
|
||||
await writeFile(info.outputPath('dual-camera-kinematics.json'), JSON.stringify(result, null, 2));
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import type {} from '../physics/agent';
|
||||
|
||||
test('v2 物理:连续两次搬运,不重载或移动支撑面', 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 results = await page.evaluate(() =>
|
||||
window.lekiwiAgent.languagePhysical([
|
||||
[0.257, 0.75, 0.128],
|
||||
[0.25, 0.5, 0.128],
|
||||
]),
|
||||
);
|
||||
await writeFile(info.outputPath('continuous-physical.json'), JSON.stringify(results, null, 2));
|
||||
for (const r of results) {
|
||||
console.log('language physical', {
|
||||
status: r.status,
|
||||
error: r.error,
|
||||
goal: r.goal,
|
||||
sample: r.sample,
|
||||
transported: r.transported,
|
||||
});
|
||||
expect(r.status, r.error).toBe('succeeded');
|
||||
expect(r.succeeded).toBe(true);
|
||||
expect(r.sample.onGoalSupport).toBe(true);
|
||||
expect(r.maxJointSpeed).toBeLessThanOrEqual(2);
|
||||
}
|
||||
await page.evaluate(() => window.lekiwiAgent.dispose());
|
||||
});
|
||||
|
||||
test('v2 真实故障:夹爪锁开不会宣称成功', 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 results = await page.evaluate(() =>
|
||||
window.lekiwiAgent.languagePhysical([[0.257, 0.75, 0.128]], 'empty'),
|
||||
);
|
||||
await writeFile(info.outputPath('empty-grasp.json'), JSON.stringify(results, null, 2));
|
||||
expect(results[0].status).toBe('failed');
|
||||
expect(results[0].error).toBe('empty_grasp');
|
||||
expect(results[0].transported).toBe(0);
|
||||
expect(results[0].succeeded).toBe(false);
|
||||
await page.evaluate(() => window.lekiwiAgent.dispose());
|
||||
});
|
||||
|
||||
test('v2 标定:连续台面边界与停靠姿态 scratch IK', 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 result = await page.evaluate(() => window.lekiwiAgent.calibrateLanguage());
|
||||
await writeFile(info.outputPath('calibration.json'), JSON.stringify(result, null, 2));
|
||||
expect(result.unchanged).toBe(true);
|
||||
for (const check of result.checks)
|
||||
expect(check.residual, JSON.stringify(check.target)).toBeLessThan(0.004);
|
||||
await page.evaluate(() => window.lekiwiAgent.dispose());
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const site = 'https://cadworld-sim.robotquan.com';
|
||||
const approvedParent = 'https://cadworld.robotquan.com';
|
||||
|
||||
test.skip(process.env.CADWORLD_PRODUCTION_TEST !== '1', '真实站点非付费检查须显式启用');
|
||||
|
||||
test('生产嵌入响应头:仅允许自身与指定同事域名,API 仍拒绝父页面来源', async ({ request }) => {
|
||||
const response = await request.get(site);
|
||||
expect(response.status()).toBe(200);
|
||||
const headers = response.headers();
|
||||
expect(headers['x-frame-options']).toBeUndefined();
|
||||
expect(headers['content-security-policy']).toContain(`frame-ancestors 'self' ${approvedParent};`);
|
||||
expect(headers['content-security-policy']).toContain("object-src 'none'");
|
||||
expect(headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(headers['strict-transport-security']).toContain('max-age=31536000');
|
||||
const forbidden = await request.post(`${site}/api/decision/v1/session`, {
|
||||
headers: { Origin: approvedParent, 'Sec-Fetch-Site': 'same-site' },
|
||||
data: {},
|
||||
});
|
||||
expect(forbidden.status()).toBe(403);
|
||||
expect((await forbidden.json()).error).toBe('origin_forbidden');
|
||||
expect(forbidden.headers()['access-control-allow-origin']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('生产嵌入:允许来源真实加载应用、Strict Cookie 会话和 CSRF 正常,无推理', async ({
|
||||
page,
|
||||
context,
|
||||
}, info) => {
|
||||
let inference = 0;
|
||||
await page.route('**/api/decision/v1/**', async (route) => {
|
||||
if (/\/(command|plan|decide|test)$/.test(new URL(route.request().url()).pathname)) {
|
||||
inference++;
|
||||
await route.abort();
|
||||
} else await route.continue();
|
||||
});
|
||||
// Only the parent HTML is synthetic. The iframe, assets and session API are live.
|
||||
const parentUrl = `${approvedParent}/__cadworld_embed_acceptance__`;
|
||||
await page.route(parentUrl, (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: `<html><body style="margin:0"><iframe title="CADWorld Sim" src="${site}/" style="width:100%;height:95vh;border:0" allow="fullscreen"></iframe></body></html>`,
|
||||
}),
|
||||
);
|
||||
await page.goto(parentUrl);
|
||||
const frame = page.frameLocator('iframe');
|
||||
await frame.getByRole('tab', { name: '控制台', exact: true }).click();
|
||||
await frame.getByRole('button', { name: '机器人语言控制', exact: true }).click();
|
||||
await expect(frame.getByRole('textbox', { name: '机器人指令' })).toBeEditable();
|
||||
const embedded = page.frames().find((f) => f.url() === `${site}/`);
|
||||
expect(embedded).toBeTruthy();
|
||||
const sessionResult = await embedded!.evaluate(async () => {
|
||||
const prefix = '/api/decision/v1';
|
||||
const bootstrap = await fetch(prefix + '/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
const session = await bootstrap.json();
|
||||
const status = await fetch(prefix + '/status');
|
||||
const csrfGuard = await fetch(prefix + '/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
const destroyed = await fetch(prefix + '/session', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': session.csrfToken },
|
||||
body: '{}',
|
||||
});
|
||||
const expired = await fetch(prefix + '/status');
|
||||
return {
|
||||
bootstrap: bootstrap.status,
|
||||
ready: session.ready,
|
||||
status: status.status,
|
||||
csrfGuard: csrfGuard.status,
|
||||
destroyed: destroyed.status,
|
||||
expired: expired.status,
|
||||
};
|
||||
});
|
||||
expect(sessionResult).toEqual({
|
||||
bootstrap: 200,
|
||||
ready: true,
|
||||
status: 200,
|
||||
csrfGuard: 403,
|
||||
destroyed: 200,
|
||||
expired: 401,
|
||||
});
|
||||
expect(
|
||||
(await context.cookies(site)).filter((c) => c.name.includes('session')).map((c) => c.name),
|
||||
).toHaveLength(0);
|
||||
expect(inference).toBe(0);
|
||||
await page.screenshot({ path: info.outputPath('approved-iframe.png') });
|
||||
});
|
||||
|
||||
test('生产嵌入:未授权同站子域名仍被 CSP 拒绝', async ({ page }) => {
|
||||
const violations: string[] = [];
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') violations.push(message.text());
|
||||
});
|
||||
const parentUrl = 'https://unapproved.robotquan.com/__cadworld_embed_acceptance__';
|
||||
await page.route(parentUrl, (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: `<iframe src="${site}/" title="forbidden"></iframe>`,
|
||||
}),
|
||||
);
|
||||
await page.goto(parentUrl);
|
||||
await expect.poll(() => violations.some((text) => text.includes('frame-ancestors'))).toBe(true);
|
||||
await expect(
|
||||
page.frameLocator('iframe').getByRole('tab', { name: '控制台', exact: true }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, type Page, type TestInfo } from '@playwright/test';
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
||||
export async function collectRobotResults(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
window.addEventListener('robot-task-result', (event) => {
|
||||
sessionStorage.setItem('test-robot-result', JSON.stringify((event as CustomEvent).detail));
|
||||
});
|
||||
});
|
||||
}
|
||||
export async function readRobotResult(page: Page) {
|
||||
await page.waitForFunction(() => sessionStorage.getItem('test-robot-result') !== null);
|
||||
return page.evaluate(() => JSON.parse(sessionStorage.getItem('test-robot-result')!));
|
||||
}
|
||||
export async function loadWebsiteRobot(page: Page) {
|
||||
await collectRobotResults(page);
|
||||
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: '机器人语言控制', exact: true }).click();
|
||||
await expect(page.locator('input[type=password]')).toHaveCount(0);
|
||||
return page.getByLabel('机器人语言控制', { exact: true });
|
||||
}
|
||||
export async function runWebsiteCommand(
|
||||
page: Page,
|
||||
instruction: string,
|
||||
info: TestInfo,
|
||||
name: string,
|
||||
) {
|
||||
const panel = page.getByLabel('机器人语言控制', { exact: true });
|
||||
await page.evaluate(() => sessionStorage.removeItem('test-robot-result'));
|
||||
await panel.getByRole('textbox', { name: '机器人指令' }).fill(instruction);
|
||||
const response = page.waitForResponse((r) => new URL(r.url()).pathname.endsWith('/command'));
|
||||
await panel.getByRole('button', { name: '执行指令', exact: true }).click();
|
||||
expect((await response).status()).toBe(200);
|
||||
await expect(panel.getByTestId('robot-command-state')).toContainText(/已完成|安全停止|已停止/, {
|
||||
timeout: 240000,
|
||||
});
|
||||
const data = await readRobotResult(page);
|
||||
await writeFile(info.outputPath(`${name}.json`), JSON.stringify(data, null, 2));
|
||||
expect(data.state, data.error).toBe('succeeded');
|
||||
expect(data.succeeded).toBe(true);
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { loadWebsiteRobot, runWebsiteCommand } from './website.helpers';
|
||||
|
||||
test('显式授权:公网内置模型,前进/转向/抓放,无手填密钥或自动重试', async ({ page }, info) => {
|
||||
test.skip(process.env.CADWORLD_LIVE_API !== '1', '仅经用户授权后运行,会消耗服务器公共额度');
|
||||
test.setTimeout(360000);
|
||||
const calls = { command: 0, plan: 0, decide: 0, blocked: 0 };
|
||||
let csrf = '',
|
||||
cleanup = false;
|
||||
await page.route('**/api/decision/v1/**', async (route) => {
|
||||
const endpoint = new URL(route.request().url()).pathname.split('/').at(-1);
|
||||
if (endpoint === 'command' || endpoint === 'plan' || endpoint === 'decide') {
|
||||
const limit = { command: 3, plan: 1, decide: 13 };
|
||||
if (calls[endpoint] >= limit[endpoint]) {
|
||||
calls.blocked++;
|
||||
await route.abort();
|
||||
return;
|
||||
}
|
||||
calls[endpoint]++;
|
||||
} else if (endpoint === 'test') {
|
||||
calls.blocked++;
|
||||
await route.abort();
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
let capture: Promise<void> | undefined;
|
||||
const captureSession = () =>
|
||||
page
|
||||
.waitForResponse((r) => r.url().endsWith('/session') && r.request().method() === 'POST')
|
||||
.then(async (r) => {
|
||||
csrf = (await r.json()).csrfToken;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
// Observe capture on all outcomes; no pre-existing keys/Cookies or storage state used.
|
||||
try {
|
||||
await loadWebsiteRobot(page);
|
||||
capture = captureSession();
|
||||
const move = await runWebsiteCommand(page, '前进0.5米', info, 'live-forward');
|
||||
expect(move.distance).toBeGreaterThan(0.49);
|
||||
expect(move.distance).toBeLessThan(0.51);
|
||||
const turn = await runWebsiteCommand(page, '左转90度', info, 'live-turn');
|
||||
expect(turn.turnDegrees).toBeGreaterThan(89);
|
||||
expect(turn.turnDegrees).toBeLessThan(91);
|
||||
expect(turn.start.x).toBeCloseTo(move.sample.x, 2);
|
||||
const panel = page.getByLabel('机器人语言控制', { exact: true });
|
||||
await panel.getByRole('button', { name: '载入预设场景' }).click();
|
||||
await expect(panel.getByRole('status')).toContainText('预设已载入', { timeout: 90000 });
|
||||
const grasp = await runWebsiteCommand(page, '把方块搬到 B 区', info, 'live-pick-place');
|
||||
expect(grasp.transported).toBeGreaterThanOrEqual(0.5);
|
||||
expect(grasp.sample.onGoalSupport).toBe(true);
|
||||
expect(calls).toEqual({ command: 3, plan: 1, decide: 13, blocked: 0 });
|
||||
await page.screenshot({ path: info.outputPath('hosted-control.png') });
|
||||
} finally {
|
||||
await capture;
|
||||
if (csrf)
|
||||
cleanup = await page
|
||||
.evaluate(
|
||||
async (token) =>
|
||||
(
|
||||
await fetch('/api/decision/v1/session', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token },
|
||||
body: '{}',
|
||||
})
|
||||
).status === 200,
|
||||
csrf,
|
||||
)
|
||||
.catch(() => false);
|
||||
await page.close();
|
||||
await writeFile(
|
||||
info.outputPath('hosted-live-summary.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
calls,
|
||||
sessionDestroyed: cleanup,
|
||||
maximumUpstreamCalls: { llm: 4, jev: 16 },
|
||||
retries: 0,
|
||||
credentialInput: false,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
expect(cleanup).toBe(true);
|
||||
});
|
||||
@@ -1,29 +1,15 @@
|
||||
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) => {
|
||||
test.skip(process.env.CADWORLD_PRODUCTION_TEST !== '1', '真实站点非付费检查须显式启用');
|
||||
test('生产 HTTPS:Pyodide 与精简语言界面,不触发推理', async ({ page }) => {
|
||||
let inference = 0;
|
||||
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());
|
||||
if (/\/api\/decision\/v1\/(command|plan|decide|test)$/.test(r.url())) inference++;
|
||||
});
|
||||
const response = await page.goto('/');
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page).toHaveTitle(/CADWorld Sim/);
|
||||
expect((await page.goto('/'))?.status()).toBe(200);
|
||||
await page
|
||||
.locator('input[type=file]')
|
||||
.first()
|
||||
@@ -40,102 +26,95 @@ test('生产 HTTPS:模型导入、Pyodide、精简设置、安全会话与订
|
||||
});
|
||||
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();
|
||||
await page.getByRole('button', { name: '机器人语言控制', exact: true }).click();
|
||||
await expect(page.locator('input[type=password]')).toHaveCount(0);
|
||||
await expect(page.getByRole('textbox', { name: '机器人指令' })).toBeEditable();
|
||||
await page.getByRole('button', { name: '执行指令', exact: true }).click();
|
||||
await expect(page.getByText('请先导入并加载 LeKiwi v1 机器人', { exact: true })).toBeVisible();
|
||||
expect(inference).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(external).toEqual([]);
|
||||
});
|
||||
test('生产 v2:空工作台载入预设、文字目标及双相机,无付费推理', async ({ page }, info) => {
|
||||
let inference = 0;
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.route('**/api/decision/v1/**', async (route) => {
|
||||
if (/\/(command|plan|decide|test)$/.test(new URL(route.request().url()).pathname)) {
|
||||
inference++;
|
||||
await route.abort();
|
||||
} else await route.continue();
|
||||
});
|
||||
expect((await page.goto('/'))?.status()).toBe(200);
|
||||
await page.getByRole('tab', { name: '控制台', exact: true }).click();
|
||||
await page.getByRole('button', { name: '机器人语言控制', exact: true }).click();
|
||||
const panel = page.getByLabel('机器人语言控制', { exact: true });
|
||||
await expect(panel.getByRole('combobox', { name: '预设场景' })).toBeVisible();
|
||||
await expect(panel.locator('input[type=password], details')).toHaveCount(0);
|
||||
await panel.getByRole('button', { name: '载入预设场景' }).click();
|
||||
await expect(panel.getByRole('status')).toContainText('预设已载入并暂停', { timeout: 120000 });
|
||||
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
|
||||
await expect(page.getByRole('combobox', { name: '摄像头视角' }).locator('option')).toHaveCount(2);
|
||||
await panel.getByRole('textbox', { name: '机器人指令' }).fill('把方块搬到 (0.25,0.50) 米');
|
||||
await expect(panel.getByRole('button', { name: '执行指令', exact: true })).toBeEnabled();
|
||||
await panel.getByRole('textbox', { name: '机器人指令' }).fill('停止');
|
||||
await panel.getByRole('button', { name: '执行指令', exact: true }).click();
|
||||
await expect(panel.getByRole('status')).toContainText('用户停止');
|
||||
expect(inference).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
await page.screenshot({ path: info.outputPath('language-v2-production.png') });
|
||||
});
|
||||
|
||||
test('官方订阅入口网络探测:不登录账号、不推理,完成后销毁会话', async ({ page }, info) => {
|
||||
test('生产默认配置受保护、订阅关闭、静态目录无密钥;退出销毁会话,无推理', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const evidence = await page.evaluate(async () => {
|
||||
const result = 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 = {
|
||||
const secured = {
|
||||
...headers,
|
||||
'X-CSRF-Token': session.csrfToken,
|
||||
'X-Config-Version': String(session.configVersion),
|
||||
};
|
||||
const response = await fetch(prefix + '/codex/login', {
|
||||
const config = await fetch(prefix + '/configuration', {
|
||||
method: 'PUT',
|
||||
headers: secured,
|
||||
body: '{}',
|
||||
});
|
||||
const subscription = await fetch(prefix + '/codex/login', {
|
||||
method: 'POST',
|
||||
headers: protectedHeaders,
|
||||
headers: secured,
|
||||
body: '{}',
|
||||
});
|
||||
const result = await response.json();
|
||||
const cleared = await fetch(prefix + '/session', {
|
||||
const env = await fetch('/.env');
|
||||
const secret = await fetch('/run/secrets/model-keys.env');
|
||||
const destroy = await fetch(prefix + '/session', {
|
||||
method: 'DELETE',
|
||||
headers: protectedHeaders,
|
||||
headers: secured,
|
||||
body: '{}',
|
||||
});
|
||||
const expired = await fetch(prefix + '/status');
|
||||
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,
|
||||
ready: session.ready,
|
||||
llm: session.configuration.llm.model,
|
||||
keyTypes: [typeof session.configuration.llm.hasKey, typeof session.configuration.jev.hasKey],
|
||||
locked: config.status,
|
||||
subscription: subscription.status,
|
||||
env: env.status,
|
||||
secret: secret.status,
|
||||
destroyed: destroy.status,
|
||||
expired: expired.status,
|
||||
};
|
||||
});
|
||||
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++;
|
||||
expect(result).toEqual({
|
||||
ready: true,
|
||||
llm: 'deepseek-flash',
|
||||
keyTypes: ['boolean', 'boolean'],
|
||||
locked: 403,
|
||||
subscription: 403,
|
||||
env: 404,
|
||||
secret: 404,
|
||||
destroyed: 200,
|
||||
expired: 401,
|
||||
});
|
||||
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') });
|
||||
});
|
||||
|
||||
@@ -1,119 +1,53 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { resolve } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { loadWebsiteRobot, runWebsiteCommand, readRobotResult } from './website.helpers';
|
||||
|
||||
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 ({
|
||||
test('服务器内置设置+假 HTTP 上游:不同语言改变真实 WASM 位移与角度,不重载机器人', 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') });
|
||||
const panel = await loadWebsiteRobot(page);
|
||||
const forward = await runWebsiteCommand(page, '前进0.5米', info, 'forward');
|
||||
expect(forward.distance).toBeGreaterThan(0.49);
|
||||
expect(forward.distance).toBeLessThan(0.51);
|
||||
const turn = await runWebsiteCommand(page, '左转90度', info, 'turn');
|
||||
expect(turn.turnDegrees).toBeGreaterThan(89);
|
||||
expect(turn.turnDegrees).toBeLessThan(91);
|
||||
expect(turn.start.x).toBeCloseTo(forward.sample.x, 2);
|
||||
const next = await runWebsiteCommand(page, '前进0.2米', info, 'forward-new-heading');
|
||||
expect(next.sample.y - next.start.y).toBeGreaterThan(0.19);
|
||||
const right = await runWebsiteCommand(page, '右转90度', info, 'turn-right');
|
||||
expect(right.turnDegrees).toBeGreaterThan(-91);
|
||||
expect(right.turnDegrees).toBeLessThan(-89);
|
||||
const back = await runWebsiteCommand(page, '后退0.2米', info, 'backward');
|
||||
expect(back.sample.x - back.start.x).toBeLessThan(-0.19);
|
||||
await panel.getByRole('textbox', { name: '机器人指令' }).fill('转动90度');
|
||||
await panel.getByRole('button', { name: '执行指令', exact: true }).click();
|
||||
await expect(panel.getByRole('status')).toContainText('请说明左转还是右转');
|
||||
await expect(panel.getByTestId('robot-command-state')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible();
|
||||
});
|
||||
test('内置设置+假 HTTP 上游:保留完整单方块真实物理抓放,不手填密钥', async ({ page }, info) => {
|
||||
const panel = await loadWebsiteRobot(page);
|
||||
await panel.getByRole('button', { name: '载入预设场景' }).click();
|
||||
await expect(panel.getByRole('status')).toContainText('预设已载入', { timeout: 90000 });
|
||||
const data = await runWebsiteCommand(page, '把方块搬到 B 区', info, 'pick-place');
|
||||
expect(data.transported).toBeGreaterThanOrEqual(0.5);
|
||||
expect(data.sample.onGoalSupport).toBe(true);
|
||||
expect(data.receipts).toHaveLength(14);
|
||||
});
|
||||
test('正在运动时停止:撤销控制权并暂停物理,不把中断当成功', async ({ page }, info) => {
|
||||
const panel = await loadWebsiteRobot(page);
|
||||
await panel.getByRole('textbox', { name: '机器人指令' }).fill('前进0.5米');
|
||||
await panel.getByRole('button', { name: '执行指令', exact: true }).click();
|
||||
await expect(panel.getByTestId('robot-command-state')).toContainText('执行中', {
|
||||
timeout: 30000,
|
||||
});
|
||||
await panel.getByRole('button', { name: '停止', exact: true }).click();
|
||||
await expect(panel.getByTestId('robot-command-state')).toContainText('已停止');
|
||||
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible();
|
||||
const data = await readRobotResult(page);
|
||||
expect(data.state).toBe('cancelled');
|
||||
expect(data.succeeded).toBe(false);
|
||||
expect(data.distance).toBeLessThan(0.1);
|
||||
await writeFile(info.outputPath('cancelled-motion.json'), JSON.stringify(data, null, 2));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user