Files
Mujoco_WASM/web_platform/e2e/ui-tuning-system.spec.ts
T
chenlin cb3fb47561
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) 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
feat(web-platform): release V0.9.4 前端设计优化
2026-09-09 13:15:46 +08:00

300 lines
16 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test';
import { tuningFixture } from './tuningFixture';
const sizes = [
[1920, 1080],
[1440, 900],
[1366, 768],
[1024, 768],
[768, 800],
] as const;
async function noOverflow(page: Page) {
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
const overflowing = await page
.locator('#root')
.evaluate((root) =>
[...root.querySelectorAll<HTMLElement>('main, nav, header, [role="dialog"]')]
.filter(
(el) => el.getClientRects().length && el.getBoundingClientRect().right > innerWidth + 1,
)
.map((el) => el.tagName),
);
expect(overflowing).toEqual([]);
}
for (const theme of ['dark', 'light'] as const)
for (const [width, height] of sizes) {
test(`调参与系统 ${theme} ${width}`, async ({ page }, info) => {
await page.setViewportSize({ width, height });
await page.addInitScript(
(value) => localStorage.setItem('mujoco-platform-theme', value),
theme,
);
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
const session = tuningFixture();
const actions: { method: string; path: string; body: unknown }[] = [];
await page.route('**/api/tuning/**', async (route) => {
const req = route.request(),
path = new URL(req.url()).pathname;
expect(req.headers().authorization).toBe('Bearer ui-mock-token');
if (req.method() !== 'GET')
actions.push({ method: req.method(), path, body: req.postDataJSON() });
let body: unknown = session;
if (path.endsWith('/capabilities'))
body = { configured: true, ready: true, model: 'UI Mock Agent', pretrainedSources: [] };
else if (path === '/api/tuning/sessions') body = { sessions: [session] };
else if (path.endsWith('/metrics'))
body = {
series: [
{
tag: 'Train/mean_reward',
points: Array.from({ length: 30 }, (_, step) => ({
step,
wallTime: step,
value: step / 10 + Math.sin(step) / 4,
})),
},
],
};
else if (path.endsWith('/approve')) {
session.proposals[0].state = 'approved';
session.state = 'paused';
} else if (req.method() === 'DELETE') session.state = 'cancelled';
await route.fulfill({ json: body });
});
await page.goto('/tuning.html');
await expect(page.getByRole('heading', { name: 'Go2 自调参' })).toBeVisible();
await expect(page.locator('#root > div')).toHaveClass(new RegExp(`theme-${theme}`));
await noOverflow(page);
await page.screenshot({ path: info.outputPath('tuning-empty.png') });
await page.getByLabel('训练服务地址').fill('http://127.0.0.1:8765');
await page.getByLabel('访问令牌(仅当前标签页)').fill('ui-mock-token');
await page.getByRole('button', { name: '连接/刷新' }).click();
await expect(page.getByRole('img', { name: /收敛曲线/ })).toBeVisible();
await expect(page.getByRole('button', { name: '停止 Session' })).toBeVisible();
const sessions = page.getByRole('region', { name: '会话与 Trial' });
const metrics = page.getByRole('main', { name: '指标与排行' });
const decisions = page.getByRole('complementary', { name: '决策与审批' });
await expect(sessions).toBeVisible();
await expect(metrics).toBeVisible();
await expect(decisions).toBeVisible();
const left = (await sessions.boundingBox())!;
const center = (await metrics.boundingBox())!;
const right = (await decisions.boundingBox())!;
if (width >= 1280) {
expect(Math.abs(left.y - center.y)).toBeLessThan(2);
expect(Math.abs(center.y - right.y)).toBeLessThan(2);
expect(left.x + left.width).toBeLessThanOrEqual(center.x + 1);
expect(center.x + center.width).toBeLessThanOrEqual(right.x + 1);
} else {
expect(left.y + left.height).toBeLessThanOrEqual(center.y + 1);
expect(center.y + center.height).toBeLessThanOrEqual(right.y + 1);
}
await page.getByRole('button', { name: '收敛曲线放大', exact: true }).click();
await page
.getByRole('button', { name: `切换到${theme === 'dark' ? '白天' : '黑夜'}主题` })
.click();
await expect(page.locator('#root > div')).toHaveClass(
new RegExp(`theme-${theme === 'dark' ? 'light' : 'dark'}`),
);
await page
.getByRole('button', { name: `切换到${theme === 'dark' ? '黑夜' : '白天'}主题` })
.click();
await noOverflow(page);
await page.screenshot({ path: info.outputPath('tuning-metrics.png') });
await page.getByRole('heading', { name: 'Trial Leaderboard' }).scrollIntoViewIfNeeded();
await noOverflow(page);
await page.screenshot({ path: info.outputPath('tuning-leaderboard.png') });
await page.getByRole('button', { name: '导入', exact: true }).scrollIntoViewIfNeeded();
await expect(page.getByRole('button', { name: '导入', exact: true })).toBeVisible();
await expect(page.getByText('导入会替换主工作台策略;无主窗口时下载文件。')).toBeVisible();
await noOverflow(page);
await page.screenshot({ path: info.outputPath('tuning-sessions.png') });
await page.getByRole('button', { name: 'Monaco Diff 审查' }).scrollIntoViewIfNeeded();
await noOverflow(page);
await page.screenshot({ path: info.outputPath('tuning-decisions.png') });
await page.getByRole('button', { name: 'Monaco Diff 审查' }).click();
const review = page.getByRole('dialog', { name: /Reward Merge Patch 审查/ });
await expect(review.locator('.monaco-diff-editor')).toBeVisible();
await expect(
review.locator(theme === 'light' ? '.monaco-editor.vs' : '.monaco-editor.vs-dark').first(),
).toBeVisible();
await review
.locator('.monaco-editor')
.last()
.click({ position: { x: 180, y: 80 } });
await page.keyboard.press('Control+Home');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Home');
await page.keyboard.press('Shift+End');
await page.keyboard.insertText(' "pose": 1.2');
await page.evaluate(
(value) => {
localStorage.setItem('mujoco-platform-theme', value);
window.dispatchEvent(new StorageEvent('storage', { key: 'mujoco-platform-theme' }));
},
theme === 'light' ? 'dark' : 'light',
);
await expect(
review.locator(theme === 'light' ? '.monaco-editor.vs-dark' : '.monaco-editor.vs').last(),
).toBeVisible();
await page.evaluate((value) => {
localStorage.setItem('mujoco-platform-theme', value);
window.dispatchEvent(new StorageEvent('storage', { key: 'mujoco-platform-theme' }));
}, theme);
await expect(review.locator('.monaco-editor').last()).toContainText('1.2');
await page.getByLabel('Proposal 审批反馈').fill('界面验收,不启动真实训练');
await noOverflow(page);
await page.screenshot({ path: info.outputPath('tuning-review.png') });
await page.getByRole('button', { name: '批准编辑后的 Patch' }).click();
await expect(review).toBeHidden();
expect(actions.find((item) => item.path.endsWith('/approve'))?.body).toEqual({
feedback: '界面验收,不启动真实训练',
patch: { weights: { pose: 1.2 }, params: {} },
});
await page.getByRole('button', { name: '参数护栏' }).click();
await expect(page.getByRole('dialog', { name: /参数安全护栏/ })).toBeVisible();
await noOverflow(page);
await page.screenshot({ path: info.outputPath('tuning-constraints.png') });
await page.keyboard.press('Escape');
await page.getByRole('button', { name: '停止 Session' }).click();
expect(actions.filter((item) => item.method === 'DELETE')).toHaveLength(1);
expect(
actions.filter((item) => item.path === '/api/tuning/sessions' && item.method === 'POST'),
).toHaveLength(0);
await page.goto('/');
await expect(page.getByRole('region', { name: '导入模型工程' })).toBeVisible();
await page.getByRole('button', { name: '更多工作台操作' }).click();
await page.getByRole('menuitem', { name: '工作台设置' }).click();
await expect(page.getByRole('dialog', { name: '工作台设置' })).toBeVisible();
await noOverflow(page);
await page.screenshot({ path: info.outputPath('settings.png') });
await page.keyboard.press('Escape');
await page.getByRole('button', { name: '布局设置' }).click();
await page.screenshot({ path: info.outputPath('layout.png') });
await page.keyboard.press('Escape');
await page.keyboard.press('Control+k');
await page.getByLabel('搜索命令').fill('快捷');
await expect(page.getByLabel('搜索命令')).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.getByRole('dialog', { name: '快捷键与视口操作' })).toBeVisible();
await page.screenshot({ path: info.outputPath('help.png') });
await page.keyboard.press('Escape');
await page.keyboard.press('Control+k');
await page.getByLabel('搜索命令').fill('无匹配命令');
await expect(page.getByText('没有匹配的命令')).toBeVisible();
await page.screenshot({ path: info.outputPath('command-empty.png') });
expect(errors).toEqual([]);
});
}
for (const theme of ['dark', 'light'] as const)
test(`源码与导入风险 ${theme}`, async ({ page }, info) => {
await page.setViewportSize({ width: 768, height: 800 });
await page.addInitScript(
(value) => localStorage.setItem('mujoco-platform-theme', value),
theme,
);
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto('/');
await page.locator('#mujoco-project-files').setInputFiles({
name: 'model.urdf',
mimeType: 'text/xml',
buffer: Buffer.from(
'<robot name="jointed"><link name="base"><inertial><mass value="1"/><origin xyz="0 0 0"/><inertia ixx=".1" iyy=".1" izz=".1" ixy="0" ixz="0" iyz="0"/></inertial><visual><geometry><box size=".4 .4 .2"/></geometry></visual></link><link name="arm"><inertial><mass value=".2"/><origin xyz="0 0 .25"/><inertia ixx=".01" iyy=".01" izz=".01" ixy="0" ixz="0" iyz="0"/></inertial><visual><origin xyz="0 0 .25"/><geometry><box size=".1 .1 .5"/></geometry></visual></link><joint name="shoulder" type="revolute"><parent link="base"/><child link="arm"/><origin xyz="0 0 .1"/><axis xyz="0 1 0"/><limit lower="-1" upper="1" effort="10" velocity="2"/></joint></robot>',
),
});
const urdf = page.getByRole('dialog', { name: '配置 URDF 仿真组件' });
await expect(urdf.getByText(/生成不限幅 motor 控制输入/)).toBeVisible();
await page.screenshot({ path: info.outputPath('urdf-options.png') });
await urdf.getByRole('button', { name: '转换并加载' }).click();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
await page.getByRole('button', { name: '通知中心' }).click();
const notifications = page.getByRole('dialog', { name: '通知中心' });
for (const summary of await notifications.getByText('事件详情', { exact: true }).all())
await summary.click();
await page.screenshot({ path: info.outputPath('notifications.png') });
await page.getByRole('button', { name: '事件日志', exact: true }).click();
await expect(page.getByRole('dialog', { name: '诊断与事件日志' })).toBeVisible();
await page.screenshot({ path: info.outputPath('diagnostics.png') });
await page.keyboard.press('Escape');
await page.getByRole('button', { name: '源代码', exact: true }).click();
const source = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await expect(source.locator('.monaco-editor')).toBeVisible();
await noOverflow(page);
await page.screenshot({ path: info.outputPath('source.png') });
await source.locator('.monaco-editor').click({ position: { x: 200, y: 100 } });
await page.keyboard.press('Control+End');
await page.keyboard.insertText('\n');
await expect(source.getByText('已修改', { exact: true })).toBeVisible();
await page.keyboard.press('Escape');
const confirm = page.getByRole('dialog', { name: '放弃未保存的修改?' });
await expect(confirm.getByText(/无法恢复/)).toBeVisible();
await page.screenshot({ path: info.outputPath('confirm.png') });
await page.keyboard.press('Escape');
await expect(confirm).toBeHidden();
await expect(source).toBeVisible();
await source.getByRole('button', { name: '关闭源代码编辑器' }).click();
await confirm.getByRole('button', { name: '放弃修改' }).click();
await expect(source).toBeHidden();
expect(errors).toEqual([]);
});
test('主工作台与独立调参标签页双向同步主题', async ({ page, context }) => {
await page.goto('/');
const tuning = await context.newPage();
await tuning.goto('/tuning.html');
await page.getByRole('button', { name: '更多工作台操作' }).click();
await page.getByRole('menuitem', { name: '切换主题' }).click();
await expect(tuning.locator('#root > div')).toHaveClass(/theme-light/);
await tuning.getByRole('button', { name: '切换到黑夜主题' }).click();
await expect(page.locator('#root > div')).toHaveClass(/theme-dark/);
await tuning.close();
});
for (const theme of ['dark', 'light'] as const)
test(`连接错误与入口选择 ${theme}`, async ({ page }, info) => {
await page.setViewportSize({ width: 768, height: 800 });
await page.addInitScript(
(value) => localStorage.setItem('mujoco-platform-theme', value),
theme,
);
await page.route('**/api/tuning/**', (route) =>
route.fulfill({ status: 503, json: { error: '训练服务未就绪,请检查本地服务' } }),
);
await page.goto('/tuning.html');
await page.getByLabel('访问令牌(仅当前标签页)').fill('ui-mock-token');
await page.getByRole('button', { name: '连接/刷新' }).click();
await expect(page.getByRole('alert')).toBeInViewport();
await expect(page.getByRole('button', { name: '关闭错误' })).toBeVisible();
await page.screenshot({ path: info.outputPath('tuning-error.png') });
await page.goto('/');
await page.locator('#mujoco-project-files').setInputFiles(
['first.xml', 'second.xml'].map((name) => ({
name,
mimeType: 'text/xml',
buffer: Buffer.from(
'<mujoco><worldbody><geom type="sphere" size=".1"/></worldbody></mujoco>',
),
})),
);
const entries = page.getByRole('dialog', { name: '选择模型入口' });
await expect(entries).toBeVisible();
await page.screenshot({ path: info.outputPath('entry-selection.png') });
await entries.getByRole('button', { name: /first.xml/ }).click();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
await page.locator('#mujoco-project-files').setInputFiles({
name: 'bad.xml',
mimeType: 'text/xml',
buffer: Buffer.from(
'<mujoco><worldbody><geom type="sphere" size="-1"/></worldbody></mujoco>',
),
});
await expect(page.getByRole('alert')).toContainText('模型编译失败');
await page.getByRole('button', { name: '技术详情', exact: true }).click();
await noOverflow(page);
await page.screenshot({ path: info.outputPath('compile-error.png') });
});