Files
danyang-match/ui-tests/wxss-component-selector-contracts.mjs
WorkBuddy 2c325136ab fix(miniapp): 修复 WXSS 通配符导致编译失败,并把 WXSS 校验升级为真编译
问题
- v2 设计收敛在 app.wxss 新增 `.tap-gap > * + *`。WXSS 不支持通配符 `*`,
  wcc/wcsc 编译期直接报 `error at token '*'`,整个小程序编译不过。
- 更值得警惕的是当时 19 个 UI 契约守卫全绿:静态守卫刻意只扫
  `miniapp/components/`(page 的 wxss 允许标签选择器,套用会误报),
  app.wxss 落在扫描范围外,且判据里根本没有通配符这一项。

修复
- 移除 `.tap-gap > * + *`。复检 v2 新增的 .tap-min / .tap-gap / .focusable / .pressable
  四个工具类全仓零引用(等价能力已各自写在组件样式内,如 button/index.wxss 直接用
  `min-height: var(--tap-min)`),且 `.focusable:focus` / `.pressable:active` 在 view 上
  不生效(小程序须用 hover-class),属"写了等于没写",故整块移除,只保留 token。
- 修掉同块注释里的嵌套 `/* */`:内层结束标记会提前终止外层注释,其后的文字会被
  当成真实样式解析。该约束已写进注释,防止后人再踩。

守卫加固
- scripts/lib/wxss-selectors.mjs
  * 新增 findUniversalSelectors(通配符判据,排除 `*=` 属性匹配符与注释内星号)
  * 新增 findNestedComments(注释嵌套判据)
  * 修 stripComments:原实现用等长空格替换,把注释内的换行也吃掉,导致报错行号
    整体偏移(实测偏差 68 行);现保留换行,并在定位时跳过选择器前的注释占位空白
- ui-tests/wxss-component-selector-contracts.mjs
  * 第 1 级:miniapp/components/(原判据,保留)
  * 第 2 级(新增):全量 miniapp/**/*.wxss,覆盖 app.wxss 与 pages
  * 第 3 级(新增):注释嵌套
  * 三级各自带正/反自检样例

新增
- scripts/miniapp-compile-check.mjs:直接调用微信开发者工具内置的 wcc/wcsc 做真编译预检
  (wcsc 单独调用不解析 @import 相对路径,脚本会先内联再编译)。
  CI runner 无该工具时输出 SKIPPED 并退出 0;刻意不挂进 run-ui-contracts —— 一个在 CI 里
  什么都不做的守卫被计入"全绿",正是本项目反复强调的假绿。CI 拦截由上面的静态守卫承担。

验证(均实际执行)
- node scripts/miniapp-compile-check.mjs → WXSS 88 个 + WXML 86 个,0 失败
- 负向注入:把 `.tap-gap > * + *` 改回去 → 预检 FAIL(exit 1),报错与用户原始报错逐字一致
  `app.wxss(364:12): error at token '*'`;已还原并复测通过
- node scripts/run-ui-contracts.mjs → 20 / 20 PASS
2026-09-23 08:25:11 +08:00

