v11b-lib.mjs 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. #!/usr/bin/env node
  2. /**
  3. * scripts/v11b-lib.mjs —— v11b 验收脚本的公共部分(不是可执行入口,只被 verify-*.mjs 引用)。
  4. *
  5. * 起服务器 / 载入 Playwright / 递归走 shadow 树 / 视图挂载等待 —— 抄自 verify-ui-v11.mjs,
  6. * 修掉了它 25% 缩放那一项的 bug(`w` 是 shadowRoot,shadowRoot 没有 getBoundingClientRect)。
  7. *
  8. * 几个坑(都在验收里踩过):
  9. * - 视图是异步挂载的:goto 之后要等 kf-app → view-* 的 shadowRoot 有子节点;数据来自 Parse,还要多等一会;
  10. * - alt 屏幕上的 headless shell 不一定和 PLAYWRIGHT_BROWSERS_PATH 里的版本对得上:用 CHROME_PATH / AGENT_BROWSER_EXECUTABLE_PATH 指定;
  11. * - page.goto(new URL('/x', base)):base 带尾斜杠时 '//' 会变成空主机名,所以统一走 new URL(p, base).href。
  12. */
  13. import { existsSync } from 'node:fs';
  14. import { spawn } from 'node:child_process';
  15. import { join, dirname } from 'node:path';
  16. import { fileURLToPath } from 'node:url';
  17. export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
  18. export async function loadPlaywright() {
  19. for (const c of [join(ROOT, 'node_modules/playwright'), '/opt/data/work/kangshifu/testing/node_modules/playwright']) {
  20. if (existsSync(c)) return (await import(`${c}/index.js`)).default ?? (await import(`${c}/index.js`));
  21. }
  22. return import('playwright');
  23. }
  24. /** 起本地 dev server(根部署),返回 { base, kill } */
  25. export async function startServer(port = 4196) {
  26. const { writeConfig } = await import('./config.mjs');
  27. const { prefix } = writeConfig({ env: 'local' });
  28. if (!existsSync(join(ROOT, 'lib/vendor/lit'))) throw new Error('缺少 lib/vendor:先 node scripts/vendor.mjs');
  29. const srv = spawn(process.execPath, [join(ROOT, 'src/dev/serve.mjs'), '--port', String(port), '--mount', prefix.replace(/\/+$/, '') || '/'], { stdio: 'ignore' });
  30. for (let i = 0; i < 40; i++) {
  31. await new Promise((r) => setTimeout(r, 100));
  32. try {
  33. const res = await fetch(`http://127.0.0.1:${port}/index.html`);
  34. if (res.ok) break;
  35. } catch { /* 还没起来 */ }
  36. }
  37. return { base: `http://127.0.0.1:${port}${prefix}`, kill: () => srv.kill() };
  38. }
  39. export async function launch() {
  40. const pw = await loadPlaywright();
  41. return pw.chromium.launch({
  42. executablePath: process.env.CHROME_PATH || process.env.AGENT_BROWSER_EXECUTABLE_PATH || undefined,
  43. args: ['--no-sandbox', '--disable-dev-shm-usage'],
  44. });
  45. }
  46. /** 页面里用的公共片段:递归 walk + 可见性 + 文本 + 在不在主列 */
  47. export const DEEP = `
  48. const walk = (root, fn, seen = new Set()) => {
  49. if (!root || seen.has(root)) return; seen.add(root);
  50. for (const el of root.querySelectorAll('*')) { fn(el); if (el.shadowRoot) walk(el.shadowRoot, fn, seen); }
  51. };
  52. const deepAll = (fn) => walk(document, fn);
  53. const elLabel = (el) => el.localName + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/ +/).join('.') : '');
  54. /* 可见 = 自身与所有祖先(含跨 shadow 的宿主)都 display / visibility / opacity 正常,且有 1px 以上尺寸 */
  55. const chain = (el) => { const out = []; for (let q = el; q; q = q.parentElement || q.getRootNode()?.host) out.push(q); return out; };
  56. const shownByStyle = (el) => { for (const q of chain(el)) { const s = getComputedStyle(q); if (s.display === 'none' || s.visibility === 'hidden' || Number(s.opacity) < 0.05) return false; } return true; };
  57. /* 元素可见:自身盒 ≥ 1px 且样式链可见。文本节点可见:它的 Range 逐字矩形 ≥ 1px 且父元素样式链可见(父盒可能被子元素撑高/塌陷,不能用它判) */
  58. const visible = (node) => {
  59. if (node.nodeType === 3) {
  60. const p = node.parentNode;
  61. if (!p || !shownByStyle(p)) return false;
  62. const rg = document.createRange(); rg.selectNodeContents(node);
  63. return rg.getClientRects().length > 0;
  64. }
  65. const r = node.getBoundingClientRect();
  66. return r.width >= 1 && r.height >= 1 && shownByStyle(node);
  67. };
  68. const app = () => document.querySelector('kf-app');
  69. const appR = () => app().shadowRoot;
  70. /* 主列里的元素(跨 shadow 边界向上走) */
  71. const main = () => appR().querySelector('main#view');
  72. const inMain = (el) => { for (let n = el; n; n = n.parentElement || n.getRootNode()?.host) { if (n === main()) return true; if (n === document || n === app()) return false; } return false; };
  73. /* 视图 CSS 里的 :host-context 什么情况会命中——不是 data-motion,也不是 kf-monad */
  74. const textOf = (t) => (t.nodeType === 3 ? t.data : '');
  75. /* 文本节点:跳过多余元素内的整段描述文字(跳过 script/style/svg 内的文字和 aria-hidden 元素里的) */
  76. const textNodesIn = (root) => {
  77. const out = [];
  78. const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
  79. let t;
  80. while ((t = w.nextNode())) {
  81. if (!t.data.trim()) continue;
  82. const p = t.parentNode;
  83. if (!p || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|TEXTAREA|OPTION)$/.test(p.nodeName)) continue;
  84. if (p.closest && p.closest('svg, [aria-hidden="true"]')) continue;
  85. /* 有排版盒 = 页面上真的排出来了(display:none 的元素里没有 range 矩形)。逐字扫,别被夹在换行里的片段骗过 */
  86. const rg = document.createRange();
  87. let shown = false;
  88. for (let i = 0; i < t.data.length && !shown; i++) {
  89. rg.setStart(t, i); rg.setEnd(t, i + 1);
  90. if (rg.getClientRects().length) shown = true;
  91. }
  92. if (!shown) continue;
  93. out.push(t);
  94. }
  95. return out;
  96. };
  97. `;
  98. export const evalIn = (page, body) => page.evaluate(`(async () => { ${DEEP}\n${body} })()`);
  99. export const VIEW_OF = {
  100. '/': 'view-workbench', '/pool': 'view-pool', '/stages': 'view-stages', '/reports': 'view-reports',
  101. '/members': 'view-members', '/ledger': 'view-ledger',
  102. };
  103. export const viewName = (p) => VIEW_OF[p] || 'view-report-type';
  104. /**
  105. * 打开一页并等到视图真的渲染出内容。
  106. * @param {object} page Playwright page
  107. * @param {string} base 服务器根(带尾斜杠)
  108. * @param {string} path 路由
  109. * @param {object} opts { me?: 预置「我是谁」, wait?: 额外等待毫秒 }
  110. */
  111. export async function openView(page, base, path, { me = null, wait = 900 } = {}) {
  112. if (me !== null) await page.evaluate((m) => localStorage.setItem('kf-board:me', JSON.stringify(m)), me).catch(() => {});
  113. await page.goto(new URL(path, base).href, { waitUntil: 'networkidle' });
  114. const name = viewName(path);
  115. await page.waitForFunction((n) => {
  116. const a = document.querySelector('kf-app');
  117. const v = a?.shadowRoot?.querySelector(n);
  118. /* 视图挂上 且 数据到了('db' in v 且为 null 时说明还在 loading,先别量) */
  119. return !!v && v.shadowRoot && v.shadowRoot.childElementCount > 0 && ('db' in v ? v.db != null : true);
  120. }, name, { timeout: 20000 });
  121. await page.waitForTimeout(wait);
  122. }
  123. /** 表格输出(stdout) */
  124. export function table(rows, cols) {
  125. const width = (s) => [...String(s)].reduce((n, ch) => n + (ch.charCodeAt(0) > 0x2e80 ? 2 : 1), 0);
  126. const pad = (s, n) => String(s) + ' '.repeat(Math.max(0, n - width(s)));
  127. const all = [cols.map((c) => c[0]), ...rows];
  128. const w = cols.map((_, i) => Math.max(...all.map((r) => width(r[i]))));
  129. const line = (r) => r.map((c, i) => pad(c, w[i])).join(' ');
  130. console.log(line(all[0]));
  131. console.log(w.map((n) => '─'.repeat(n)).join(' '));
  132. for (const r of rows) console.log(line(cols.map((c) => r?.[c[1]] ?? "")));
  133. }