Files

93 lines
7.6 KiB
JavaScript
Raw Permalink 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.
/**
* 使用隔离 Chrome profile 运行真实 IndexedDB/Web Locks 场景,或以 --package 消费构建后的浏览器 ESM。
* 本地 HTTP server 只提供测试页面、源码/发布文件和可控 NDJSON 流;普通模式通过 Node 去除 .ts 类型,不运行应用 bundler。
* 此入口内联了一套简化 CDP 请求表,未使用 cdp-client 的连接级超时/故障保护,不能把 Runtime.evaluate 的 timeout 当作所有命令的截止时间。
*/
import { createServer } from 'node:http';
import { readFile, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resolve, join } from 'node:path';
import { spawn } from 'node:child_process';
import { stripTypeScriptTypes } from 'node:module';
import { setTimeout as pause } from 'node:timers/promises';
// 从脚本位置定位仓库,随机临时 profile 保证测试不读写日常 Chrome 会话。
const root = resolve(import.meta.dirname, '../..'), profile = await mkdtemp(join(tmpdir(), 'endoleap-storage-browser-'));
// --package 只影响发布文件服务和页面导入入口;它不会在这里自动构建 .headless-dist。
const packageMode=process.argv.includes('--package');
// 按 key 保留挂起的响应与读取次数,供页面测试控制共享读取的首块、尾块和失败尾部。
const readStreams=new Map();
// 只在回环地址提供 fixture;源码读取或类型去除失败时返回 500 供页面检查报告。
const server = createServer(async (request, response) => {
try {
const path = new URL(request.url, 'http://localhost').pathname;
// 同一 key 每次读取计数增加,立即发送第一行并保持连接;由独立 release 请求决定何时结束。
if(path==='/loader-stream'){
const url=new URL(request.url,'http://localhost'),key=url.searchParams.get('key');
if(!key){response.writeHead(400).end();return;}
const state=readStreams.get(key)??{reads:0};state.reads++;state.response=response;readStreams.set(key,state);
response.writeHead(200,{'Content-Type':'application/x-ndjson'});response.write(JSON.stringify({part:0,complete:false})+'\n');return;
}
// 查找挂起流,可发送合法完成行或非法 JSON 尾部;返回读次数用于核对共享加载没有重复 fetch。
if(path==='/loader-release'){
const url=new URL(request.url,'http://localhost'),state=readStreams.get(url.searchParams.get('key'));
if(!state){response.writeHead(404).end();return;}
if(!state.response.destroyed)state.response.end(url.searchParams.get('fail')==='1'?'{invalid tail}\n':JSON.stringify({part:1,complete:true})+'\n');
response.setHeader('Content-Type','application/json');response.end(JSON.stringify({reads:state.reads}));return;
}
if (path === '/') { response.setHeader('Content-Type', 'text/html'); response.end('<title>Headless storage checks</title>'); return; }
// 包模式从 .headless-dist 读取,其余只允许 src/lib 和 tests/headless;resolve 后再检查目录前缀。
const isPackage=packageMode&&path.startsWith('/package/');
const filename = isPackage?resolve(root,'.headless-dist',path.slice('/package/'.length)):resolve(root, `.${path}`);
if (!(isPackage&&filename.startsWith(`${root}/.headless-dist/`)) && !filename.startsWith(`${root}/src/lib/`) && !filename.startsWith(`${root}/tests/headless/`)) { response.writeHead(404).end(); return; }
// 仅对 .ts 去除类型后提供 JavaScript;.mjs 和编译包文件保持读取内容,不进行应用级转换。
let source = await readFile(filename, 'utf8');
if (filename.endsWith('.ts')) source = stripTypeScriptTypes(source);
response.setHeader('Content-Type', 'text/javascript'); response.end(source);
} catch (error) { response.writeHead(500).end(String(error)); }
});
// 先绑定随机本地端口,再用独立 profile 启动 headless Chrome;CHROME_BIN 可覆盖默认安装路径。
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const chrome = spawn(process.env.CHROME_BIN ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ['--headless=new', '--no-first-run', '--disable-background-networking', '--disable-component-update', '--remote-debugging-port=0', `--user-data-dir=${profile}`, `http://127.0.0.1:${server.address().port}/`], { stdio: 'ignore' });
let socket;
try {
// 最多轮询 100 次 DevToolsActivePort,每次间隔 100 毫秒;未找到端口即失败,不连接用户现有浏览器。
let port;
for (let attempt = 0; attempt < 100; attempt++) {
try { port = Number((await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]); break; } catch { await pause(100); }
}
if (!port) throw new Error('isolated Chromium did not start');
// 从本次 Chrome 的调试服务查找 page,建立 WebSocket 后才发送 CDP 命令。
const tabs = await fetch(`http://127.0.0.1:${port}/json/list`).then(response => response.json());
socket = new WebSocket(tabs.find(tab => tab.type === 'page').webSocketDebuggerUrl);
await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; });
// pending 仅按 ID 路由 Promise;这里没有每条命令的本地截止定时器。
let next = 0; const pending = new Map();
// 只处理带 ID 的响应,协议错误拒绝相应 Promise;没有转发无 ID 事件或整连接失败处理。
socket.onmessage = event => { const message = JSON.parse(event.data); if (message.id) { const callbacks = pending.get(message.id); pending.delete(message.id); if (message.error) callbacks.reject(new Error(JSON.stringify(message.error))); else callbacks.resolve(message.result); } };
// 登记递增 ID 后发送命令,等待对应响应;其行为不同于独立 createCdpCalls 的有界调用器。
const call = (method, params) => new Promise((resolve, reject) => { const id = ++next; pending.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params })); });
const origin = `http://127.0.0.1:${server.address().port}`;
// 导航到本次 fixture origin,随后最多 100 次检查页面 origin 与 readyState,避免误在空白页执行。
await call('Page.navigate', { url: `${origin}/` });
let loaded = false;
for (let attempt = 0; attempt < 100; attempt++) {
const state = await call('Runtime.evaluate', { expression: `location.origin === ${JSON.stringify(origin)} && document.readyState === 'complete'`, returnByValue: true });
if (state.result?.value === true) { loaded = true; break; }
await pause(50);
}
if (!loaded) throw new Error('browser test origin did not finish loading');
// 页面动态导入普通存储场景或编译包场景并等待 run;30 秒 timeout 是该 evaluate 的协议参数。
const result = await call('Runtime.evaluate', { expression: `import('/tests/headless/${packageMode?'package-browser-cases':'browser-storage-cases'}.mjs').then(module => module.run())`, awaitPromise: true, returnByValue: true, timeout: 30000 });
// 页面异常直接使 runner 失败,只有成功返回的场景消息才逐条打印 PASS。
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text);
for (const message of result.result.value) console.log(`PASS ${message}`);
} finally {
// 清理连接并请求 Chrome 退出,等待进程 exit 后再关闭 HTTP server 和删除 profile;此等待没有额外强杀截止时间。
socket?.close();
const exited = new Promise(resolve => { if (chrome.exitCode !== null) resolve(); else chrome.once('exit', resolve); });
chrome.kill(); await exited;
await new Promise(resolve => server.close(resolve));
await rm(profile, { recursive: true, force: true, maxRetries: 3 });
}