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((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((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 | 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; 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((done) => { if (backend.exitCode !== null) done(); else backend.once('exit', () => done()); }); writeFileSync(join(root, 'browser-server.log'), log); } }); } }