/** * The design lint's core contract: parsers, the rule table, the output lines, * exit codes and waivers. Rule families have their own files * (test/design-lint-.test.ts); the render tier is * test/design-lint-render.test.ts. */ import { afterAll, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { buildStyleIndex, buildTokens, parseCss, pxOf, resolveVars, stylesFor, type StyleIndex } from '../lib/design-lint/css'; import { contrastRatio, parseColor, srgbToOklch } from '../lib/design-lint/color'; import { ancestorsOf, directChildren, getAttr, innerOf, jsxPseudo, parseDoc, plainText, treeOf } from '../lib/design-lint/html'; import { lint, formatResult } from '../lib/design-lint/index'; import { Reporter, exitCodeOf, finalize, formatFinding, formatSummary, summarize } from '../lib/design-lint/report'; import { collectFiles, contextWaivers, lintStatic, MAX_FILE_BYTES } from '../lib/design-lint/static'; import type { LintContext } from '../lib/design-lint/context'; import { browserAvailable } from './helpers/browser-available'; import { RULES } from '../lib/design-lint/rules/ids'; import { BUZZWORDS, COPY_IDS } from '../lib/design-lint/rules/copy'; import { catalogEntry, DESIGN_SLOP_CATALOG, detectorSlopEntries, judgmentTellEntries, renderCatalog, selectCatalog } from '../lib/design-catalog'; import { autoFixEntries, checklistSlopEntries } from '../scripts/resolvers/design-checklist'; import { FAMILIES } from '../lib/design-lint/rules'; import { findWaivers, reasonIsBoilerplate, resolveWaivers, applyWaivers, suspectedRules } from '../lib/design-lint/waivers'; const tmpDirs: string[] = []; afterAll(() => { for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); }); const ROOT = path.resolve(import.meta.dir, '..'); const BIN = path.join(ROOT, 'bin', 'gstack-design-lint'); const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'design-lint'); function runBin(args: string[], cwd = ROOT) { const r = Bun.spawnSync([BIN, ...args], { cwd, stdout: 'pipe', stderr: 'pipe', timeout: 60_000 }); return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() }; } function tmpPage(html: string, name = 'index.html'): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-')); tmpDirs.push(dir); const file = path.join(dir, name); fs.writeFileSync(file, html); return file; } /** A temporary tree: each key is a relative path (parent directories are created), each value the file's text. */ function tmpTree(files: Record): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-tree-')); tmpDirs.push(dir); for (const [rel, text] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); fs.writeFileSync(path.join(dir, rel), text); } return dir; } describe('design-lint: CSS reading', () => { test('nested rules, media blocks and keyframes flatten with line numbers; tokens resolve through var() with fallbacks', () => { const css = `:root { --color-ink: oklch(20% 0.02 250); --space-4: 1rem; } .card { padding: var(--space-4); &:hover { color: var(--color-ink); } @media (min-width: 60em) { padding: 2rem; } } @keyframes rise { from { opacity: 0 } to { opacity: 1 } }`; const rules = parseCss(css, 'a.css'); const sel = (s: string) => rules.filter((r) => r.selector === s); expect(sel('.card')[0].decls).toEqual([{ prop: 'padding', value: 'var(--space-4)', line: 3 }]); expect(sel('.card:hover')[0].decls[0]).toEqual({ prop: 'color', value: 'var(--color-ink)', line: 4 }); expect(sel('.card').find((r) => r.media === '(min-width: 60em)')?.decls[0].value).toBe('2rem'); expect(rules.filter((r) => r.keyframes === 'rise')).toHaveLength(2); const tokens = buildTokens(rules); expect(tokens['--space-4']).toBe('1rem'); expect(resolveVars('var(--space-4) var(--missing, 8px)', tokens)).toBe('1rem 8px'); expect(pxOf('1.5rem')).toBe(24); expect(pxOf('50%')).toBeNull(); }); }); describe('design-lint: colour', () => { test('hex, rgb, hsl and oklch parse; contrast and OKLCH round-trip hold', () => { expect(parseColor('#fff')).toEqual({ r: 1, g: 1, b: 1, alpha: 1 }); expect(parseColor('rgb(0 0 0 / 50%)')?.alpha).toBe(0.5); expect(parseColor('hsl(0 100% 50%)')?.r).toBeCloseTo(1, 5); const ok = parseColor('oklch(50% 0.1 250)')!; expect(ok.okl).toEqual({ L: 0.5, C: 0.1, H: 250 }); const back = srgbToOklch(ok); expect(back.L).toBeCloseTo(0.5, 1); expect(back.H).toBeCloseTo(250, 0); expect(contrastRatio(parseColor('black')!, parseColor('white')!)).toBeCloseTo(21, 1); expect(parseColor('var(--x)')).toBeNull(); }); }); describe('design-lint: markup reading', () => { test('tags keep positions and lines; styles and scripts are lifted; component files become pseudo-HTML with their class strings', () => { const doc = parseDoc('p.html', '\n\n

