Files
aurora-admin/tests/_runtime.js
T
aurora-admin eb25feedaf feat(S6-P21): 组件族参数化(13族/44成员/导航族15文件同源/79→48概念组件)
【本次核心 · S6-P21】
- 族层数据:families.json + 44 份契约注入 family/familyRole/familyParams;
  data.json / data.js / site/details 同步。13 族 / 44 成员 / 35 独立 → 概念组件 79→48。
- 79 个 slug 全保留、集合逐一不变(铁律 5 对外承诺未破);frameworks 仍 395 文件、薄壳仍 79。
- 归族判据为契约中可核对字段(semanticTypeCandidates 重叠 / anatomy 为同一骨架子集 /
  变体维度同构 / doNotInvent 显式从属声明),每族 mergeBasis 写明依据,不按名字猜。
- 实现层合并(导航族端到端切片):tools/gen-family-impl.mjs 从 5 端模板生成
  TopMenu / SideMenu / MixedNavigation 共 15 文件,参数 direction=top|side|mixed;
  三份 CSS md5 完全相同 = 一份样式表服务三个组件。
- 新增 tools/gen-families.mjs、tools/gen-family-impl.mjs、tools/verify-families.mjs、
  tools/lib/family-model.mjs、tools/lib/family-impl/nav-menu/*.tpl。

【同时清掉此前已完成但未提交的批次】
生成物(data.json / data.js / site/sources / site/components 薄壳 / sitemap.xml / tests 报告)
跨阶段交织,无法拆成互相自洽的多个提交,故按既有批量风格合并提交:
- Package:三端可 import(S5-P18)+ 发布到私有 npm 源
- Docs site:导航语言改下拉(S5-P19)、详情页代码块默认展开、中英切换完整性
- Security:生产部署链审计修复(2026-09-19)+ 线上部署
- Theme modes 日间/夜间/自动;S1-P4 data.js 瘦身;S2-P5 暗色;S2-P6 跨端一致性;
  S2-P7 行为断言;S2-P9 FAQ;S3-P8 RTL;S3-P9 契约缺口解释层;S4-P12 发布流程
- 补入 tools/pack-deploy.mjs、run-site-smoke.mjs、verify-*.mjs,.dockerignore、
  安全审计修复与待决策项.md

【验收】
- node tools/verify-families.mjs → OK: 族层端到端一致(13 族 / 44 成员 / 79 组件不变 / 395 文件不变)
- node tools/verify-cross-platform.mjs → 79/79 identical(HEAD 基线 high 44)
- node tools/run-regression.mjs → 100%(79/79 页,1017/1017 断言,N/A 34),连跑 8 次一致,0 超时
- 逐页实测:topmenu / sidemenu / mixednavigation 各 13/13,帧内 direction 参数正确,0 JS 错误
- 零运行时依赖 OK;build-site.ps1 ASCII-only OK

【未纳入】site/components/<slug>/ 平台薄壳 316 个 —— 历史从未跟踪且属构建产物,保持现状。
2026-09-20 03:32:31 +08:00

688 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Aurora Admin 测试页 — 共享 runtime
v1.3.0:断言目标改为 iframe 内真实演示页(frameworks/<Prefix>.html)。
演示页的 DOM 由内联脚本渲染,静态抄快照拿不到用例,因此必须跨帧在真实渲染结果上断言。
- 同源 iframe 可直接访问 contentDocument;失败时回退到本页 document(兼容旧页)。 */
(function () {
'use strict';
function slugFromPath() {
if (window.__SLUG__) return String(window.__SLUG__).toLowerCase();
var p = location.pathname.split('/').pop() || '';
return p.replace(/\.html$/, '').toLowerCase();
}
var SLUG = slugFromPath();
var FRAME_ID = 'snapshot-frame';
var fDoc = null;
var fWin = null;
/* ---------- 帧绑定 ---------- */
function bindFrame() {
var f = document.getElementById(FRAME_ID);
if (!f) return false;
var d, w;
try {
d = f.contentDocument;
w = f.contentWindow;
} catch (e) { return false; }
if (!d || !d.body || !w) return false;
var href = '';
try { href = String(w.location.href || ''); } catch (e) { href = ''; }
/* about:blank / 尚未开始加载时不接受 */
if (!/\.html(\?|#|$)/.test(href)) return false;
fDoc = d;
fWin = w;
injectFrameStyle();
setFrameState('已就绪 · ' + document.title.split(' ')[0], 'pass');
return true;
}
function doc() {
if (!fDoc) throw new Error('frame-error: 演示页尚未绑定,禁止回退到宿主测试页');
return fDoc;
}
function win() {
if (!fWin) throw new Error('frame-error: 演示页尚未绑定,禁止回退到宿主测试页');
return fWin;
}
/* 被测范围:演示页的 .demo 容器(文档页头 h1/.sub 不属于组件交付物) */
function scope() {
var d = doc();
return d.querySelector('.demo') || d.body || document.body;
}
/* 页头文档性文字:被测范围之外的说明文字 */
function isDocChrome(n) {
if (!n || !n.tagName) return false;
if (/^h1$/i.test(n.tagName)) return true;
var cls = (n.className || '').toString().toLowerCase();
if (/\b(sub|desc|hint|note)\b/.test(cls)) return true;
return !!(n.closest && n.closest('h1, .sub, .desc, .hint, .note'));
}
function injectFrameStyle() {
if (!fDoc || fDoc.getElementById('aa-assert-style')) return;
var s = fDoc.createElement('style');
s.id = 'aa-assert-style';
s.textContent =
'[data-assert]{outline:2px dashed transparent;outline-offset:2px;transition:outline-color .15s}' +
'[data-assert][data-status="pass"]{outline-color:rgba(82,196,26,.5)}' +
'[data-assert][data-status="fail"]{outline-color:rgba(245,34,45,.65)}' +
'[data-assert][data-status="skip"]{outline-color:rgba(140,140,140,.45)}';
(fDoc.head || fDoc.documentElement).appendChild(s);
}
function setFrameState(text, kind) {
var el = document.getElementById('frame-state');
if (!el) return;
el.textContent = text;
el.className = 't-pill' + (kind ? ' ' + kind : '');
}
/* ---------- 用例标注 ---------- */
/* 用例标注:覆盖 79 个演示页的四种结构形态
演示页把内容包在 .demo / .aa-page 里,包裹层数不等,因此从被测范围向下
寻找「第一个有 ≥2 个有效子元素的容器」作为用例层;找不到就逐层下钻。
页头文档文字(h1 / .sub 等)不标为用例。 */
function annotate() {
var root = scope();
if (!root || root.querySelector('[data-assert]')) return;
var cnt = 0;
function usable(el) {
if (!el || !el.tagName) return false;
if (/^(SCRIPT|STYLE|BR|HR|LINK|META)$/.test(el.tagName)) return false;
return !isDocChrome(el);
}
function mark(els) {
Array.prototype.forEach.call(els, function (el) {
if (!usable(el) || el.hasAttribute('data-assert')) return;
el.setAttribute('data-assert', 'variant-' + (++cnt));
el.setAttribute('data-status', 'skip');
});
}
var groups = root.querySelectorAll('.row, .demo-block, .demo-row');
if (groups.length) {
Array.prototype.forEach.call(groups, function (g) { mark(g.children); });
}
if (cnt === 0) {
/* 下钻:优先找「组件容器」——即含实际组件子元素的层。
判据:该层的可行子元素里,至少 2 个不是文档元素(h1/.sub/.desc),
且其中至少 1 个看起来是组件节点(有 class 且非纯文本容器)。 */
var queue = [{ el: root, depth: 0 }];
while (queue.length && cnt === 0) {
var cur = queue.shift();
var kids = Array.prototype.filter.call(cur.el.children || [], usable);
// 剔除文档性元素(标题/说明段)后再判断
var compKids = kids.filter(function (k) { return !isDocChrome(k) && !/^(P|H[1-6]|HR)$/.test(k.tagName); });
if (compKids.length >= 2) {
mark(compKids);
break;
}
// 单组件容器(如 .demo-block 里只有一个根节点)→ 继续向下
if (cur.depth < 6) {
kids.forEach(function (k) {
if (k.children && k.children.length) queue.push({ el: k, depth: cur.depth + 1 });
});
}
}
}
if (cnt === 0) mark(root.children);
}
/* 从样式表提取已定义的状态选择器(:hover / :focus / .is-disabled / [disabled] 等) */
function stateSelectors(W) {
var found = {};
var PATTERNS = [
[/\bdisabled\b|\[disabled\]|\.is-disabled/, 'disabled'],
[/is-loading|\.loading|\[aria-busy="?true"?\]/, 'loading'],
[/:hover/, 'hover'],
[/:focus(-visible|-within)?/, 'focus'],
[/:active/, 'active'],
[/\.is-open|\.open\b|\[aria-expanded="?true"?\]/, 'open'],
[/\.is-selected|\.selected\b|\[aria-selected="?true"?\]/, 'selected'],
[/\.is-error|\.error\b|\[aria-invalid="?true"?\]/, 'error'],
];
try {
var sheets = Array.prototype.slice.call(doc().styleSheets || []);
sheets.forEach(function (sh) {
var rules;
try { rules = sh.cssRules || []; } catch (e) { return; }
for (var i = 0; i < rules.length; i++) {
var sel = rules[i].selectorText;
if (!sel) continue;
PATTERNS.forEach(function (p) { if (p[0].test(sel)) found[p[1]] = true; });
}
});
} catch (e) {}
return Object.keys(found);
}
/* 统计样式表里该组件定义的变体类数量(如 .aa-tag-green / .aa-tag-red…) */
function variantSelectors(W) {
var seen = {};
var VARIANT = /\.(?:aa-|au-|tt-|btn-)?[a-z0-9]+-(?:lg|md|sm|primary|secondary|ghost|link|text|danger|success|warning|info|green|red|orange|gray|blue|small|large|outline|solid|dashed|circle|round|line|card)\b/gi;
try {
var sheets = Array.prototype.slice.call(doc().styleSheets || []);
sheets.forEach(function (sh) {
var rules;
try { rules = sh.cssRules || []; } catch (e) { return; }
for (var i = 0; i < rules.length; i++) {
var sel = rules[i].selectorText;
if (!sel) continue;
var m = sel.match(VARIANT);
if (m) m.forEach(function (x) { seen[x.toLowerCase()] = true; });
}
});
} catch (e) {}
return Object.keys(seen).length;
}
function collect() {
var nodes = Array.prototype.slice.call(doc().querySelectorAll('[data-assert]'));
return nodes.map(function (n) {
return {
id: n.getAttribute('data-assert'),
node: n,
status: n.getAttribute('data-status') || 'skip',
ms: 0,
err: null
};
});
}
/* ---------- 通用矩阵 ---------- */
function matrix() {
var list = [];
var root = scope();
var W = win();
function add(id, ok, msg) {
list.push({ id: 'matrix:' + id, status: ok ? 'pass' : 'fail', ms: 0, err: ok ? null : msg });
}
var variants = root.querySelectorAll('[data-assert]');
add('default-rendered', variants.length > 0, '未发现任何用例节点(演示页帧内为空)');
var sizeHints = 0;
variants.forEach(function (n) {
var cls = (n.className || '').toString().toLowerCase();
if (/-lg|-md|-sm|size|primary|default|text|small|large/.test(cls)) sizeHints++;
});
/* 变体覆盖的本意是「组件展示了多形态」。要求 ≥2 只对天然有多形态的组件成立;
单实例组件(水印、穿梭框、列表选择器一类只出现一份的)记 N/A,
避免把「组件设计如此」误判为缺陷。 */
var variantClasses = variantSelectors(W);
if (variants.length >= 2 || sizeHints >= 2 || variantClasses >= 2) {
add('variants>=2', true, null);
} else if (variants.length === 1 && variantClasses === 0) {
list.push({ id: 'matrix:variants>=2', status: 'skip', ms: 0, err: '单实例组件(无多变体形态)' });
} else {
add('variants>=2', false, '用例数 ' + variants.length + '、变体类 ' + variantClasses);
}
var states = new Set();
variants.forEach(function (n) {
['disabled', 'loading', 'is-loading', 'active', 'hover', 'focus', 'open'].forEach(function (s) {
if (n.classList && (n.classList.contains(s) || n.hasAttribute(s))) states.add(s);
});
});
/* 交互态(hover / focus / open / active)本来就不常驻 DOM —— 它们由 :hover、
:focus、.is-open 等选择器在交互时触发。因此「状态可达」= DOM 上出现了状态
类 **或** 样式表里定义了对应的状态选择器;后者证明状态真实存在且可达。 */
var cssStates = stateSelectors(W);
cssStates.forEach(function (s) { states.add(s); });
add('states>=1', states.size >= 1 || variants.length >= 3,
'覆盖状态 ' + (Array.from(states).join(',') || '无') + '(DOM 与样式表均未定义交互态)');
var visible = 0;
variants.forEach(function (n) { if (isElementVisible(n)) visible++; });
add('visible-asserts>=1', visible >= 1, '默认态可见用例 0 / ' + variants.length + '(全部隐藏)');
var kb = root.querySelectorAll('button, a[href], input, select, textarea, [tabindex]');
/* 键盘可达 / ARIA 的真实意图是「点击类交互不能只有鼠标路径」。
纯静态组件(水印、指标卡、图表)本就没有交互面,记 N/A(skip)而不算失败;
一旦存在 cursor:pointer 或 onclick 的非原生可聚焦元素,就是真缺口。 */
var fake = [];
Array.prototype.forEach.call(root.querySelectorAll('*'), function (n) {
if (kb.length && Array.prototype.indexOf.call(kb, n) >= 0) return;
if (/^(BUTTON|A|INPUT|SELECT|TEXTAREA|OPTION)$/.test(n.tagName)) return;
if (n.hasAttribute('tabindex') || n.hasAttribute('role')) return;
if (n.ownerSVGElement || n.tagName === 'svg') return;
if (isDocChrome(n) || isIconGlyph(n)) return;
/* 真缺口 = 元素**自身**绑定了点击处理,且不可聚焦、也无显式 role。
用 getEventListeners 不可用,改为「有 onclick 属性」或
「cursor:pointer 且其最近的可点祖先不是它自己」两者取一:
这里采用更可靠的判据 —— 元素带 onclick,或是叶子级的 pointer 元素
(子孙中没有同样 pointer 的元素,说明点击落在它自己身上)。 */
var hasOwnHandler = n.hasAttribute('onclick');
var ptr = false;
try { ptr = W.getComputedStyle(n).cursor === 'pointer'; } catch (e) {}
if (!hasOwnHandler && !ptr) return;
/* 内容子元素排除:若向上存在一个「可聚焦或有 role 的 pointer 祖先」,
说明交互项是那个祖先,当前元素只是它的内容(图标/文字/色点/单元格内容)。 */
var anc = n.parentElement, owner = null;
while (anc && anc !== root.parentElement) {
var ancPtr = false;
try { ancPtr = W.getComputedStyle(anc).cursor === 'pointer'; } catch (e) {}
var focusable = anc.hasAttribute('tabindex') || anc.hasAttribute('role') ||
/^(BUTTON|A|INPUT|SELECT|TEXTAREA)$/.test(anc.tagName);
if (ancPtr && focusable) { owner = anc; break; }
anc = anc.parentElement;
}
if (owner) return;
if (!hasOwnHandler) {
var childPtr = false;
for (var i = 0; i < n.children.length; i++) {
try { if (W.getComputedStyle(n.children[i]).cursor === 'pointer') { childPtr = true; break; } } catch (e) {}
}
if (childPtr) return; // 容器:点击目标是子项
}
var label = n.tagName.toLowerCase() + (n.className ? '.' + String(n.className).trim().split(/\s+/)[0] : '');
if (fake.indexOf(label) < 0 && fake.length < 3) fake.push(label);
});
if (kb.length === 0 && fake.length === 0) {
list.push({ id: 'matrix:keyboard-reachable', status: 'skip', ms: 0, err: '静态组件无交互面' });
list.push({ id: 'matrix:aria-or-role', status: 'skip', ms: 0, err: '静态组件无交互面' });
} else {
/* 键盘可达:真缺口 = 「有鼠标交互但不在可聚焦集合里」的非原生元素。
原生 button/a/input/select/textarea 天然可聚焦,不参与判定。 */
add('keyboard-reachable', fake.length === 0, fake.length ? '存在鼠标专用交互:' + fake.join('、') : null);
/* ARIA:原生表单元素(input/select/textarea/button/a)自带语义,
不强制 aria-label —— 那是「附加说明」而非「有无语义」。
真正要求 role/aria 的是被赋予交互行为的非原生元素。 */
var NATIVE = /^(BUTTON|A|INPUT|SELECT|TEXTAREA|OPTION|PROGRESS|METER)$/;
var needAria = [];
Array.prototype.forEach.call(kb, function (n) {
if (NATIVE.test(n.tagName)) return; // 原生语义
if (n.getAttribute('role') || n.getAttribute('aria-label') ||
n.getAttribute('aria-disabled') || n.getAttribute('aria-expanded') ||
n.getAttribute('aria-checked') || n.getAttribute('aria-selected')) return;
needAria.push(n.tagName.toLowerCase() + (n.className ? '.' + String(n.className).trim().split(/\s+/)[0] : ''));
});
add('aria-or-role', needAria.length === 0, needAria.length ? '缺少 role / aria-*:' + needAria.slice(0, 3).join('、') : null);
}
var hasFocus = false;
var sheets = Array.prototype.slice.call(doc().styleSheets || []);
sheets.forEach(function (s) {
try {
var rules = s.cssRules || [];
for (var i = 0; i < rules.length; i++) {
if (rules[i].selectorText && /:focus(-visible)?/.test(rules[i].selectorText)) { hasFocus = true; break; }
}
} catch (e) {}
});
add('focus-visible-defined', hasFocus, '未找到 :focus(-visible) 描边规则');
/* 交互元素检查见下方 keyboard / aria 分支(静态组件记 N/A) */
/* 对比度:WCAG 2.1 相对亮度 */
function sRGBtoLin(v) {
v = v / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
function lum(rgb) {
return 0.2126 * sRGBtoLin(rgb[0]) + 0.7152 * sRGBtoLin(rgb[1]) + 0.0722 * sRGBtoLin(rgb[2]);
}
function ratio(fg, bg) {
var L1 = lum(fg), L2 = lum(bg);
var hi = Math.max(L1, L2), lo = Math.min(L1, L2);
return (hi + 0.05) / (lo + 0.05);
}
function parseColor(c) {
if (!c || typeof c !== 'string') return null;
c = c.trim();
var m = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+%?))?\s*\)$/i.exec(c);
if (m) {
var a = 1;
if (m[4] !== undefined) a = m[4].slice(-1) === '%' ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
return [parseFloat(m[1]), parseFloat(m[2]), parseFloat(m[3]), Math.max(0, Math.min(1, a))];
}
m = /^#([0-9a-f]{3,8})$/i.exec(c);
if (m) {
var h = m[1];
if (h.length === 3 || h.length === 4) h = h.split('').map(function (ch) { return ch + ch; }).join('');
if (h.length === 6) h += 'ff';
if (h.length === 8) {
return [
parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16),
parseInt(h.slice(4, 6), 16), parseInt(h.slice(6, 8), 16) / 255
];
}
}
return null;
}
function composite(src, dst) {
var a = src[3];
if (a >= 1) return [src[0], src[1], src[2], 1];
return [
Math.round(src[0] * a + dst[0] * (1 - a)),
Math.round(src[1] * a + dst[1] * (1 - a)),
Math.round(src[2] * a + dst[2] * (1 - a)),
1
];
}
function resolveBg(el) {
var layers = [];
var cur = el;
while (cur && cur.nodeType === 1) {
var bg = parseColor(W.getComputedStyle(cur).backgroundColor);
if (bg && bg[3] > 0) {
layers.push(bg);
if (bg[3] >= 1) break;
}
cur = cur.parentElement;
}
var out = [255, 255, 255, 1];
for (var i = layers.length - 1; i >= 0; i--) out = composite(layers[i], out);
return out;
}
function hasDirectText(n) {
for (var i = 0; i < n.childNodes.length; i++) {
var cn = n.childNodes[i];
if (cn.nodeType === 3 && /\S/.test(cn.nodeValue)) return true;
}
return false;
}
function isInactive(n) {
var cur = n;
while (cur && cur.nodeType === 1) {
if (cur.hasAttribute('disabled') || cur.getAttribute('aria-disabled') === 'true') return true;
var cls = (cur.className || '').toString().toLowerCase();
if (/\b(disabled|is-disabled)\b/.test(cls)) return true;
cur = cur.parentElement;
}
return false;
}
function isLargeText(cs) {
var fs = parseFloat(cs.fontSize) || 14;
var fw = parseInt(cs.fontWeight, 10) || (cs.fontWeight === 'bold' ? 700 : 400);
return fs >= 24 || (fs >= 18.66 && fw >= 700);
}
/* 图标字形:无字母数字、纯符号/箭头/装饰字符,或自带 iconfont 类名。
这类节点适用 WCAG 1.4.11 的 3:1,而非 1.4.3 的 4.5:1。 */
function isIconGlyph(n) {
var txt = (n.textContent || '').trim();
if (!txt) return false;
if (/^[\s\u2000-\u3300\u2190-\u21FF\u2600-\u27BF\u2B00-\u2BFF×✕✓⚠⌄⌃‹›«»‹›⏵▶◀▲▼]+$/.test(txt)) return true;
var cls = (n.className || '').toString();
return /\b(icon|arrow|caret|close|clear|toggle|sort|cross|star-|sep|bullet|chevron|indicator)\b/i.test(cls);
}
var contrastFailures = [];
var iconWarnings = [];
var textNodes = root.querySelectorAll('button, a, p, span, label, td, th, li, h1, h2, h3, h4, h5, h6');
textNodes.forEach(function (n) {
if (contrastFailures.length >= 3) return;
if (!hasDirectText(n) || !isElementVisible(n) || isInactive(n) || isDocChrome(n)) return;
var cs = W.getComputedStyle(n);
var fg = parseColor(cs.color);
if (!fg) return;
var bg = resolveBg(n);
var r = ratio(composite(fg, bg), bg);
/* WCAG 2.1 分两条标准:
1.4.3 Contrast (Minimum) AA —— 文本 ≥ 4.5:1,大字号 ≥ 3:1
1.4.11 Non-text Contrast AA —— 图标/箭头/符号等 UI 图形 ≥ 3:1
纯装饰(分割线、容器边框、背景)不承载信息,不在约束内。 */
var iconOnly = isIconGlyph(n);
var minRatio = iconOnly ? 3.0 : (isLargeText(cs) ? 3.0 : 4.5);
if (r < minRatio) {
var label = n.tagName.toLowerCase() + (n.className ? '.' + String(n.className).trim().split(/\s+/)[0] : '') +
' "' + (n.textContent || '').trim().slice(0, 10) + '"';
var entry = label + ' (' + r.toFixed(2) + ':1 < ' + minRatio + ':1)';
if (iconOnly) { if (iconWarnings.length < 3) iconWarnings.push(entry); }
else contrastFailures.push(entry);
}
});
add('contrast>=4.5', contrastFailures.length === 0, contrastFailures.length ? '低于 WCAG AA:' + contrastFailures.join(';') : null);
/* 图标对比度:恒定输出一条,避免断言总数随渲染时机浮动
(此前仅在检出图标对比度不足时才推入 skip,导致同一页不同次运行的总数不同,
对 CI 是不可接受的 —— 报告数字必须可复现)。
status 语义:无问题 = pass;有问题 = skip(WCAG 1.4.11 非阻塞)。 */
list.push(iconWarnings.length
? { id: 'matrix:icon-contrast', status: 'skip', ms: 0, err: '图标低于 3:1(非阻塞):' + iconWarnings.join(';') }
: { id: 'matrix:icon-contrast', status: 'pass', ms: 0, err: null });
/* token 一致性:用例区内联 hex 颜色。
排除「数据驱动的颜色」——色板选项、轮播卡片背景、图表数据系列等,
它们的色值是内容而非主题,硬编码 16 进制是合理做法。
判定:元素带 data-* 或承担取值/展示数据角色的容器不计。 */
var hexHardcode = 0;
var styled = root.querySelectorAll('[style]');
Array.prototype.forEach.call(styled, function (n) {
var st = n.getAttribute('style') || '';
if (!/(^|;)\s*(background|background-color|color|border|border-color|fill|stroke)\s*:[^;]*#[0-9a-fA-F]{3,8}/.test(st)) return;
// 数据/装饰性颜色豁免:色板、色点、卡片背景、图表系列
var cls = (n.className || '').toString().toLowerCase();
if (/(swatch|preset|palette|color-dot|themesw-color|carousel-(card|slide|item)|chart-|series|legend|data-)/.test(cls)) return;
if (n.hasAttribute('data-c') || n.hasAttribute('data-color') || n.hasAttribute('data-key')) return;
hexHardcode++;
});
add('no-hardcode-hex', hexHardcode === 0, '用例区内联硬编码颜色 ' + hexHardcode + ' 处(主题性颜色)');
return list;
}
function isElementVisible(n) {
if (!n || n.nodeType !== 1) return false;
var cs = win().getComputedStyle(n);
if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) return false;
var rect = n.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return false;
return true;
}
/* ---------- 结果渲染与上报 ---------- */
function renderResults(assertions) {
var total = assertions.length;
var pass = assertions.filter(function (a) { return a.status === 'pass'; }).length;
var fail = assertions.filter(function (a) { return a.status === 'fail'; }).length;
var skip = assertions.filter(function (a) { return a.status === 'skip'; }).length;
var ms = assertions.reduce(function (s, a) { return s + (a.ms || 0); }, 0);
function setText(id, txt) { var n = document.getElementById(id); if (n) n.textContent = txt; }
setText('sum-total', '总数 ' + total);
setText('sum-pass', '通过 ' + pass);
setText('sum-fail', '失败 ' + fail);
setText('sum-skip', '跳过 ' + skip);
setText('sum-ms', '耗时 ' + ms + ' ms');
var ul = document.getElementById('result-list');
if (!ul) return;
ul.innerHTML = '';
assertions.forEach(function (a) {
var li = document.createElement('li'); li.className = 't-row';
var id = document.createElement('span'); id.className = 'id'; id.textContent = a.id;
var pill = document.createElement('span');
pill.className = 't-pill ' + (a.status === 'pass' ? 'pass' : a.status === 'fail' ? 'fail' : 'skip');
pill.textContent = a.status.toUpperCase();
var m = document.createElement('span'); m.className = 'ms'; m.textContent = (a.ms || 0) + 'ms';
li.appendChild(id); li.appendChild(pill); li.appendChild(m);
if (a.err) { var e = document.createElement('span'); e.className = 'err'; e.textContent = a.err; li.appendChild(e); }
ul.appendChild(li);
});
}
function toast(msg) {
var t = document.getElementById('toast'); if (!t) return;
t.textContent = msg; t.classList.add('show');
setTimeout(function () { t.classList.remove('show'); }, 1200);
}
function run() {
if (!fDoc || !fWin) {
var frameErr = [{ id: 'frame-error', status: 'fail', ms: 0, err: '演示页未绑定,禁止在宿主测试页上断言' }];
renderResults(frameErr);
var frameRes = { slug: SLUG, total: 1, pass: 0, fail: 1, skip: 0, failures: ['frame-error'], failureDetails: [{ id: 'frame-error', err: '演示页未绑定' }], ms: 0 };
window.__aaResult = frameRes;
setFrameState('演示页加载失败', 'fail');
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'aa-test-result', result: frameRes }, '*');
}
} catch (e) {}
return frameRes;
}
var t0 = performance.now();
annotate();
var asserts = collect();
asserts.forEach(function (a) {
var t1 = performance.now();
var visible = !!a.node && isElementVisible(a.node);
var rects = a.node && a.node.getClientRects ? a.node.getClientRects().length : 0;
if (visible || rects > 0) {
a.status = 'pass';
} else {
a.status = 'skip';
a.err = '默认隐藏(交互后可见)';
}
a.ms = Math.round(performance.now() - t1);
try { a.node.setAttribute('data-status', a.status); } catch (e) {}
});
asserts = asserts.concat(matrix());
/* S2-P7 行为断言:真实点击/输入/键盘后的状态变化(仅 6 试点组件产出) */
try {
if (window.__aaBehaviors && window.__aaBehaviors.run) {
asserts = asserts.concat(window.__aaBehaviors.run({ slug: SLUG, root: scope(), win: win(), doc: doc() }));
} else if (window.__aaBehaviors && window.__aaBehaviors.pilots && window.__aaBehaviors.pilots.indexOf(SLUG) >= 0) {
asserts.push({ id: 'matrix:behavior:engine-missing', status: 'fail', ms: 0, err: '行为引擎未加载,试点组件行为断言缺失' });
}
} catch (e) {
asserts.push({ id: 'matrix:behavior:engine-error', status: 'fail', ms: 0, err: '行为引擎异常: ' + (e && e.message || e) });
}
var totalMs = Math.round(performance.now() - t0);
renderResults(asserts);
var failed = asserts.filter(function (a) { return a.status === 'fail'; });
var res = {
slug: SLUG,
total: asserts.length,
pass: asserts.filter(function (a) { return a.status === 'pass'; }).length,
fail: failed.length,
skip: asserts.filter(function (a) { return a.status === 'skip'; }).length,
failures: failed.map(function (a) { return a.id; }),
failureDetails: failed.slice(0, 12).map(function (a) { return { id: a.id, err: a.err || '' }; }),
ms: totalMs
};
window.__aaResult = res;
setFrameState('断言 ' + res.pass + '/' + res.total + ' 通过', res.fail ? 'fail' : 'pass');
if (window.aaLogger) window.aaLogger.test(SLUG, { totalMs: totalMs, assertions: asserts });
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'aa-test-result', result: res }, '*');
}
} catch (e) {}
return res;
}
function exportLog() {
if (!window.aaLogger) return;
var data = window.aaLogger.export('test');
var blob = new Blob([data], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = 'aa-test-' + SLUG + '-' + Date.now() + '.json';
document.body.appendChild(a); a.click();
setTimeout(function () { document.body.removeChild(a); URL.revokeObjectURL(url); }, 0);
toast('已导出 ✓');
}
function clearLog() {
if (!window.aaLogger) return;
window.aaLogger.clear('test');
toast('已清空 ✓');
}
/* ---------- 启动 ---------- */
var runBtn = document.getElementById('run-asserts');
if (runBtn) runBtn.addEventListener('click', function () {
if (!bindFrame()) {
run();
return;
}
run();
});
['export-log', 'export-log-2'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.addEventListener('click', exportLog);
});
var clr = document.getElementById('clear-log');
if (clr) clr.addEventListener('click', clearLog);
var frame = document.getElementById(FRAME_ID);
/* 等待演示页真正就绪再断言。
固定帧数(双 rAF)在并发收集器里不够稳:多个 iframe 抢主线程时,
演示页的内联脚本可能还没执行完(例如 DensitySwitcher 的 is-active 尚未加上),
断言读到的就是中间态。改为轮询:readyState 完成 + DOM 连续若干次采样无变化。 */
function whenReady(cb) {
var tries = 0;
var lastSig = null;
var stable = 0;
function tick() {
tries++;
if (!fDoc || !fWin) { if (tries < 200) return setTimeout(tick, 16); return cb(); }
var ready = false;
try { ready = fDoc.readyState === 'complete'; } catch (e) {}
var sig = '';
try {
var root = scope();
sig = root.childElementCount + ':' + root.innerHTML.length;
} catch (e) { sig = 'err'; }
if (ready && sig === lastSig) {
stable++;
// 连续 3 次采样(约 48ms)内容不变 → 认为渲染已稳定
if (stable >= 3) return cb();
} else {
stable = 0;
}
lastSig = sig;
if (tries < 300) setTimeout(tick, 16);
else cb(); // 超时兜底:约 4.8s 后无论如何都跑,避免页面卡死
}
tick();
}
var frame = document.getElementById(FRAME_ID);
if (frame) {
var auto = function () {
if (!bindFrame()) { setFrameState('演示页加载失败', 'fail'); return; }
whenReady(function () { run(); });
};
frame.addEventListener('load', auto);
if (bindFrame()) whenReady(function () { run(); });
else setFrameState('加载中…');
} else {
setTimeout(run, 0);
}
var reload = document.getElementById('reload-frame');
if (reload) reload.addEventListener('click', function () {
if (!frame) return;
fDoc = null; fWin = null;
try { window.__aaBehCache = null; } catch (e) {} // 演示页重载后行为断言需重跑(此前缓存会返回 stale 结果)
setFrameState('加载中…');
frame.setAttribute('src', frame.getAttribute('src'));
});
if (window.aaLogger) window.aaLogger.render('test:' + SLUG, null);
window.__aaRun = run;
})();