180 lines
8.0 KiB
TypeScript
180 lines
8.0 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
||
import { spawn } from 'node:child_process';
|
||
import { createHash } from 'node:crypto';
|
||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||
import { createServer } from 'node:net';
|
||
import { tmpdir } from 'node:os';
|
||
import { join, resolve } from 'node:path';
|
||
|
||
const realRoot = process.env.GO2_UPLOAD_REAL_DIR;
|
||
const token = 'local-upload-browser-test-token';
|
||
async function unusedPort() {
|
||
const socket = createServer();
|
||
await new Promise<void>((done) => socket.listen(0, '127.0.0.1', done));
|
||
const address = socket.address();
|
||
const port = typeof address === 'object' && address ? address.port : 0;
|
||
await new Promise<void>((done) => socket.close(() => done()));
|
||
return port;
|
||
}
|
||
for (const panel of ['ordinary', 'tuning'] as const) {
|
||
for (const format of ['pt', 'onnx'] as const) {
|
||
test(`${panel}真实单${format}文件:默认服务空目录上传并选择内容ID`, async ({
|
||
page,
|
||
}, testInfo) => {
|
||
test.skip(!realRoot, '设置GO2_UPLOAD_REAL_DIR;Vite dev提供真实组件,启动独立默认训练服务');
|
||
const root = mkdtempSync(join(tmpdir(), `go2-browser-upload-${panel}-${format}-`));
|
||
const port = await unusedPort();
|
||
const endpoint = `http://127.0.0.1:${port}`;
|
||
const backend = spawn(
|
||
resolve('.venv/bin/python'),
|
||
[
|
||
'-u',
|
||
'training_server/server.py',
|
||
'--port',
|
||
String(port),
|
||
'--token',
|
||
token,
|
||
'--tuning-data-root',
|
||
root,
|
||
],
|
||
{
|
||
cwd: process.cwd(),
|
||
env: { ...process.env, DEEPSEEK_API_KEY: '', WANDB_MODE: 'disabled' },
|
||
},
|
||
);
|
||
let log = '';
|
||
backend.stdout.on('data', (data: Buffer) => {
|
||
log += data.toString();
|
||
});
|
||
backend.stderr.on('data', (data: Buffer) => {
|
||
log += data.toString();
|
||
});
|
||
try {
|
||
await expect
|
||
.poll(
|
||
async () => {
|
||
try {
|
||
return (
|
||
await fetch(`${endpoint}/api/training/health`, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
).status;
|
||
} catch {
|
||
return 0;
|
||
}
|
||
},
|
||
{ timeout: 30_000 },
|
||
)
|
||
.toBe(200);
|
||
await page.goto(process.env.GO2_PRETRAINED_DEV_URL ?? 'http://127.0.0.1:4174');
|
||
// Real production panel components, isolated from unrelated heavy scene import.
|
||
await page.evaluate(async (panel) => {
|
||
localStorage.clear();
|
||
sessionStorage.clear();
|
||
const entry = await (await fetch('/src/main.tsx')).text();
|
||
const reactPath = entry.match(/from "([^"]+\/react\.js[^"]*)"/)![1];
|
||
const domPath = entry.match(/from "([^"]+\/react-dom_client\.js[^"]*)"/)![1];
|
||
const { createElement } = (await import(reactPath)).default;
|
||
const { createRoot } = (await import(domPath)).default;
|
||
const host = document.createElement('div');
|
||
host.id = 'upload-browser-panel';
|
||
host.style.cssText =
|
||
'position:fixed;inset:0;z-index:10000;background:#111;overflow:auto;padding:20px';
|
||
document.body.append(host);
|
||
if (panel === 'ordinary') {
|
||
const path = '/src/training/LocalTrainingPanel.tsx';
|
||
const { LocalTrainingPanel } = await import(path);
|
||
createRoot(host).render(createElement(LocalTrainingPanel, { onPolicyReady: () => {} }));
|
||
} else {
|
||
const path = '/src/tuning/TuningApp.tsx';
|
||
const { TuningApp } = await import(path);
|
||
createRoot(host).render(createElement(TuningApp));
|
||
}
|
||
}, panel);
|
||
const ui = page.locator('#upload-browser-panel');
|
||
await ui
|
||
.getByLabel(panel === 'ordinary' ? '本地训练服务地址' : '训练服务地址', { exact: true })
|
||
.fill(endpoint);
|
||
await ui
|
||
.getByLabel(panel === 'ordinary' ? '训练服务访问令牌' : '访问令牌(仅当前标签页)', {
|
||
exact: true,
|
||
})
|
||
.fill(token);
|
||
await ui
|
||
.getByRole('button', { name: panel === 'ordinary' ? /^连接$/ : '连接/刷新' })
|
||
.click();
|
||
await ui.getByText('上传基础策略', { exact: true }).click();
|
||
await expect(ui.getByLabel('确认Go2 legacy47模板')).toBeEnabled();
|
||
await expect(ui.getByLabel('基础策略', { exact: true })).toHaveValue('');
|
||
await ui.getByLabel('确认Go2 legacy47模板').check();
|
||
const path = join(realRoot!, format === 'pt' ? 'model_10000.pt' : 'policy.onnx');
|
||
const sha = createHash('sha256').update(readFileSync(path)).digest('hex');
|
||
const responsePromise = page.waitForResponse((response) =>
|
||
response.url().startsWith(`${endpoint}/api/training/pretrained-sources/upload?`),
|
||
);
|
||
await ui.getByLabel('选择基础策略文件').setInputFiles(path);
|
||
const response = await responsePromise;
|
||
expect(response.status()).toBe(201);
|
||
const catalog = await (
|
||
await fetch(`${endpoint}/api/training/health`, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
).json();
|
||
const record = catalog.pretrainedSources[0];
|
||
await expect(ui.getByLabel('基础策略', { exact: true })).toHaveValue(record.id);
|
||
await ui.getByText('来源与校验详情', { exact: true }).click();
|
||
await expect(ui.getByText(/原文件 SHA256/)).toContainText(sha);
|
||
await expect(ui.getByText(/上传格式/)).toContainText(format);
|
||
await expect(ui.getByText(/已选择.*仅继承策略权重/)).toBeVisible();
|
||
if (format === 'onnx')
|
||
await expect(ui.getByText(/ONNX统计count合成/)).toContainText('1000000');
|
||
// Intercept only train dispatch; uploads and catalog are real authenticated HTTP.
|
||
// Never dispatch 4096-env training or an Agent/three-seed evaluation from this test.
|
||
let submitted: Record<string, unknown> | undefined;
|
||
await page.route(
|
||
`${endpoint}${panel === 'ordinary' ? '/api/training/jobs' : '/api/tuning/sessions'}`,
|
||
async (route) => {
|
||
if (route.request().method() !== 'POST') return route.continue();
|
||
submitted = route.request().postDataJSON() as Record<string, unknown>;
|
||
await route.fulfill({
|
||
status: 400,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({ error: '浏览器验收只截获启动请求,未训练或调用Agent' }),
|
||
});
|
||
},
|
||
);
|
||
if (panel === 'tuning') await ui.getByLabel('Agent 失败时允许 Optuna fallback').check();
|
||
await ui
|
||
.getByRole('button', { name: panel === 'ordinary' ? '发起本地训练' : '启动自调参' })
|
||
.click();
|
||
await expect.poll(() => submitted?.pretrainedSourceId).toBe(record.id);
|
||
if (panel === 'tuning') expect(submitted?.mode).toBe('approval');
|
||
await expect(ui.getByLabel('基础策略', { exact: true })).toHaveValue(record.id);
|
||
const health = await (
|
||
await fetch(`${endpoint}/api/training/health`, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
).json();
|
||
expect(health.pretrainedSources).toHaveLength(1);
|
||
expect(createHash('sha256').update(readFileSync(path)).digest('hex')).toBe(sha);
|
||
await testInfo.attach('upload-contract', {
|
||
body: JSON.stringify(
|
||
{ root, endpoint, record, submitted, sourceUnchanged: true },
|
||
null,
|
||
2,
|
||
),
|
||
contentType: 'application/json',
|
||
});
|
||
await page.screenshot({ path: testInfo.outputPath('upload.png') });
|
||
} finally {
|
||
backend.kill('SIGTERM');
|
||
await new Promise<void>((done) => {
|
||
if (backend.exitCode !== null) done();
|
||
else backend.once('exit', () => done());
|
||
});
|
||
writeFileSync(join(root, 'browser-server.log'), log);
|
||
}
|
||
});
|
||
}
|
||
}
|