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; });