/** * 本地页面回归工装:起静态服务、桩掉接口、用无头 Chromium 量尺寸。 * * 插件的页面代码是浏览器原生模块,没有 jsdom 能覆盖的执行环境;仓库里的做法一向是 * 「读源码抽函数 + 替身」。这条命令补上缺的一环:真的把页面跑起来,在几个视口宽度下 * 量它。 * * 用法(`<页面目录>` 默认是 `<插件根>/web`): * * node scripts/web-page-probe.mjs --root plugins/dsh-auth --prefix /auth \ * --stub fixtures/auth-api.json --probe tests/page-probe.js --widths 1150,860,640 * * 工作原理:静态服务把 `<页面目录>` 挂在 `--prefix` 下,未命中的请求按 `--stub` 的 JSON * 回应(缺桩会返回 404,方便看出漏了什么);HTML 里注入一段脚本,在 `--settle` 毫秒后 * 执行 `--probe` 给出的表达式,把结果写进标题栏;Chromium 用 `--dump-dom` 输出整页, * 这里再把标题栏里的 JSON 取出来。整个过程不需要任何 npm 依赖。 * * 没有可用的 Chromium 时不会假装通过:默认打印「跳过」并以 0 退出(本地/CI 都可能没有 * 浏览器),加 `--require-browser` 则视为失败。 */ import { createServer } from 'node:http'; import { execFile, execFileSync } from 'node:child_process'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { extname, join, resolve, sep } from 'node:path'; import { promisify } from 'node:util'; const runFile = promisify(execFile); const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp', '.woff2': 'font/woff2', '.ico': 'image/x-icon' }; const USAGE = [ '用法:node scripts/web-page-probe.mjs --root <插件根> [选项]', '', ' --root <目录> 插件根目录(必填;`--dir` 未给时用它的 web/)', ' --dir <目录> 页面目录,默认 /web', ' --prefix 页面挂载前缀,默认 /(auth 页面用 /auth)', ' --page <路径> 要打开的页面,默认 /(即 index.html)', ' --stub <文件.json> 未命中文件的请求按它回应:路径 → 响应体,或 {status, headers, body}', ' --replace <词=文件> 服务 HTML 前把 <词> 全部换成该文件的内容(宿主注入的页面配置就是这样塞进去的)', ' --mount <路径=目录> 额外挂载一个静态目录(例如需要构建产物的 /插件前缀/assets)', ' --probe <文件.js> 注入页面的表达式源码,结果写进标题栏;不传则只报告页面是否可打开', ' --widths <列表> 视口宽度,逗号分隔,默认 1150,860,640', ' --height <像素> 视口高度,默认 1600', ' --settle <毫秒> 注入脚本执行前的等待,默认 500', ' --budget <毫秒> Chromium 虚拟时间预算,默认 4000', ' --browser <路径> 指定 Chromium;默认依次找 CHROME_PATH、Playwright 缓存、PATH', ' --require-browser 找不到浏览器时以失败退出', ' --json 只输出 JSON(给脚本用)', ' --help 打印这段用法', ].join('\n'); function parse(argv) { const options = { widths: '1150,860,640', height: '1600', settle: '500', budget: '4000' }; const flags = new Set(['require-browser', 'json', 'help']); const repeatable = new Set(['replace', 'mount']); while (argv.length) { const key = argv.shift(); if (!key.startsWith('--')) throw new Error(`未知参数:${key}`); const name = key.slice(2); if (flags.has(name)) { options[name] = true; continue; } const value = argv.shift(); if (value === undefined || value.startsWith('--')) throw new Error(`${key} 需要一个值。`); if (repeatable.has(name)) options[name] = [...(options[name] ?? []), value]; else options[name] = value; } return options; } /** `词=值` 形式:值可能是文件路径(--replace)或目录(--mount)。 */ function pair(value, label) { const cut = value.indexOf('='); if (cut <= 0 || cut === value.length - 1) throw new Error(`${label} 需要「左=右」形式,收到:${value}`); return [value.slice(0, cut), value.slice(cut + 1)]; } /** 找一个能用的 Chromium:显式路径 → CHROME_PATH → Playwright 缓存 → PATH → macOS 应用。 */ export function findBrowser(explicit) { if (explicit) return existsSync(explicit) ? explicit : null; if (process.env.CHROME_PATH && existsSync(process.env.CHROME_PATH)) return process.env.CHROME_PATH; const caches = [ process.env.USERPROFILE && join(process.env.USERPROFILE, 'AppData/Local/ms-playwright'), process.env.HOME && join(process.env.HOME, '.cache/ms-playwright'), process.env.HOME && join(process.env.HOME, 'Library/Caches/ms-playwright'), ].filter(Boolean); const relative = [['chrome-win64', 'chrome.exe'], ['chrome-linux', 'chrome'], ['chrome-mac', 'Chromium.app/Contents/MacOS/Chromium'], ['chrome-headless-shell-win64', 'chrome-headless-shell.exe'], ['chrome-headless-shell-linux64', 'chrome-headless-shell']]; for (const cache of caches) { if (!existsSync(cache)) continue; const versions = readdirSync(cache).filter(name => name.startsWith('chromium-')).sort().reverse(); for (const version of versions) for (const parts of relative) { const candidate = join(cache, version, ...parts); if (existsSync(candidate)) return candidate; } } for (const name of ['chromium', 'chromium-browser', 'google-chrome', 'google-chrome-stable', 'chrome']) { try { execFileSync(process.platform === 'win32' ? 'where' : 'which', [name], { stdio: 'ignore' }); return name; } catch { /* 换下一个 */ } } const mac = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; return existsSync(mac) ? mac : null; } /** 静态目录 + 桩接口。返回 { close, origin }。 */ function serve({ directory, prefix, stub, probe, settle, mounts = [], replacements = [] }) { const root = resolve(directory); const base = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix; // 最长前缀优先:`/p/assets` 要盖过 `/p`,否则资源会从页面目录里找不到。 const table = [...mounts.map(([path, dir]) => [path.endsWith('/') ? path.slice(0, -1) : path, resolve(dir)]), [base, root]] .sort((left, right) => right[0].length - left[0].length); const server = createServer((request, response) => { const path = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname); const mount = table.find(([mountPath]) => path === mountPath || path.startsWith(`${mountPath}/`)); if (!mount) { respond(response, 404, { 'content-type': 'application/json' }, JSON.stringify({ error: '没有挂载这个路径', path })); return; } const [mountPath, mountRoot] = mount; const relative = path === mountPath ? 'index.html' : path.slice(mountPath.length + 1); const file = resolve(mountRoot, relative === '' ? 'index.html' : relative); if (!file.startsWith(mountRoot + sep) || !existsSync(file) || !statSync(file).isFile()) { const entry = stub?.[path]; if (entry === undefined) { respond(response, 404, { 'content-type': 'application/json' }, JSON.stringify({ error: '缺少桩', path })); return; } const { status = 200, headers = {}, body } = entry !== null && typeof entry === 'object' && 'body' in entry ? entry : { body: entry }; respond(response, status, { 'content-type': 'application/json; charset=utf-8', ...headers }, typeof body === 'string' ? body : JSON.stringify(body ?? null)); return; } const type = TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream'; const bytes = readFileSync(file); if (type.startsWith('text/html') && (probe !== undefined || replacements.length)) { let html = bytes.toString('utf8'); for (const [token, text] of replacements) html = html.split(token).join(text); respond(response, 200, { 'content-type': type }, probe === undefined ? html : inject(html, probe, settle)); return; } respond(response, 200, { 'content-type': type }, bytes); }); return new Promise(resolve_ => server.listen(0, '127.0.0.1', () => resolve_({ close: () => server.close(), origin: `http://127.0.0.1:${server.address().port}` }))); } function respond(response, status, headers, body) { response.writeHead(status, { 'cache-control': 'no-store', ...headers }); response.end(body); } /** 注入测量脚本:等页面安静下来后执行探针表达式,把结果写进标题栏。 */ function inject(html, probe, settle) { const script = ``; return html.includes('') ? html.replace('', `${script}\n`) : `${html}\n${script}`; } const decode = text => text.replaceAll('"', '"').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll(''', "'"); /** * 在指定宽度下打开页面并取回探针结果。 * * 必须用异步执行:静态服务就在本进程里,同步等待浏览器会把事件循环卡住, * 浏览器拿不到页面、双方一起等下去(原型分成两个进程,所以没暴露这一点)。 */ async function measure({ browser, url, width, height, budget }) { const { stdout } = await runFile(browser, ['--headless=new', '--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage', `--virtual-time-budget=${budget}`, '--dump-dom', `--window-size=${width},${height}`, url], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: Number(budget) + 20000 }); const match = /PROBE:([\s\S]*?)<\/title>/.exec(stdout); if (!match) return { error: '页面没有回传探针结果(检查 --probe 是否执行,或页面是否报错)' }; return JSON.parse(decode(match[1])); } /** * 在指定宽度下量一次,必要时重试。 * * 无头 Chromium 在负载高的机器上偶尔会在注入脚本执行前就 dump DOM(拿不到探针结果), * 而页面本身没有报错。这种偶发只影响「等多久」,所以这里重试一次并放宽虚拟时间预算; * 页面真的坏了(探针抛错、缺少桩)两次都会失败,不会把失败变成通过。 */ async function measureWithRetry({ browser, url, width, height, budget }) { const first = await measure({ browser, url, width, height, budget }); if (first.error === undefined) return first; const second = await measure({ browser, url, width, height, budget: budget * 2 }); if (second.error === undefined) return second; return { error: `${second.error}(已重试一次,虚拟时间预算 ${budget * 2} 毫秒)` }; } export async function probePage(options) { const root = options.root && resolve(options.root); if (!root) throw new Error('必须指定 --root <插件根>。'); const directory = resolve(options.dir ?? join(root, 'web')); if (!existsSync(directory)) throw new Error(`页面目录不存在:${directory}`); const prefix = options.prefix ?? '/'; const stubPath = options.stub && resolve(options.stub); const stub = stubPath ? JSON.parse(readFileSync(stubPath, 'utf8')) : undefined; const probe = options.probe ? readFileSync(resolve(options.probe), 'utf8') : undefined; const mounts = (options.mount ?? []).map(value => { const [path, dir] = pair(value, '--mount'); if (!existsSync(resolve(dir))) throw new Error(`--mount 的目录不存在:${dir}`); return [path, dir]; }); const replacements = (options.replace ?? []).map(value => { const [token, file] = pair(value, '--replace'); if (!existsSync(resolve(file))) throw new Error(`--replace 的文件不存在:${file}`); return [token, readFileSync(resolve(file), 'utf8').trim()]; }); const widths = String(options.widths).split(',').map(value => Number(value.trim())).filter(value => Number.isFinite(value) && value > 0); if (!widths.length) throw new Error('--widths 至少要有一个正整数。'); const browser = findBrowser(options.browser); if (!browser) return { skipped: true, reason: '没有找到 Chromium:设置 CHROME_PATH、安装 Playwright 浏览器,或用 --browser 指定。' }; const server = await serve({ directory, prefix, stub, probe, settle: options.settle, mounts, replacements }); try { const page = options.page ?? `${prefix.endsWith('/') ? prefix : `${prefix}/`}`; const results = []; for (const width of widths) results.push({ width, value: await measureWithRetry({ browser, url: `${server.origin}${page}`, width, height: Number(options.height) || 1600, budget: Number(options.budget) || 4000 }) }); return { browser, results }; } finally { server.close(); } } function main(argv) { let options; try { options = parse(argv); } catch (error) { console.error(`${error.message}\n\n${USAGE}`); process.exitCode = 2; return; } if (options.help) { console.log(USAGE); return; } probePage(options).then(result => { if (result.skipped) { console.log(`页面探针跳过:${result.reason}`); if (options['require-browser']) { process.exitCode = 1; return; } return; } if (options.json) { console.log(JSON.stringify(result, null, 2)); return; } console.log(`浏览器:${result.browser}`); for (const { width, value } of result.results) console.log(`\n=== 视口 ${width}px ===\n${JSON.stringify(value, null, 2)}`); }).catch(error => { console.error(error.message); process.exitCode = 1; }); } if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/u, '$1'))) main(process.argv.slice(2));