179 lines
8.7 KiB
JavaScript
Raw Permalink 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.
// 契约:WXSS 选择器合法性(两级,级别不同不可混淆)
//
// ── 第 1 级:组件选择器限制(2026-09-19)────────────────────────────
// 由来:开发者工具警告
// [pages/index/index] Some selectors are not allowed in component wxss,
// including tag name selectors, ID selectors, and attribute selectors.
// (./components/recommend-card/index.wxss:1:1092)
// 实测违规 1 处:`.compact-like image`。
//
// ⚠️ 作用域刻意限定 `miniapp/components/`:**page 的 wxss 允许标签选择器**,
// 把本规则套到 pages 上会立刻产生误报 —— 守卫一旦有假阳性就会被绕过。
//
// ── 第 2 级:通配符 `*` —— 编译期致命(2026-09-23 新增)─────────────
// 由来:设计收敛时在 `miniapp/app.wxss` 写了 `.tap-gap > * + *`,工具直接编译失败:
// [ WXSS 文件编译错误] ./app.wxss
// ./app.wxss(356:12): error at token `*`
// **上面第 1 级的扫描范围是 components/,app.wxss 根本不在其中,因此这次完全没有拦截。**
// 这是纯盲区,不是判据写错。现已补第 2 级:通配符与作用域无关(app/pages/components
// 全部禁止),且性质是"编译不过"而非"样式不生效",必须独立成档并对全量 wxss 扫描。
//
// 同时修正一处过度推断:报错列号 356:12 恰好落在 `*` 字符上,说明解析器已接受其左侧的
// `>` 与 `+` ⇒ 子选择器与相邻兄弟选择器**不在**禁止范围,判据里明确放行、不得误报。
//
// ── 第 3 级:注释嵌套 —— 同样是编译期问题(2026-09-23 新增)────────────
// 修第 2 级时本人又踩了一次:在注释里写了内层注释,外层注释被提前截断,尾部残留
// 文字被当成样式解析。它还会**污染第 2 级的结论** —— 负向注入曾因此报出一条
// 原因完全错误的命中(选择器原文显示成 `gap: var(--tap-gap);`)。
// 教训:注入后"变红"≠ 守卫有效,必须核对变红的原因。
// 判据:状态机 —— "不在注释态却遇到结束标记"即为嵌套。
//
// 自检:三级判据各自内置正向样例(合法类 / 伪类 / `>` / `+` / `~` / `*=` / 多行注释 /
// 注释内单星号)与反向样例,确保既不全哑、也不误伤。
//
// ⚠️ 已知未纳入(证据冲突,暂不阻断):pages 下 6 处 `.btn-xxx[disabled]`。
// 官方 WXSS 文档称"属性名选择器 [...] 不会生效",但开发者工具的组件限制明确
// 只针对 component wxss、page 是允许的,两个来源冲突,且这些代码自 2026-09-09
// 基线起长期存在。按"不拿猜测当判据"处理:记录在案,不纳入断言。
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import { checkWxss, checkWxssUniversal, findNestedComments, walkWxss } from '../scripts/lib/wxss-selectors.mjs'
const root = resolve(import.meta.dirname, '..')
// WXSS_SCAN_ROOT 仅用于负向注入自证(指向临时目录),默认 miniapp/components
const scanRoot = resolve(root, process.env.WXSS_SCAN_ROOT ?? 'miniapp/components')
// ── 1. 判据自检 ──────────────────────────────────────────────────
const selfCheckCases = [
{
name: '正向:纯类选择器 / 伪类 / 子选择器 均合法',
src: [
'.a { color: red }',
'.a .b { color: red }',
'.a > .b { color: red }',
'.a:active, .b::after { color: red }',
'.a:nth-child(2) { color: red }',
'@media (min-width: 600px) { .a { color: red } }',
'/* .commented-out image { color: red } */',
].join('\n'),
expect: 0,
},
{ name: '反向:标签选择器', src: '.a image { width: 1px }', expect: 1 },
{ name: '反向:ID 选择器', src: '#main { width: 1px }', expect: 1 },
{ name: '反向:属性选择器', src: '.a[data-x="1"] { width: 1px }', expect: 1 },
]
for (const c of selfCheckCases) {
const got = checkWxss(c.src)
assert.equal(
got.length,
c.expect,
`判据自检失败 —— ${c.name}\n期望 ${c.expect} 条,实得 ${got.length} 条:\n${got.map((x) => x.message).join('\n')}`,
)
}
// ── 1b. 判据自检(第 2 级:通配符)─────────────────────────────
const universalCases = [
{
name: '正向:子 / 相邻兄弟 / 通用兄弟 / 伪类 均不误报',
src: '.a > .b + .c ~ .d:active { color: red }',
expect: 0,
},
{ name: '正向:属性匹配符 `*=` 不误报', src: '.a[class*="x"] { color: red }', expect: 0 },
{ name: '正向:注释里的 `*` 不算', src: '/* .a > * + * { margin: 0 } */\n.a { color: red }', expect: 0 },
{ name: '反向:`.a > * + *`(本次实际踩到的写法)', src: '.a > * + * { margin-left: 8px }', expect: 1 },
{ name: '反向:单独通配符', src: '* { margin: 0 }', expect: 1 },
]
for (const c of universalCases) {
const got = checkWxssUniversal(c.src)
assert.equal(
got.length,
c.expect,
`通配符判据自检失败 —— ${c.name}\n期望 ${c.expect} 条,实得 ${got.length} 条:\n${got.map((x) => x.message).join('\n')}`,
)
}
// ── 2. 全量扫描组件 WXSS(第 1 级)─────────────────────────────
const files = walkWxss(scanRoot)
assert.ok(files.length > 0, `未在 ${scanRoot} 下找到任何 .wxss —— 扫描清单可能失配,拒绝报绿`)
const broken = []
for (const file of files) {
for (const p of checkWxss(readFileSync(file, 'utf8'))) {
broken.push(`${relative(root, file).replace(/\\/g, '/')}:${p.line} ${p.message}`)
}
}
assert.deepEqual(
broken,
[],
`以下组件 WXSS 使用了不允许的选择器(开发者工具会警告,样式不生效):\n${broken.join('\n')}`,
)
// ── 3. 全量扫描**所有** WXSS 的通配符(第 2 级,含 app.wxss 与 pages)────
// 这正是上次漏掉 app.wxss 导致编译失败的补丁点。
const allRoot = resolve(root, 'miniapp')
const allFiles = walkWxss(allRoot)
assert.ok(
allFiles.length >= files.length,
`全量扫描只找到 ${allFiles.length} 个 .wxss,少于组件目录的 ${files.length} 个 —— 扫描清单可疑,拒绝报绿`,
)
assert.ok(
allFiles.some((f) => f.endsWith('app.wxss')),
'全量扫描未包含 app.wxss —— 全局样式是本规则的重点对象,缺失即视为清单失配,拒绝报绿',
)
const universal = []
for (const file of allFiles) {
for (const p of checkWxssUniversal(readFileSync(file, 'utf8'))) {
universal.push(`${relative(root, file).replace(/\\/g, '/')}:${p.line} ${p.message}`)
}
}
assert.deepEqual(
universal,
[],
`以下 WXSS 使用了通配符选择器 \`*\` —— 开发者工具会**直接编译失败**(不是"样式不生效"):\n${universal.join('\n')}\n` +
`提示:需要"相邻兄弟补间距"时改用容器 display:flex + gap,或给子元素具名类后写 margin。`,
)
// ── 4. 注释嵌套(全量 wxss)─────────────────────────────────────
// CSS 注释不支持嵌套,内层结束标记会提前截断外层注释,其后文字被当作真实样式。
const commentCases = [
{ name: '正向:单行 + 多行注释', src: '/* a */\n.a { color: red }\n/*\n b\n*/', expect: 0 },
{ name: '正向:注释内含单个星号', src: '/* 5 * 3 = 15 */\n.a { color: red }', expect: 0 },
{ name: '正向:无注释', src: '.a { color: red }', expect: 0 },
{ name: '反向:注释内嵌注释(本次实际踩到的写法)', src: '/* outer /* inner */ tail */\n.a { color: red }', expect: 1 },
{ name: '反向:注释未闭合', src: '.a { color: red }\n/* never ends', expect: 1 },
]
for (const c of commentCases) {
const got = findNestedComments(c.src)
assert.equal(
got.length,
c.expect,
`注释判据自检失败 —— ${c.name}\n期望 ${c.expect} 条,实得 ${got.length} 条:\n${got.map((x) => x.message).join('\n')}`,
)
}
const nested = []
for (const file of allFiles) {
for (const p of findNestedComments(readFileSync(file, 'utf8'))) {
nested.push(`${relative(root, file).replace(/\\/g, '/')}:${p.line} ${p.message}`)
}
}
assert.deepEqual(
nested,
[],
`以下 WXSS 存在注释嵌套或未闭合 —— 会导致外层注释提前结束、后续文字被当成样式解析:\n${nested.join('\n')}`,
)
console.log(
`WXSS 选择器契约通过(组件样式 ${files.length} 个;全量 ${allFiles.length} 个含 app.wxss;` +
`通配符 0 处;注释嵌套 0 处)`,
)