#!/usr/bin/env node /** * scripts/v11b-lib.mjs —— v11b 验收脚本的公共部分(不是可执行入口,只被 verify-*.mjs 引用)。 * * 起服务器 / 载入 Playwright / 递归走 shadow 树 / 视图挂载等待 —— 抄自 verify-ui-v11.mjs, * 修掉了它 25% 缩放那一项的 bug(`w` 是 shadowRoot,shadowRoot 没有 getBoundingClientRect)。 * * 几个坑(都在验收里踩过): * - 视图是异步挂载的:goto 之后要等 kf-app → view-* 的 shadowRoot 有子节点;数据来自 Parse,还要多等一会; * - alt 屏幕上的 headless shell 不一定和 PLAYWRIGHT_BROWSERS_PATH 里的版本对得上:用 CHROME_PATH / AGENT_BROWSER_EXECUTABLE_PATH 指定; * - page.goto(new URL('/x', base)):base 带尾斜杠时 '//' 会变成空主机名,所以统一走 new URL(p, base).href。 */ import { existsSync } from 'node:fs'; import { spawn } from 'node:child_process'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); export async function loadPlaywright() { for (const c of [join(ROOT, 'node_modules/playwright'), '/opt/data/work/kangshifu/testing/node_modules/playwright']) { if (existsSync(c)) return (await import(`${c}/index.js`)).default ?? (await import(`${c}/index.js`)); } return import('playwright'); } /** 起本地 dev server(根部署),返回 { base, kill } */ export async function startServer(port = 4196) { const { writeConfig } = await import('./config.mjs'); const { prefix } = writeConfig({ env: 'local' }); if (!existsSync(join(ROOT, 'lib/vendor/lit'))) throw new Error('缺少 lib/vendor:先 node scripts/vendor.mjs'); const srv = spawn(process.execPath, [join(ROOT, 'src/dev/serve.mjs'), '--port', String(port), '--mount', prefix.replace(/\/+$/, '') || '/'], { stdio: 'ignore' }); for (let i = 0; i < 40; i++) { await new Promise((r) => setTimeout(r, 100)); try { const res = await fetch(`http://127.0.0.1:${port}/index.html`); if (res.ok) break; } catch { /* 还没起来 */ } } return { base: `http://127.0.0.1:${port}${prefix}`, kill: () => srv.kill() }; } export async function launch() { const pw = await loadPlaywright(); return pw.chromium.launch({ executablePath: process.env.CHROME_PATH || process.env.AGENT_BROWSER_EXECUTABLE_PATH || undefined, args: ['--no-sandbox', '--disable-dev-shm-usage'], }); } /** 页面里用的公共片段:递归 walk + 可见性 + 文本 + 在不在主列 */ export const DEEP = ` const walk = (root, fn, seen = new Set()) => { if (!root || seen.has(root)) return; seen.add(root); for (const el of root.querySelectorAll('*')) { fn(el); if (el.shadowRoot) walk(el.shadowRoot, fn, seen); } }; const deepAll = (fn) => walk(document, fn); const elLabel = (el) => el.localName + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/ +/).join('.') : ''); /* 可见 = 自身与所有祖先(含跨 shadow 的宿主)都 display / visibility / opacity 正常,且有 1px 以上尺寸 */ const chain = (el) => { const out = []; for (let q = el; q; q = q.parentElement || q.getRootNode()?.host) out.push(q); return out; }; 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; }; /* 元素可见:自身盒 ≥ 1px 且样式链可见。文本节点可见:它的 Range 逐字矩形 ≥ 1px 且父元素样式链可见(父盒可能被子元素撑高/塌陷,不能用它判) */ const visible = (node) => { if (node.nodeType === 3) { const p = node.parentNode; if (!p || !shownByStyle(p)) return false; const rg = document.createRange(); rg.selectNodeContents(node); return rg.getClientRects().length > 0; } const r = node.getBoundingClientRect(); return r.width >= 1 && r.height >= 1 && shownByStyle(node); }; const app = () => document.querySelector('kf-app'); const appR = () => app().shadowRoot; /* 主列里的元素(跨 shadow 边界向上走) */ const main = () => appR().querySelector('main#view'); 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; }; /* 视图 CSS 里的 :host-context 什么情况会命中——不是 data-motion,也不是 kf-monad */ const textOf = (t) => (t.nodeType === 3 ? t.data : ''); /* 文本节点:跳过多余元素内的整段描述文字(跳过 script/style/svg 内的文字和 aria-hidden 元素里的) */ const textNodesIn = (root) => { const out = []; const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); let t; while ((t = w.nextNode())) { if (!t.data.trim()) continue; const p = t.parentNode; if (!p || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|TEXTAREA|OPTION)$/.test(p.nodeName)) continue; if (p.closest && p.closest('svg, [aria-hidden="true"]')) continue; /* 有排版盒 = 页面上真的排出来了(display:none 的元素里没有 range 矩形)。逐字扫,别被夹在换行里的片段骗过 */ const rg = document.createRange(); let shown = false; for (let i = 0; i < t.data.length && !shown; i++) { rg.setStart(t, i); rg.setEnd(t, i + 1); if (rg.getClientRects().length) shown = true; } if (!shown) continue; out.push(t); } return out; }; `; export const evalIn = (page, body) => page.evaluate(`(async () => { ${DEEP}\n${body} })()`); export const VIEW_OF = { '/': 'view-workbench', '/pool': 'view-pool', '/stages': 'view-stages', '/reports': 'view-reports', '/members': 'view-members', '/ledger': 'view-ledger', }; export const viewName = (p) => VIEW_OF[p] || 'view-report-type'; /** * 打开一页并等到视图真的渲染出内容。 * @param {object} page Playwright page * @param {string} base 服务器根(带尾斜杠) * @param {string} path 路由 * @param {object} opts { me?: 预置「我是谁」, wait?: 额外等待毫秒 } */ export async function openView(page, base, path, { me = null, wait = 900 } = {}) { if (me !== null) await page.evaluate((m) => localStorage.setItem('kf-board:me', JSON.stringify(m)), me).catch(() => {}); await page.goto(new URL(path, base).href, { waitUntil: 'networkidle' }); const name = viewName(path); await page.waitForFunction((n) => { const a = document.querySelector('kf-app'); const v = a?.shadowRoot?.querySelector(n); /* 视图挂上 且 数据到了('db' in v 且为 null 时说明还在 loading,先别量) */ return !!v && v.shadowRoot && v.shadowRoot.childElementCount > 0 && ('db' in v ? v.db != null : true); }, name, { timeout: 20000 }); await page.waitForTimeout(wait); } /** 表格输出(stdout) */ export function table(rows, cols) { const width = (s) => [...String(s)].reduce((n, ch) => n + (ch.charCodeAt(0) > 0x2e80 ? 2 : 1), 0); const pad = (s, n) => String(s) + ' '.repeat(Math.max(0, n - width(s))); const all = [cols.map((c) => c[0]), ...rows]; const w = cols.map((_, i) => Math.max(...all.map((r) => width(r[i])))); const line = (r) => r.map((c, i) => pad(c, w[i])).join(' '); console.log(line(all[0])); console.log(w.map((n) => '─'.repeat(n)).join(' ')); for (const r of rows) console.log(line(cols.map((c) => r?.[c[1]] ?? ""))); }