/** * 使用隔离 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('Headless storage checks'); 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 }); }