256 lines
13 KiB
JavaScript
256 lines
13 KiB
JavaScript
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(`<!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; });
|