#!/usr/bin/env node /** * run-mobile-regression.mjs — 移动端 headless 回归 runner * * 与 PC 侧 tools/run-regression.mjs 的关系: * - 同口径(超时重试一次;两次都超时记 timeout,不计入通过率分母;N/A 不进分母) * - 同断言引擎(tests/_runtime.js),行为动词来自移动端 tests/mobile/_behaviors.js * - **不同数据源与产物**:组件清单取 site/m/data.mobile.json(不读 PC 的 site/data.json), * 页面取 tests/mobile/.html,报告写 tests/mobile-report.json(不覆盖 PC 报告) * * 前置:node site/dev-server.js(端口 3311) * 依赖:playwright(devDependency,CI 专用) * 退出码:0 全通过 / 1 有失败 / 2 环境问题 */ import { writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); const OUT_DIR = join(ROOT, 'tests'); const BASE = process.env.REG_BASE || 'http://127.0.0.1:3311'; const CONC = Number(process.env.REG_MOBILE_CONC) || 4; const PAGE_TIMEOUT = 20000; const ASSERTS_TIMEOUT = 10000; const RETRY_LIMIT = 2; const PREFIX = 'tests/mobile'; async function checkServer() { try { const r = await fetch(`${BASE}/site/m/data.mobile.json`, { signal: AbortSignal.timeout(5000) }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return await r.json(); } catch (e) { console.error(`[FATAL] 无法连接 ${BASE}/site/m/data.mobile.json — ${e.message}`); console.error(' 请先在仓库根目录运行:node site/dev-server.js'); console.error(' 若文件不存在,先运行:node tools/build-mobile.mjs'); process.exit(2); } } let chromium; try { ({ chromium } = await import('playwright')); } catch { console.error('[FATAL] 未安装 playwright。CI 环境请先运行:npm i -D playwright && npx playwright install --with-deps chromium'); process.exit(2); } async function runOne(browser, slug, attempt = 1) { /* 测试页自身是桌面壳(内部 375×640 设备帧),故用桌面视口打开 */ const p = await browser.newPage({ viewport: { width: 1100, height: 900 } }); try { await p.goto(`${BASE}/${PREFIX}/${slug}.html`, { waitUntil: 'load', timeout: PAGE_TIMEOUT }); await p .waitForFunction(() => { const el = document.getElementById('sum-total'); return el && el.textContent !== '总数 0'; }, { timeout: ASSERTS_TIMEOUT }) .catch(() => {}); const btn = p.locator('#run-asserts'); if (await btn.count()) { await btn.click().catch(() => {}); await p.waitForTimeout(200); } const r = await p.evaluate(() => { const num = (s) => parseInt(String(s || '0').replace(/\D/g, ''), 10) || 0; const t = (id) => { const n = document.getElementById(id); return n ? n.textContent : '0'; }; const failures = [...document.querySelectorAll('#result-list .t-row')] .filter((li) => li.querySelector('.t-pill')?.classList.contains('fail')) .map((li) => li.querySelector('.id')?.textContent || '?') .slice(0, 8); const details = [...document.querySelectorAll('#result-list .t-row')] .filter((li) => li.querySelector('.t-pill')?.classList.contains('fail')) .map((li) => ({ id: li.querySelector('.id')?.textContent || '?', err: li.querySelector('.err')?.textContent || '' })) .slice(0, 8); return { total: num(t('sum-total')), pass: num(t('sum-pass')), fail: num(t('sum-fail')), skip: num(t('sum-skip')), failures, failureDetails: details, }; }); if (r.total === 0 && attempt < RETRY_LIMIT) { await p.close(); await new Promise((res) => setTimeout(res, 300)); return runOne(browser, slug, attempt + 1); } if (r.total === 0) { await p.close(); return { slug, total: 0, pass: 0, fail: 1, skip: 0, failures: ['no-result'] }; } await p.close(); return { slug, ...r }; } catch (e) { await p.close().catch(() => {}); if (attempt < RETRY_LIMIT) { await new Promise((res) => setTimeout(res, 300)); return runOne(browser, slug, attempt + 1); } const isTimeout = /Timeout|timeout/.test(String(e.message || e)); return { slug, total: 0, pass: 0, fail: isTimeout ? 0 : 1, skip: 0, failures: [isTimeout ? 'timeout' : 'page-error'], errorDetail: String(e.message || e).slice(0, 200), }; } } const data = await checkServer(); const slugs = data.components.map((c) => c.slug); console.log(`[mobile-regression] ${slugs.length} 个移动端组件 / 并发 ${CONC} / 目标 ${BASE}`); const browser = await chromium.launch(); const results = []; for (let i = 0; i < slugs.length; i += CONC) { const batch = slugs.slice(i, i + CONC); const batchResults = await Promise.all(batch.map((s) => runOne(browser, s))); results.push(...batchResults); process.stdout.write(`\r[mobile-regression] ${Math.min(i + CONC, slugs.length)}/${slugs.length}`); } console.log(''); await browser.close(); const totalAssert = results.reduce((s, r) => s + r.total, 0); const passAssert = results.reduce((s, r) => s + r.pass, 0); const failAssert = results.reduce((s, r) => s + r.fail, 0); const skipAssert = results.reduce((s, r) => s + r.skip, 0); const denom = passAssert + failAssert; const timedOut = results.filter((r) => (r.failures || []).includes('timeout')).map((r) => r.slug); const pagesAllPass = results.filter((r) => r.fail === 0 && r.total > 0).length; const report = { generated: new Date().toISOString().slice(0, 19).replace('T', ' '), library: 'kole-ui-mobile', kind: 'mobile', version: data.meta?.version || '', pages: results.length, pagesAllPass, passRate: denom ? Math.round((passAssert / denom) * 1000) / 10 : 0, assertions: { total: totalAssert, pass: passAssert, fail: failAssert, skip: skipAssert, na: skipAssert }, timedOut, /* 逐页结果(PC 侧报告缺这一块,S5-P17 就是在抱怨「失败落不了盘」): 移动端文档站按组件显示「断言 N 条 · 全通过 / 有失败」直接读这里,不猜。 */ pageResults: results.map((r) => ({ slug: r.slug, total: r.total, pass: r.pass, fail: r.fail, skip: r.skip, })), failedPages: results .filter((r) => r.fail > 0) .map((r) => ({ slug: r.slug, fail: r.fail, failures: r.failures, details: r.failureDetails, errorDetail: r.errorDetail })), }; if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true }); writeFileSync(join(OUT_DIR, 'mobile-report.json'), JSON.stringify(report, null, 2) + '\n'); const esc = (s) => String(s).replace(/&/g, '&').replace(/ ` ` + (r.fail > 0 ? `${r.fail} failed` : '') + `` ) .join('\n'); writeFileSync( join(OUT_DIR, 'mobile-report-junit.xml'), `\n\n${cases}\n\n` ); console.log( `passRate ${report.passRate}% | pages ${results.length} (all-pass ${pagesAllPass}) | assertions ${passAssert}/${denom}` + (skipAssert ? ` | N/A ${totalAssert - denom}` : '') + (timedOut.length ? ` | timeout ${timedOut.length} (${timedOut.slice(0, 5).join(', ')})` : '') ); console.log('written: tests/mobile-report.json, tests/mobile-report-junit.xml'); if (failAssert > 0) { console.error(`\n[FAIL] ${failAssert} 条移动端断言失败:`); report.failedPages.slice(0, 10).forEach((p) => { console.error(` ${p.slug}: ${(p.failures || []).join(', ')}`); (p.details || []).slice(0, 4).forEach((d) => console.error(` ${d.id}${d.err ? ' → ' + d.err : ''}`)); }); process.exit(1); } if (timedOut.length > 0) { console.error(`\n[FAIL] ${timedOut.length} 个页面超时未产出结果(重试后仍失败)`); process.exit(1); } console.log('\n[OK] 移动端全部通过'); process.exit(0);