import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import fs from 'node:fs/promises'; import { createServer } from 'node:http'; import path from 'node:path'; import readline from 'node:readline'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const TEMP_ROOT = path.join(ROOT, '.tmp'); const CHANNEL = process.env.ASSISTANT_BROWSER_CHANNEL || (process.platform === 'win32' ? 'msedge' : 'chromium'); const KEEP_ARTIFACTS = process.argv.includes('--keep-artifacts'); const escapeHtml = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[char])); function browserClient(home) { const child = spawn(process.execPath, [path.join(ROOT, 'tools/learning/browser-worker.mjs')], { cwd: ROOT, env: { ...process.env, ASSISTANT_LEARNING_HOME: home, ASSISTANT_BROWSER_CHANNEL: CHANNEL, ASSISTANT_BROWSER_HEADLESS: '1', }, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], }); let sequence = 0; let stderr = ''; let exitResult; let resolveExit; const exited = new Promise(resolve => { resolveExit = resolve; }); const requests = new Map(); const output = readline.createInterface({ input: child.stdout, crlfDelay: Infinity }); output.on('line', line => { let reply; try { reply = JSON.parse(line); } catch { const error = new Error('Worker stdout contains a non-JSON protocol line'); for (const request of requests.values()) { clearTimeout(request.timer); request.reject(error); } requests.clear(); return; } const request = requests.get(reply.id); if (!request) return; requests.delete(reply.id); clearTimeout(request.timer); if (reply.error) { const error = new Error(reply.error.message); error.code = reply.error.code; request.reject(error); } else request.resolve(reply.result); }); child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString()).slice(-12_000); }); child.on('error', error => { for (const request of requests.values()) { clearTimeout(request.timer); request.reject(error); } requests.clear(); }); child.on('exit', (code, signal) => { exitResult = { code, signal }; for (const request of requests.values()) { clearTimeout(request.timer); request.reject(new Error(`Worker exited before replying, code=${code}. ${stderr}`)); } requests.clear(); resolveExit(exitResult); }); return { call(method, params = {}) { const id = `worker-test-${++sequence}`; return new Promise((resolve, reject) => { const timer = setTimeout(() => { requests.delete(id); reject(new Error(`Worker timed out: ${method}. ${stderr}`)); }, 100_000); requests.set(id, { resolve, reject, timer }); child.stdin.write(JSON.stringify({ id, method, params }) + '\n', error => { if (!error) return; clearTimeout(timer); requests.delete(id); reject(error); }); }); }, async close({ timeout = 15_000 } = {}) { if (exitResult) return exitResult; if (!child.stdin.writableEnded) child.stdin.end(); let timer; try { return await Promise.race([exited, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Worker did not release browsers after stdin EOF')), timeout); })]); } catch (error) { child.kill('SIGTERM'); throw error; } finally { clearTimeout(timer); output.close(); } }, }; } async function main() { await fs.mkdir(TEMP_ROOT, { recursive: true }); const runRoot = await fs.mkdtemp(path.join(TEMP_ROOT, 'worker-regression-')); const home = path.join(runRoot, 'learning'); const results = []; let succeeded = false; let slowRequestCount = 0; let resolveSlowNavigation; const slowNavigation = new Promise(resolve => { resolveSlowNavigation = resolve; }); const server = createServer((request, response) => { const url = new URL(request.url, 'http://127.0.0.1'); if (url.pathname === '/fixture/slow') { slowRequestCount += 1; // No response headers: verify is now waiting in its real browser goto, // whose ordinary timeout is 20 seconds. EOF must interrupt it earlier. resolveSlowNavigation(); return; } if (url.pathname !== '/fixture/search') { response.writeHead(404).end(); return; } const keyword = escapeHtml(url.searchParams.get('q') || '初始'); response.setHeader('Content-Type', 'text/html; charset=utf-8'); response.setHeader('Set-Cookie', 'worker-test-session=retained; Max-Age=86400; Path=/; SameSite=Lax'); response.end(`本机学习工作进程验收

材料搜索

${keyword} 的搜索结果
${request.headers.cookie?.includes('worker-test-session=retained') ? 'retained' : 'fresh'}

13812345678

`); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const entryUrl = `http://127.0.0.1:${server.address().port}/fixture/search`; const client = browserClient(home); let restartedClient; const passed = (name, detail = {}) => { results.push({ name, passed: true, ...detail }); console.log(`ok ${results.length} - ${name}`); }; const rejectCode = (method, params, code) => assert.rejects(() => client.call(method, params), error => error.code === code); try { const opened = await client.call('open', { task_id: 'regression-a', account_key: 'test-account', entry_url: entryUrl }); assert.equal(opened.status, 'awaiting_user'); await rejectCode('capture', { task_id: 'regression-a' }, 'AWAITING_USER'); passed('首次打开进入人工确认,未确认不能采集'); await rejectCode('open', { task_id: 'regression-b', account_key: 'test-account', entry_url: entryUrl }, 'ACCOUNT_BUSY'); passed('同一账号不能同时被两个学习任务持有'); const confirmation = await client.call('ready', { task_id: 'regression-a', identity_label: '隔离测试账号' }); assert.equal(confirmation.identity_verification, 'operator_confirmed'); assert.equal(confirmation.manual_confirmation, true); const observation = await client.call('capture', { task_id: 'regression-a', note: '本机测试,无真实平台登录' }); assert.ok(!observation.text.includes('13812345678')); assert.ok(!observation.elements.some(element => element.kind === 'fill' && /phone|手机号/.test(element.selector + ' ' + element.label))); const image = await fs.readFile(observation.screenshot_path); assert.equal(image.subarray(1, 4).toString(), 'PNG'); passed('人工确认来源如实记录,文本及候选排除敏感输入并生成实际 PNG'); await rejectCode('action', { task_id: 'regression-a', kind: 'click', selector: '[data-testid="send"]' }, 'WRITE_ACTION_BLOCKED'); passed('拒绝发送私信动作'); const steps = [ { kind: 'fill', selector: '[data-testid="query"]', parameter: 'keyword' }, { kind: 'click', selector: '[data-testid="search"]' }, { kind: 'read', selector: '[data-testid="results"]', output_name: 'results' }, ]; const verified = await client.call('verify', { task_id: 'regression-a', entry_url: entryUrl, steps, parameters: { keyword: '木材' }, expected_outputs: { results: '木材' } }); assert.equal(verified.passed, true, JSON.stringify(verified.errors)); assert.equal(verified.os, process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'macos' : process.platform); assert.ok(verified.outputs.results.some(item => item.text.includes('木材'))); assert.ok(verified.evidence.length >= 4); passed('参数化填写 搜索 读回实际浏览器结果通过', { os: verified.os, browser_version: verified.browser_version }); const failed = await client.call('verify', { task_id: 'regression-a', entry_url: entryUrl, steps, parameters: { keyword: '水泥' }, expected_outputs: { results: '钢材' } }); assert.equal(failed.passed, false); assert.ok(failed.errors.length > 0); assert.ok(failed.outputs.results.some(item => item.text.includes('水泥'))); passed('错误输出预期保留实际读回并判失败'); await rejectCode('verify', { task_id: 'regression-a', entry_url: entryUrl, steps, parameters: { keyword: '木材' }, expected_outputs: { results: '' } }, 'INVALID_EXPECTED_OUTPUT'); passed('拒绝用空输出预期取得验证通过'); await client.call('close', { task_id: 'regression-a' }); const reopened = await client.call('open', { task_id: 'regression-c', account_key: 'test-account', entry_url: entryUrl }); assert.notEqual(reopened.session_id, opened.session_id); assert.equal(reopened.status, 'awaiting_user'); await rejectCode('capture', { task_id: 'regression-c' }, 'AWAITING_USER'); await client.call('ready', { task_id: 'regression-c', identity_label: '隔离测试账号' }); const reused = await client.call('capture', { task_id: 'regression-c' }); assert.match(reused.text, /retained/); const profiles = await fs.readdir(path.join(home, 'profiles')); assert.equal(profiles.length, 1); passed('关闭重开复用同一资料目录及 Cookie,新会话仍需人工确认'); await rejectCode('not-a-method', {}, 'UNKNOWN_METHOD'); const status = await client.call('status', { task_id: 'regression-c' }); assert.equal(status.status, 'observing'); passed('JSONL 错误响应有代码且后续请求继续正常'); await client.call('close', { task_id: 'regression-c' }); assert.equal((await client.call('status', { task_id: 'regression-c' })).status, 'closed'); passed('显式关闭释放会话,状态如实返回关闭'); await client.call('open', { task_id: 'regression-eof', account_key: 'test-account', entry_url: entryUrl }); await client.call('ready', { task_id: 'regression-eof', identity_label: '隔离测试账号' }); const interruptedVerify = client.call('verify', { task_id: 'regression-eof', entry_url: new URL('/fixture/slow', entryUrl).href, steps, parameters: { keyword: '木材' }, expected_outputs: { results: '木材' }, }).then(result => ({ result }), error => ({ error: error.message })); let navigationTimer; try { await Promise.race([slowNavigation, new Promise((_, reject) => { navigationTimer = setTimeout(() => reject(new Error('Verify never reached the pending local navigation')), 8_000); })]); } finally { clearTimeout(navigationTimer); } const closingStarted = Date.now(); const exited = await client.close({ timeout: 8_000 }); const elapsed = Date.now() - closingStarted; assert.deepEqual(exited, { code: 0, signal: null }); assert.ok(elapsed < 8_000, `EOF shutdown took ${elapsed} ms`); const interrupted = await interruptedVerify; assert.ok(interrupted.error || interrupted.result?.passed === false, 'Interrupted verify must never pass'); assert.equal(slowRequestCount, 1, 'Interrupted navigation must not be replayed'); passed('verify执行中EOF立即中断并正常退出,不等待20秒导航超时或重放', { elapsed_ms: elapsed }); restartedClient = browserClient(home); const afterEof = await restartedClient.call('open', { task_id: 'regression-after-eof', account_key: 'test-account', entry_url: entryUrl }); assert.equal(afterEof.status, 'awaiting_user'); await restartedClient.call('ready', { task_id: 'regression-after-eof', identity_label: '隔离测试账号' }); const afterRestart = await restartedClient.call('capture', { task_id: 'regression-after-eof' }); assert.match(afterRestart.text, /retained/); assert.equal((await fs.readdir(path.join(home, 'profiles'))).length, 1); passed('EOF后新进程重开原资料目录,Cookie保留且浏览器锁释放'); succeeded = true; await fs.writeFile(path.join(runRoot, 'report.json'), JSON.stringify({ passed: results.length, results }, null, 2)); console.log(JSON.stringify({ passed: results.length, browser_channel: CHANNEL, artifacts: KEEP_ARTIFACTS ? runRoot : 'removed after success' })); } finally { try { await client.close(); await restartedClient?.close(); } finally { await new Promise(resolve => server.close(resolve)); } if (succeeded && !KEEP_ARTIFACTS) { // Only remove the exact directory created by this run under this workspace. if (path.dirname(runRoot) !== TEMP_ROOT || !path.basename(runRoot).startsWith('worker-regression-')) { throw new Error('Refusing to remove a path outside this test run'); } await fs.rm(runRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 300 }); } else console.log(`验收证据保留在 ${runRoot}`); } } main().catch(error => { console.error(error); process.exitCode = 1; });