assistant/tools/learning/test-browser-worker.mjs

256 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[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(`<!doctype html><html lang="zh-CN"><head><title>本机学习工作进程验收</title></head>
<body><main><h1>材料搜索</h1><form method="get"><input data-testid="query" name="q" placeholder="搜索关键词"><button data-testid="search">搜索</button></form>
<div data-testid="results">${keyword} 的搜索结果</div><div data-testid="cookie-state">${request.headers.cookie?.includes('worker-test-session=retained') ? 'retained' : 'fresh'}</div>
<form><input name="phone" placeholder="手机号" value="13812345678"><button>验证码登录</button></form><p>13812345678</p><button data-testid="send">发送私信</button></main></body></html>`);
});
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; });