285 lines
15 KiB
TypeScript
285 lines
15 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();
|
|
await expect(page.getByRole('button', { name: /决策与审批 · 待审批 1/ })).toBeVisible();
|
|
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: '会话与 Trial' }).click();
|
|
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: /决策与审批/ }).click();
|
|
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();
|
|
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') });
|
|
});
|