| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142 |
- #!/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]] ?? "")));
- }
|