// tests/unit/renderer/styles/no-html-scrollbar-gutter.test.ts // // 行为锁(audit-r237,2026-10-20):globals.css 里**任何命中 html 元素的规则** // (html / html.xxx / :root / :root[...] 等)都不得带 scrollbar-gutter 声明。 // // audit-r145c 给 html 加了 scrollbar-gutter: stable,想治「列表出现/收起 // 滚动条时内容跳 8px」。但本 app 文档流根本不滚动(App 根节点 // `h-screen … overflow-hidden`,所有滚动都在内部面板),stable 在 html 上 // = 永久预留一条右侧死白条(实测 1440px 窗口只排版到 1429px,用户报 // 「窗口关闭按钮右边一条窄空白」)。r145c 的跳变属于内部滚动容器,要治 // 也得加在真正滚动的元素上。 // // 形态:源码字面解析(css-tree 不在 devDeps,用注释剥离 + 最内层块正则)。 // 注释先剥掉 ⇒ 本文件/globals.css 注释里出现 "scrollbar-gutter" 字样不会误判。 // 读文件走 process.cwd()(jsdom 环境下 fileURLToPath(import.meta.url) 不可靠, // 见 r236 同族测试)。 import { readFileSync } from 'fs'; import { join } from 'path'; import { describe, expect, it } from 'vitest'; /** 剥注释后的 globals.css 全文 */ function cssWithoutComments(): string { return readFileSync(join(process.cwd(), 'src/renderer/src/styles/globals.css'), 'utf8').replace( /\/\*[\s\S]*?\*\//g, '', ); } interface CssRule { selector: string; body: string; } /** * 提取所有**最内层**规则块。@layer / @media 等嵌套 at-rule 的头部 * (`... {` 后紧跟的内容含 `{`)正则匹配不上会被自然跳过,只留下叶子规则。 */ function leafRules(css: string): CssRule[] { const out: CssRule[] = []; const re = /([^{}]+)\{([^{}]*)\}/g; let m: RegExpExecArray | null; while ((m = re.exec(css)) !== null) { out.push({ selector: m[1].trim(), body: m[2] }); } return out; } /** 选择器逗号拆段后,任一段命中 html 元素本体:html / html.light / :root / :root[...] */ function hitsHtmlElement(selector: string): boolean { return selector .split(',') .some((part) => /^(?:html|:root)(?:[.#[:]|$)/.test(part.trim())); } describe('html 不得带 scrollbar-gutter(r237 行为锁)', () => { const rules = leafRules(cssWithoutComments()); it('能解析出 html 的 scrollbar 着色规则(解析器没瞎、r143c 着色未被顺手删)', () => { const htmlRules = rules.filter((r) => hitsHtmlElement(r.selector)); expect(htmlRules.length).toBeGreaterThan(0); expect(htmlRules.some((r) => /scrollbar-color\s*:/.test(r.body))).toBe(true); }); it('没有任何命中 html 元素的规则声明 scrollbar-gutter(右侧死白条不复发)', () => { const offenders = rules.filter( (r) => hitsHtmlElement(r.selector) && /scrollbar-gutter\s*:/.test(r.body), ); expect( offenders.map((o) => o.selector), 'html/:root 上的 scrollbar-gutter 会在不滚动的文档流上永久预留死白条;r145c 的跳变要治请加到真正滚动的内部元素', ).toEqual([]); }); });