Hi & bye

\n'); expect(doc.tags.find((t) => t.name === 'h1')?.line).toBe(3); expect(doc.styles[0].css).toBe('h1{color:red}'); expect(doc.scripts.trim()).toBe('alert(1)'); expect(doc.text).toContain('Hi & bye'); expect(getAttr(doc.tags.find((t) => t.name === 'img')!.attrs, 'alt')).toBe(''); expect(plainText('

A · B →

')).toBe('A · B →'); const tsx = parseDoc('Hero.tsx', 'export const Hero = () => (

Hi

);'); expect(tsx.kind).toBe('jsx'); expect(tsx.tags.map((t) => t.name).filter((n) => n === 'section' || n === 'h1')).toEqual(['section', 'h1', 'h1', 'section']); expect(tsx.classAt.get(tsx.tags.find((t) => t.name === 'section')!.pos)).toEqual(['bg-indigo-500', 'p-4']); }); }); describe('design-lint: the rule table', () => { test('every id is kebab-case with a tier, a source gate, a title and a fix; docs/design-lint.md names each one', () => { const docs = fs.readFileSync(path.join(ROOT, 'docs', 'design-lint.md'), 'utf8'); for (const [id, m] of Object.entries(RULES)) { expect(id).toMatch(/^[a-z][a-z0-9-]*$/); expect(['blocks', 'ask', 'polish']).toContain(m.tier); expect(m.gate.length).toBeGreaterThan(0); expect(m.title.length).toBeGreaterThan(8); expect(m.fix.length).toBeGreaterThan(3); expect(docs).toContain('`' + id + '`'); } expect(Object.keys(RULES).length).toBeGreaterThan(100); expect(FAMILIES.map((f) => f.name)).toEqual(['color', 'type', 'structure', 'chrome', 'motion', 'assets', 'finish', 'component', 'copy']); }); test('the three house rules gstack keeps at ask stay at ask, and the Floor rules the plan names block', () => { expect(RULES['kicker-above-heading'].tier).toBe('ask'); expect(RULES['screen-word-budget'].tier).toBe('ask'); expect(RULES['em-dash-overuse'].tier).toBe('ask'); expect(RULES['overflow-x-unclipped'].tier).toBe('ask'); for (const id of ['purple-blue-gradient', 'nested-cards', 'wide-tracking', 'template-punctuation', 'missing-focus-visible', 'low-contrast', 'content-hidden-at-rest', 'hero-fold']) expect(RULES[id].tier).toBe('blocks'); }); }); describe('design-lint: the output contract', () => { test('a finding prints as LINT: : | fix: ; a suspected blocking tell lands at ask; unknown ids throw', () => { const r = new Reporter('page', '/cwd'); r.report('nested-cards', '/cwd/site/index.html', 12, 'div.card > div.card', {}); r.report('invented-proof', '/cwd/site/index.html', 30, '"Trusted by 500+ teams"', { suspected: true }); r.report('section-gap', '/cwd/site/index.html', 1, 'x'); expect(formatFinding(r.findings[0])).toBe('LINT: blocks nested-cards site/index.html:12 div.card > div.card | fix: one containment layer'); expect(r.findings[1].tier).toBe('ask'); expect(() => r.report('no-such-rule', 'f', 1, 'x')).toThrow(/unknown rule id/); const component = new Reporter('component', '/cwd'); component.report('section-gap', '/cwd/a.tsx', 1, 'page-only rule under component scope'); expect(component.findings).toEqual([]); }); test('blocks exit 2, an unanswered ask exits 1, polish alone exits 0; waived is counted apart from ask and never summed into blocks', () => { const base = { file: 'a.html', line: 1, evidence: 'e', fix: 'f', source: 'static' as const }; const blocks = { rule: 'nested-cards', tier: 'blocks' as const, grade: 'blocks' as const, ...base }; const ask = { rule: 'section-gap', tier: 'ask' as const, grade: 'ask' as const, ...base }; const waived = { ...ask, grade: 'waived' as const, waiver: 'because the ledger separates rows with rules' }; const polish = { rule: 'straight-quotes', tier: 'polish' as const, grade: 'polish' as const, ...base }; expect(exitCodeOf(summarize([blocks, ask, polish], 'skipped: x'))).toBe(2); expect(exitCodeOf(summarize([ask, polish], 'ran'))).toBe(1); expect(exitCodeOf(summarize([polish, waived], 'ran'))).toBe(0); expect(formatSummary(summarize([blocks, waived, waived, polish], 'ran'))).toBe('LINT_SUMMARY: blocks=1 ask=0 waived=2 polish=1 render=ran'); expect(finalize([polish, ask, blocks, blocks]).map((f) => f.grade)).toEqual(['blocks', 'ask', 'polish']); expect(formatFinding(waived)).toContain('| fix: waived (because the ledger'); }); test('the bin prints the contract and exits by it; --rules lists the table; a usage error exits 3', () => { const slopped = runBin(['test/fixtures/design-lint/slopped.html']); expect(slopped.code).toBe(2); const lines = slopped.out.trim().split('\n'); for (const l of lines.slice(0, -1)) expect(l).toMatch(/^LINT: (blocks|ask|polish|waived) [a-z][a-z0-9-]* \S+:\d+ .+ \| fix: .+$/); expect(lines.at(-1)).toMatch(/^LINT_SUMMARY: blocks=\d+ ask=\d+ waived=\d+ polish=\d+ render=skipped: not requested$/); expect(runBin(['--rules']).out).toContain('nested-cards'); expect(runBin([]).code).toBe(3); expect(runBin(['--scope', 'app', 'x.html']).code).toBe(3); const json = JSON.parse(runBin(['--json', 'test/fixtures/design-lint/slopped.html']).out); expect(json.summary.blocks).toBeGreaterThan(0); expect(Array.isArray(json.findings)).toBe(true); }); test('a clean page passes: exit 0 and LINT_SUMMARY blocks=0 ask=0', async () => { const file = tmpPage(fs.readFileSync(path.join(FIXTURES, 'clean', 'index.html'), 'utf8')); const result = await lint([file], { cwd: path.dirname(file) }); const unexpected = result.findings.filter((f) => f.grade === 'blocks' || f.grade === 'ask'); expect(unexpected.map(formatFinding)).toEqual([]); expect(result.exitCode).toBe(0); expect(formatResult(result)).toContain('LINT_SUMMARY: blocks=0 ask=0'); }); // Value: protects=every usage exit (3) the bin promises and --help's 0; fails_when=a flag check is dropped or a usage error falls through to lint() and exits 0; why_new=the bin test only covers no-args and --scope; seam=none; no_claim=stderr wording beyond the key phrase test('the bin exits 3 for a non-http --url, an unknown flag, a flag without its value, a path it cannot read and a tree with nothing to lint; --help exits 0', () => { const url = runBin(['--url', 'ftp://example.test/page']); expect([url.code, url.err]).toEqual([3, expect.stringContaining('http or https')]); const flag = runBin(['--bogus', 'x.html']); expect([flag.code, flag.err]).toEqual([3, expect.stringContaining('unknown flag --bogus')]); const value = runBin(['x.html', '--job']); expect([value.code, value.err]).toEqual([3, expect.stringContaining('--job needs a value')]); const missing = runBin(['/nowhere/design-lint/none.html']); expect([missing.code, missing.err]).toEqual([3, expect.stringContaining('cannot read /nowhere/design-lint/none.html')]); expect(missing.err).toContain('no .html, .css, .jsx, .tsx, .vue, .svelte or .astro files found'); const nothing = runBin([tmpTree({ 'notes.txt': 'not a page', 'data.json': '{}' })]); expect([nothing.code, nothing.out]).toEqual([3, '']); const help = runBin(['--help']); expect([help.code, help.out.trim(), help.err]).toEqual([0, expect.stringMatching(/^usage: gstack-design-lint { const cssOnly = tmpTree({ 'tokens.css': ':root { --color-ink: oklch(22% 0.03 250); }', 'Hero.tsx': 'export const Hero = () =>

Ledger

;' }); const result = await lint([cssOnly], { cwd: cssOnly, render: true, warn: () => {} }); expect(result.files.map((f) => path.basename(f))).toEqual(['Hero.tsx', 'tokens.css']); expect(result.summary.render).toBe('skipped: no HTML page to render'); expect(formatResult(result)).toMatch(/render=skipped: no HTML page to render$/); const nothing = await lint([cssOnly], { cwd: cssOnly, warn: () => {} }); expect(nothing.summary.render).toBe('skipped: not requested'); const r = runBin(['--url', 'http://127.0.0.1:9/']); if (browserAvailable()) { expect(r.code).toBe(2); expect(r.out).toMatch(/^LINT: blocks render http:\/\/127\.0\.0\.1:9\/:1 could not reach http:\/\/127\.0\.0\.1:9\/: .+ \| fix: .+$/m); expect(r.out).toMatch(/^LINT_SUMMARY: blocks=1 ask=0 waived=0 polish=0 render=ran$/m); } else { expect(r.code).toBe(0); expect(r.out).toMatch(/^LINT_SUMMARY: blocks=0 ask=0 waived=0 polish=0 render=skipped: /m); } }); }); describe('design-lint: the static tier\'s files and contexts', () => { // Value: protects=which files the walk reads and which stylesheet may carry a page's waiver; fails_when=vendored or built CSS is linted, or a linked stylesheet from elsewhere waives a page's finding; why_new=no test walks a tree with node_modules, dot or build directories, or a waiver in CSS; seam=none; no_claim=ordering inside one directory beyond sorted names test('the walk skips node_modules, dot folders and build output below the top, warns on an unreadable path; a waiver in any stylesheet the page reads answers for that stylesheet', () => { const dir = tmpTree({ 'site/index.html': 'Ledger

The ledger

The ledger separates its rows with hairline rules.

', 'site/local.css': '/* gstack-lint-disable section-gap: the ledger separates its rows with rules */\n.row { border-top: 1px solid var(--color-line); }', 'shared/theme.css': '/* gstack-lint-disable template-nav: the ledger keeps three links for its three modes */\n:root { --color-line: oklch(85% 0.015 80); }', 'site/node_modules/lib/vendor.css': '.v { color: #123456; }', 'site/.cache/stale.html': '

stale

', 'site/dist/built.css': '.b { color: #654321; }', 'dist/top.css': '.t { color: var(--color-line); }', }); const warned: string[] = []; const files = collectFiles([path.join(dir, 'site'), path.join(dir, 'dist'), path.join(dir, 'site', 'index.html'), path.join(dir, 'gone')], (l) => warned.push(l)); expect(files.map((f) => path.relative(dir, f))).toEqual(['site/index.html', 'site/local.css', 'dist/top.css']); expect(warned).toEqual([`design-lint: cannot read ${path.join(dir, 'gone')}`]); const { contexts } = lintStatic([path.join(dir, 'site')], { cwd: dir, warn: (l) => { throw new Error(l); } }); expect(contexts).toHaveLength(1); expect(contexts[0].cssFiles.map((c) => path.relative(dir, c.file))).toEqual(['site/local.css', 'shared/theme.css']); const set = contextWaivers(contexts[0], dir); expect([...set.accepted.entries()]).toEqual([['section-gap@site/local.css', 'the ledger separates its rows with rules'], ['template-nav@shared/theme.css', 'the ledger keeps three links for its three modes']]); expect(set.refused).toEqual([]); }); // Value: protects=two pages in one directory each own only the stylesheets they link; fails_when=grouping hands every stylesheet to every page, so one page's waiver answers the other's finding and the finding prints twice; why_new=every fixture directory holds one page; seam=none; no_claim=which page a stylesheet linked by neither belongs to test('two pages in one directory: a finding in one page\'s stylesheet is reported once and is not waived by the other page', () => { const dir = tmpTree({ 'a.html': 'Ledger

The ledger

The ledger closes the month on the first day.

', 'a.css': ':root { --color-gray: oklch(55% 0 0); --color-ink: oklch(22% 0.03 250); }\n.rule { border-color: var(--color-gray); }', 'b.html': '\nArchive

The archive

The archive keeps its grey plates on the shelf.

', 'b.css': ':root { --color-ink: oklch(22% 0.03 250); }\n.shelf { color: var(--color-ink); }', }); const result = lintStatic([dir], { cwd: dir, warn: (l) => { throw new Error(l); } }); expect(result.contexts.map((c) => [path.basename(c.key), c.cssFiles.map((f) => path.basename(f.file))])).toEqual([['a.html', ['a.css']], ['b.html', ['b.css']]]); const grey = finalize(result.findings).filter((f) => f.rule === 'zero-chroma-neutrals'); expect(grey.map((f) => [f.grade, f.file, f.line])).toEqual([['ask', 'a.css', 1]]); expect(result.findings.filter((f) => f.rule === 'waiver')).toEqual([]); }); }); describe('design-lint: the copy tier', () => { const DIRTY = `

Elevate your workflow seamlessly

Trusted by Acme and John Doe. 99.99% uptime, 50% faster, 10,000+ happy users. [Needs proof: logo]

Oops! Something broke. Lorem ipsum.

`; const CLEAN = `Ledgerline: close the books in 2 days Skip to contentPricing

Close the books in 2 days, not 9

Teams at Halvorsen Freight cut month-end by 47.2%. Save 20% yearly. 100% open source. 30-day refund.

`; const copy = (html: string, scope: 'page' | 'component' = 'page') => { const file = tmpPage(html); return lintStatic([file], { cwd: path.dirname(file), scope, warn: () => {} }).findings.filter((f) => f.source === 'copy'); }; const ids = (html: string) => [...new Set(copy(html).map((f) => f.rule))].sort(); // Value: protects=every copy-tier tell reports at the line that holds it, under the craft ids and catalog fixes; fails_when=a rule reports at line 1, drops a tell or re-reports the static tier's 100vh and transition: all; why_new=the fold replaced lib/design-craft's counts with positions; seam=none; no_claim=render-tier findings test('a page with every tell reports each one at its line, with what matched; 100vh and transition: all stay with the static tier', () => { const found = copy(DIRTY).map((f) => [f.rule, f.line, f.evidence]); expect(found).toEqual(expect.arrayContaining([ ['dead-link', 2, 'a dead link (href="#")'], ['missing-aria-current', 2, 'route navigation without aria-current'], ['marketing-buzzword', 3, 'a marketing buzzword: "Elevate", "seamlessly"'], ['placeholder-brand', 3, 'a placeholder brand (Acme, Nexus): "Acme"'], ['placeholder-name', 3, 'a placeholder name (John Doe): "John Doe"'], ['round-number', 3, 'a round invented number: "99.99% uptime", "50% faster", "10,000+ happy users"'], ['needs-proof', 3, 'a [Needs proof] marker left in the page: "[Needs proof:"'], ['img-no-alt', 4, 'an without alt'], ['oops', 4, '"oops" as an error message: "Oops"'], ['lorem', 4, 'lorem ipsum placeholder text: "Lorem ipsum"'], ['scroll-listener', 5, 'a scroll event listener'], ['alert', 5, 'alert() for a message'], ['missing-title', 1, 'a document without a '], ])); expect(ids(DIRTY)).toEqual(['alert', 'dead-link', 'img-no-alt', 'lorem', 'marketing-buzzword', 'missing-aria-current', 'missing-favicon', 'missing-meta-description', 'missing-skip-link', 'missing-title', 'needs-proof', 'oops', 'placeholder-brand', 'placeholder-name', 'round-number', 'scroll-listener']); for (const f of copy(DIRTY)) expect(f.fix, f.rule).toBe(catalogEntry(f.rule)!.fix!); const file = tmpPage(DIRTY); const all = lintStatic([file], { cwd: path.dirname(file), warn: () => {} }).findings; expect(all.filter((f) => f.rule === 'vh-height').map((f) => f.source)).toEqual(['static']); expect(all.filter((f) => f.rule === 'layout-transition').map((f) => f.source)).toEqual(['static']); }); test('a finished page is clean: CSS 100%, in-page anchors, a showAlert helper and inlined Pretext are not flagged', () => { expect(copy(CLEAN)).toEqual([]); const pretext = fs.readFileSync(path.join(ROOT, 'design-html', 'vendor', 'pretext.js'), 'utf-8'); expect(copy(CLEAN.replace('</body>', `<script type="module">${pretext}</script></body>`))).toEqual([]); }); test('every copy-tier id is a lint rule; each but the shell check is a grep-detectable catalog entry whose fix the lint prints', () => { expect(new Set(COPY_IDS).size).toBe(COPY_IDS.length); for (const id of COPY_IDS) { expect(RULES[id]?.gate, id).toBe('craft'); if (id === 'empty-mount-node') continue; expect(catalogEntry(id)?.detect, id).toContain('grep'); expect(RULES[id].fix, id).toBe(catalogEntry(id)!.fix!); } expect(Object.keys(RULES).filter((id) => RULES[id].gate === 'craft').sort()).toEqual([...COPY_IDS].sort()); }); // Value: protects=checker-only craft entries render into no skill prose or checklist, and the lint reports each; fails_when=a renderer reads DESIGN_SLOP_CATALOG, not RENDERED, or a checker-only id has no lint rule; why_new=moved from test/design-craft.test.ts with the checker; seam=none; no_claim=byte size of any one skill (size guard owns it) test('checker-only catalog entries are lint rules that no rendered list carries; catalog entries with a fix still render', () => { const checkerOnly = DESIGN_SLOP_CATALOG.filter((e) => e.checkerOnly); expect(checkerOnly.length).toBeGreaterThan(0); const rendered = new Set([selectCatalog({}), detectorSlopEntries(), judgmentTellEntries(), checklistSlopEntries(), autoFixEntries()].flat().map((e) => e.id)); const prose = renderCatalog({ kind: 'slop' }); for (const e of checkerOnly) { expect(RULES[e.id], e.id).toBeDefined(); expect(rendered.has(e.id), e.id).toBe(false); expect(prose, e.id).not.toContain(e.prose); } for (const id of ['marketing-buzzword', 'layout-transition']) expect(rendered.has(id), id).toBe(true); }); test('BUZZWORDS covers every word the catalog heuristic and the craft section name, and matches stems, not look-alikes', () => { const heuristic = catalogEntry('marketing-buzzword')?.heuristic ?? ''; const words = heuristic.replace(/^.*?for /, '').replace(/\.$/, '').split(/,\s*/); expect(words.length).toBeGreaterThan(5); for (const word of words) expect(BUZZWORDS).toContain(word); const section = fs.readFileSync(path.join(ROOT, 'design-html', 'sections', 'craft.md.tmpl'), 'utf-8'); const buzz = section.match(/No buzzwords: ([^\n]+)\.\n/)?.[1].replace(/"/g, '').split(/,\s*/) ?? []; expect(buzz.length).toBeGreaterThan(10); for (const word of buzz) expect(BUZZWORDS).toContain(word); expect(copy('<p>Unlocking seamless, next gen workflows that elevate teams</p>').map((f) => f.evidence)).toEqual(['a marketing buzzword: "Unlocking", "seamless", "next gen", "elevate"']); expect(copy('<p>Take the elevator to the elevation map; streamers sold here</p>')).toEqual([]); const names = section.match(/No placeholder people or brands \(([^)]+)\)/)?.[1].split(/,\s*/) ?? []; expect(names.length).toBe(5); for (const name of names) expect(ids(`<p>${name}</p>`)).toEqual([/Doe/.test(name) ? 'placeholder-name' : 'placeholder-brand']); }); test('the skip link is judged by position and target; aria-current is required only for route navigation', () => { expect(ids(CLEAN.replace('id="main"', 'id="content"'))).toEqual(['missing-skip-link']); expect(ids(CLEAN.replace('id="main"', 'data-id="main"'))).toEqual(['missing-skip-link']); expect(ids(CLEAN.replace('>Skip to content<', '>Zum Inhalt springen<'))).toEqual([]); expect(ids(CLEAN.replace('<a href="#main">Skip to content</a>', ''))).toEqual(['missing-skip-link']); expect(ids(CLEAN.replace('<a href="/" aria-current="page">Home</a><a href="/pricing">Pricing</a>', '<a href="#features">Features</a><a href="#faq">FAQ</a>'))).toEqual([]); expect(copy(CLEAN.replace(' aria-current="page"', '')).map((f) => [f.rule, f.line])).toEqual([['missing-aria-current', 4]]); }); test('copy reads visible text: entities decode, svg text counts, and comments, scripts, styles and templates do not', () => { expect(ids('<p>[Needs proof: logo]</p>')).toEqual(['needs-proof']); expect(ids('<img src="a.png" data-alt="x">')).toEqual(['img-no-alt']); expect(ids('<svg><text>Acme</text></svg>')).toEqual(['placeholder-brand']); expect(ids('<p>Set a price alert (free)</p>')).toEqual([]); expect(ids('<style>.a{content:"Acme"}</style><script>// John Doe</script><template><p>Lorem ipsum</p></template><p>Halvorsen</p>')).toEqual([]); expect(ids('<section><h2>Pricing</h2><a href="/buy">Start free trial</a></section>')).toEqual([]); }); // Value: protects=the documented craft-checker limits stay fixed in the fold: script payloads, single-quoted handlers, script text as presence, commented markup; fails_when=a code or presence rule reads the raw source again; why_new=#3433 listed these as checker limits for the follow-up; seam=none; no_claim=external scripts, which the lint does not fetch test('a hydration payload, commented-out markup and script text are not page content; single-quoted handlers are read', () => { expect(ids('<p>Halvorsen</p><script>window.__DATA__ = "<img src=a.png><a href=\\"#\\">x</a>";</script>')).toEqual([]); expect(ids('<!-- <img src="a.png"><p>Lorem ipsum</p><a href="#">x</a> --><p>Halvorsen</p>')).toEqual([]); expect(copy("<button onclick='alert(1)'>Go</button>\n<button onclick=\"alert(2)\">Go</button>").map((f) => [f.rule, f.line])).toEqual([['alert', 1], ['alert', 2]]); const head = '<!doctype html><html lang="en"><head><meta name="description" content="d"><link rel="icon" href="/i.svg"><script>const t = "<title>Ledger";Skip

Halvorsen

'; expect(ids(head)).toEqual(['missing-title']); }); // Value: protects=a client-rendered shell is named an empty mount node instead of passing as clean; fails_when=a