import { readdirSync, readFileSync, statSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' const here = dirname(fileURLToPath(import.meta.url)) const srcRoot = resolve(here, '../..') const stylesDir = join(srcRoot, 'styles') const variablesCss = readFileSync(join(stylesDir, 'variables.css'), 'utf-8') /** * Custom properties that are legitimately *not* declared in variables.css: * they are set at runtime (inline `:style` bindings, JS-built names) or are * component-local knobs declared in the same stylesheet that consumes them. * Anything else must be a canonical token from variables.css. */ const RUNTIME_PROPERTY_ALLOWLIST = new Set([]) const SOURCE_EXTENSIONS = ['.vue', '.css', '.ts'] const IGNORED_DIRS = new Set(['__tests__', 'generated-ui', 'node_modules']) function walk(dir: string, out: string[] = []): string[] { for (const entry of readdirSync(dir)) { const full = join(dir, entry) if (statSync(full).isDirectory()) { if (!IGNORED_DIRS.has(entry)) walk(full, out) } else if (SOURCE_EXTENSIONS.some((ext) => entry.endsWith(ext))) { out.push(full) } } return out } const sourceFiles = walk(srcRoot) function declaredIn(text: string): Set { const names = new Set() // `--foo: value` declarations, in CSS blocks and in JS style objects. for (const m of text.matchAll(/(--[A-Za-z0-9_-]+)\s*:/g)) names.add(m[1]) // setProperty('--foo', …) / `'--foo'` keys in style bindings. for (const m of text.matchAll(/['"`](--[A-Za-z0-9_-]+)['"`]/g)) names.add(m[1]) return names } const canonicalTokens = declaredIn(variablesCss) // Component-local custom properties: declared in a stylesheet or set from an // inline `:style` binding in the SFC that owns it. They are legitimate, they // just are not part of the global token set. const componentLocalProperties = new Set() for (const file of sourceFiles) { for (const name of declaredIn(readFileSync(file, 'utf-8'))) { componentLocalProperties.add(name) } } interface Usage { file: string token: string } const usages: Usage[] = [] for (const file of sourceFiles) { const text = readFileSync(file, 'utf-8') for (const m of text.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)/g)) { usages.push({ file, token: m[1] }) } } describe('CSS custom properties', () => { it('scans a meaningful number of var() references', () => { expect(usages.length).toBeGreaterThan(500) expect(canonicalTokens.size).toBeGreaterThan(70) }) it('only references custom properties that are actually defined', () => { const undefinedUsages = usages.filter(({ token }) => { if (canonicalTokens.has(token)) return false if (RUNTIME_PROPERTY_ALLOWLIST.has(token)) return false return !componentLocalProperties.has(token) }) const report = [ ...new Set( undefinedUsages.map( ({ file, token }) => `${token} (${relative(srcRoot, file)})`, ), ), ].sort() expect(report).toEqual([]) }) // 9a: PlotlyChart reads it through getComputedStyle('--font-sans'), which the // scan above counts as a declaration, so it slipped through as undefined. it('should declare --font-sans as Fira Sans when charts read the sans font token (9a)', () => { expect(variablesCss).toMatch(/--font-sans:\s*'Fira Sans'/) }) it('does not use the platform-only --color-bg-*/--color-text-*/--color-border aliases', () => { const platformOnly = /^--color-(bg|text)-|^--color-border$/ const offenders = [ ...new Set( usages .filter(({ token }) => platformOnly.test(token)) .map(({ file, token }) => `${token} (${relative(srcRoot, file)})`), ), ].sort() expect(offenders).toEqual([]) }) it('aggregates every component stylesheet in styles/index.css', () => { const index = readFileSync(join(stylesDir, 'index.css'), 'utf-8') const imported = new Set( [...index.matchAll(/@import\s+'\.\/components\/([^']+)'/g)].map((m) => m[1]), ) const onDisk = readdirSync(join(stylesDir, 'components')).filter((f) => f.endsWith('.css'), ) expect([...onDisk].filter((f) => !imported.has(f)).sort()).toEqual([]) }) it('does not re-import aggregated stylesheets from SFC