Ctrl K
ComunidadGuías…

scripts/web-page-probe.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * 本地页面回归工装:起静态服务、桩掉接口、用无头 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 <目录>         页面目录,默认 <root>/web',
  '  --prefix </路径>     页面挂载前缀,默认 /(auth 页面用 /auth)',
  '  --page <路径>        要打开的页面,默认 <prefix>/(即 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 = `<script type="module">
setTimeout(async () => {
  try {
    const value = await (async () => { ${probe}\n })();
    document.title = 'PROBE:' + JSON.stringify(value ?? null);
  } catch (error) {
    document.title = 'PROBE:' + JSON.stringify({ error: String(error?.message ?? error) });
  }
}, ${Number(settle) || 0});
</script>`;
  return html.includes('</body>') ? html.replace('</body>', `${script}\n</body>`) : `${html}\n${script}`;
}

const decode = text => text.replaceAll('&quot;', '"').replaceAll('&amp;', '&').replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&#39;', "'");

/**
 * 在指定宽度下打开页面并取回探针结果。
 *
 * 必须用异步执行:静态服务就在本进程里,同步等待浏览器会把事件循环卡住,
 * 浏览器拿不到页面、双方一起等下去(原型分成两个进程,所以没暴露这一点)。
 */
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 = /<title>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));