From fc2685b528500c5799b72c5934e551ed063b74aa Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 08:26:23 +0000 Subject: [PATCH] design-lint: third review cycle tests; render keeps the measured horizontal-scroll when merging widths Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/design-lint-assets.test.ts | 22 ++++ test/design-lint-chrome.test.ts | 22 ++++ test/design-lint-color.test.ts | 9 ++ test/design-lint-motion.test.ts | 19 +++ test/design-lint-settle.test.ts | 182 +++++++++++++++++++++++++++++ test/design-lint-structure.test.ts | 31 +++++ test/design-lint-tailwind.test.ts | 9 ++ test/design-lint-type.test.ts | 21 ++++ test/design-lint.test.ts | 74 +++++++++++- 9 files changed, 386 insertions(+), 3 deletions(-) create mode 100644 test/design-lint-settle.test.ts diff --git a/test/design-lint-assets.test.ts b/test/design-lint-assets.test.ts index c9dc50cd6..a73456593 100644 --- a/test/design-lint-assets.test.ts +++ b/test/design-lint-assets.test.ts @@ -137,3 +137,25 @@ describe('design-lint assets: fixtures', () => { expect(only(slopped.findings, 'unlabeled-decoration').map((f) => [f.grade, f.line])).toEqual([['blocks', 114]]); }); }); + +describe('design-lint assets: third review cycle', () => { + test('large text passes at 3:1 and the same colour on body text asks; body text at the 4.5:1 edge is clean', () => { + const big = page('.big { font-size: 32px; color: #959595; background: #fff; }', '

Large text at three to one

'); + expect(only(run(big), 'low-contrast')).toEqual([]); + const small = page('.small { font-size: 16px; color: #959595; background: #fff; }', '

Body text at three to one

'); + expect(only(run(small), 'low-contrast').map((f) => [f.grade, f.evidence])).toEqual([['ask', '3.00:1 under the 4.5:1 threshold on .small']]); + expect(only(run(page('.body { color: #767676; background: #fff; }', '

Readable

')), 'low-contrast')).toEqual([]); + }); + test('two-icon-libraries reads class tokens and asset references, not prose: feather-light and bi-weekly are words', () => { + const prose = page('', '

A feather-light app with our bi-weekly newsletter.

'); + expect(only(run(prose), 'two-icon-libraries')).toEqual([]); + const mixed = page('', '
'); + expect(only(run(mixed), 'two-icon-libraries').map((f) => f.evidence)).toEqual(['icon libraries mixed on one page: font-awesome and bootstrap-icons']); + const linked = page('', '
'); + expect(only(run(linked), 'two-icon-libraries').map((f) => f.evidence)).toEqual(['icon libraries mixed on one page: font-awesome and feather']); + }); + test('an svg inside a labelled list item whose end tag is left out is still labelled', () => { + const f = run(page('', '
')); + expect(only(f, 'unlabeled-decoration')).toEqual([]); + }); +}); diff --git a/test/design-lint-chrome.test.ts b/test/design-lint-chrome.test.ts index 42bbbeaa5..38ed7bac0 100644 --- a/test/design-lint-chrome.test.ts +++ b/test/design-lint-chrome.test.ts @@ -245,3 +245,25 @@ describe('design-lint chrome: fixtures', () => { expect(only(slopped.findings, 'kicker-above-heading').map((f) => [f.grade, f.line, f.evidence])).toEqual([['ask', 120, 'the eyebrow "01 / FEATURES" before

']]); }); }); + +describe('design-lint chrome: third review cycle', () => { + test('repeated-fact counts a percentage printed in four sections', () => { + const band = (i: number) => '

Save 30% off this month.

'; + const hits = only(run(page('', '
\n' + [1, 2, 3, 4].map(band).join('\n') + '\n
')), 'repeated-fact'); + expect(hits.map((f) => [f.grade, f.evidence])).toEqual([['ask', 'the same fact printed in 4 sections: 30% (4)']]); + }); + test('a stock line in the hero blocks; the same line in body copy below the hero is suspected, so it sits at ask and a waiver can answer it', () => { + const inHero = page('', '
\n

Ledger

Everything you need to close the month.

\n

Notes

The books close on the first.

\n
'); + expect(only(run(inHero), 'generic-hero-copy').map((f) => [f.grade, f.line])).toEqual([['blocks', lineOf(inHero, 'Everything you need')]]); + const below = page('', '
\n

Ledger

The books close on the first.

\n

Help

Whether you\'re logged in or not, your cart stays.

\n
'); + expect(only(run(below), 'generic-hero-copy').map((f) => [f.grade, f.line])).toEqual([['ask', lineOf(below, 'Whether you')]]); + const waived = run('\n' + below); + expect(only(waived, 'generic-hero-copy').map((f) => f.grade)).toEqual(['waived']); + expect(only(waived, 'waiver')).toEqual([]); + }); + test('a region whose list items leave their end tags out still counts facts per section', () => { + const band = (i: number) => '
  • From $49 a month.
  • Second item
'; + const hits = only(run(page('', '
\n' + [1, 2, 3, 4].map(band).join('\n') + '\n
')), 'repeated-fact'); + expect(hits.map((f) => f.evidence)).toEqual(['the same fact printed in 4 sections: $49 (4)']); + }); +}); diff --git a/test/design-lint-color.test.ts b/test/design-lint-color.test.ts index 836a8f8fe..9f1e4f4a2 100644 --- a/test/design-lint-color.test.ts +++ b/test/design-lint-color.test.ts @@ -200,3 +200,12 @@ describe('design-lint colour: fixtures', () => { expect(byRule(findings, 'pure-black-white')).toEqual([]); }); }); + +describe('design-lint colour: the indigo-to-violet ramp (third review cycle)', () => { + test('a near single-hue indigo ramp is not the indigo-to-violet gradient; stops twelve degrees apart are', () => { + const near = lintPage(page('.band { background: linear-gradient(90deg, oklch(60% 0.2 253), oklch(70% 0.15 258)); }')); + expect(lines(byRule(near.findings, 'purple-blue-gradient'))).toEqual([]); + const ramp = lintPage(page('.band { background: linear-gradient(90deg, oklch(60% 0.2 275), oklch(70% 0.15 300)); }')); + expect(byRule(ramp.findings, 'purple-blue-gradient').map((f) => [f.grade, f.evidence])).toEqual([['blocks', expect.stringContaining('indigo-to-violet')]]); + }); +}); diff --git a/test/design-lint-motion.test.ts b/test/design-lint-motion.test.ts index 834d91312..018ffca41 100644 --- a/test/design-lint-motion.test.ts +++ b/test/design-lint-motion.test.ts @@ -249,3 +249,22 @@ describe('design-lint motion: the shared fixtures', () => { expect(f.filter((x) => FAMILY.includes(x.rule)).map(formatFinding)).toEqual([]); }); }); + +describe('design-lint motion: the removed-outline check (third review cycle)', () => { + test('a fractional outline on :focus-visible is a ring, so outline: none on :focus does not block', async () => { + const f = await lintPage(page('a:focus { outline: none; }\na:focus-visible { outline: 0.25rem solid var(--color-ink); }', 'Go', { states: false }).replace('a:focus-visible, .button:focus-visible { outline: 2px solid var(--color-ink); }\n', '')); + none(f, 'missing-focus-visible'); + }); + test('the usual :focus:not(:focus-visible) reset with a ring, and a box-shadow ring beside outline: none, are clean; outline: 0 and outline-style: none with no ring block', async () => { + const base = (css: string) => page(css, 'Go', { states: false }); + none(await lintPage(base('a:focus:not(:focus-visible) { outline: none; }\na:focus-visible { outline: 2px solid var(--color-ink); }')), 'missing-focus-visible'); + none(await lintPage(base('a:focus { outline: none; box-shadow: 0 0 0 3px var(--color-ink); }')), 'missing-focus-visible'); + for (const removed of ['outline: 0', 'outline: 0px', 'outline-style: none']) { + const html = base(`a:focus { ${removed}; }`); + const f = await lintPage(html); + expect(only(f, 'missing-focus-visible').map((x) => [x.grade, x.evidence.split(' on ')[0]])).toEqual([['blocks', removed]]); + } + const hoverOnly = await lintPage(base('a:focus { outline: none; }\na:hover { outline: 2px solid var(--color-ink); }')); + expect(only(hoverOnly, 'missing-focus-visible').map((x) => x.grade)).toEqual(['blocks']); + }); +}); diff --git a/test/design-lint-settle.test.ts b/test/design-lint-settle.test.ts new file mode 100644 index 000000000..bcd34bc59 --- /dev/null +++ b/test/design-lint-settle.test.ts @@ -0,0 +1,182 @@ +/** + * The render tier settled without a browser: a fake page runner hands lintRender every answer a browser could give + * (nothing, null, malformed JSON, a probe error, a warn on a blocking rule, a polish tell, a thrown page script, + * a pixel pass that cannot be read) and lint() settles the result against the page's waivers. Every cell of the + * state matrix the browser tests only reach with Chromium installed is pinned here on every lane. + */ +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 type { RenderResult, RenderSpec } from '../lib/aside-render'; +import { lint } from '../lib/design-lint/index'; +import { lintRender, type Runner } from '../lib/design-lint/render'; +import type { Finding } from '../lib/design-lint/report'; +import { FLOOR_REFUSAL, OWN_RULE_REFUSAL } from '../lib/design-lint/waivers'; + +const tmpDirs: string[] = []; +afterAll(() => { for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); }); + +const PAGE = (lead = '') => lead + 'LedgerSkip to content

The ledger

The ledger closes the month with a hairline rule under every total.

'; + +function tmpPage(html: string): { dir: string; file: string } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-settle-')); + tmpDirs.push(dir); + const file = path.join(dir, 'index.html'); + fs.writeFileSync(file, html); + return { dir, file }; +} + +/** Step indices of one measuring pass: 0 installs the probes, 1 is setup, 2..13 the twelve measures (13 is the focus walk). */ +const SETUP = 1, FIRST = 2, FOCUS = 13; + +type Patch = (evals: Record, spec: RenderSpec) => void; + +/** A runner whose page answers every measure with nothing to report, then lets a test rewrite the answers; a pixel pass (the spec with a screenshot step) answers through the same patch. */ +function runner(patch: Patch = () => {}, stdout = ''): Runner & { specs: RenderSpec[] } { + const specs: RenderSpec[] = []; + const run = (async (spec: RenderSpec): Promise => { + specs.push(spec); + const evals: Record = { [SETUP]: '{"lost":[]}' }; + for (let i = FIRST; i <= FOCUS; i++) evals[i] = '[]'; + patch(evals, spec); + return { ok: true, engine: 'browse', outputs: [], evals, stdout }; + }) as Runner & { specs: RenderSpec[] }; + run.specs = specs; + return run; +} + +const renders = (f: Finding[]) => f.filter((x) => x.rule === 'render').map((x) => [x.grade, x.evidence]); +const byRule = (f: Finding[], rule: string) => f.filter((x) => x.rule === rule); + +describe('design-lint render settled without a browser: what the page hands back', () => { + test('a clean page measured by a quiet runner has no finding and status ran', async () => { + const { dir, file } = tmpPage(PAGE()); + const r = await lintRender([file], { cwd: dir, run: runner() }); + expect(r.status).toBe('ran'); + expect(r.findings).toEqual([]); + }); + + test('a measure that came back missing, null, malformed or as a probe error is one blocking render finding naming the probe and the width', async () => { + const { dir, file } = tmpPage(PAGE()); + const cases: Array<[string | undefined, RegExp]> = [ + [undefined, /^the layout measurement at 320px failed: no result came back$/], + ['null', /^the layout measurement at 320px failed: empty result$/], + ['abc', /^the layout measurement at 320px failed: .*JSON/], + ['{"error":"probe lost the document"}', /^the layout measurement at 320px failed: probe lost the document$/], + ]; + for (const [answer, want] of cases) { + const r = await lintRender([file], { cwd: dir, run: runner((ev) => { if (answer === undefined) delete ev[FIRST]; else ev[FIRST] = answer; }) }); + expect(renders(r.findings)).toHaveLength(1); + expect(renders(r.findings)[0][0]).toBe('blocks'); + expect(renders(r.findings)[0][1]).toMatch(want); + } + }); + + test('a setup that failed blocks; fonts that did not load are suspected, so the finding sits at ask', async () => { + const { dir, file } = tmpPage(PAGE()); + const failed = await lintRender([file], { cwd: dir, run: runner((ev) => { ev[SETUP] = '{"error":"no document"}'; }) }); + expect(renders(failed.findings)).toEqual([['blocks', 'the page could not be prepared for measurement: no document']]); + const lost = await lintRender([file], { cwd: dir, run: runner((ev) => { ev[SETUP] = '{"lost":["Literata","/css/site.css"]}'; }) }); + expect(renders(lost.findings)).toEqual([['ask', 'fonts or stylesheets did not load (Literata, /css/site.css): the measurements show a fallback face']]); + }); + + test('a page script that threw on load is a blocking render finding carrying the first 90 characters of its message', async () => { + const { dir, file } = tmpPage(PAGE()); + const r = await lintRender([file], { cwd: dir, run: runner(() => {}, 'PAGE_ERRORS=["uncaught: Error: boom at line 3"]\n') }); + expect(renders(r.findings)).toEqual([['blocks', 'a script threw on load: Error: boom at line 3']]); + const long = await lintRender([file], { cwd: dir, run: runner(() => {}, 'PAGE_ERRORS=' + JSON.stringify(['uncaught: ' + 'x'.repeat(200)]) + '\n') }); + expect(renders(long.findings)[0][1]).toBe('a script threw on load: ' + 'x'.repeat(90)); + }); + + test('a page that did not render, and a runner that threw, each land as one blocking render finding', async () => { + const { dir, file } = tmpPage(PAGE()); + const dead = await lintRender([file], { cwd: dir, run: async () => ({ ok: false, outputs: [], evals: {}, stdout: '', error: 'browser gone' }) }); + expect(renders(dead.findings)).toEqual([['blocks', 'the page did not render: browser gone']]); + const threw = await lintRender([file], { cwd: dir, run: async () => { throw new Error('socket closed'); } }); + expect(renders(threw.findings)).toEqual([['blocks', 'the page could not be measured: socket closed']]); + }); + + test('a warn on a blocking rule lands at ask with source render; a fail lands at blocks; a rule the lint does not know is tampering', async () => { + const { dir, file } = tmpPage(PAGE()); + const r = await lintRender([file], { cwd: dir, run: runner((ev) => { + ev[FIRST] = JSON.stringify([{ rule: 'horizontal-scroll', grade: 'warn', evidence: 'the hero may overflow' }, { rule: 'horizontal-scroll', grade: 'fail', evidence: 'the hero overflows' }]); + ev[FIRST + 1] = JSON.stringify([{ rule: 'nope', grade: 'fail', evidence: 'x' }, { rule: 'horizontal-scroll', grade: 'maybe', evidence: 'y' }]); + }) }); + expect(byRule(r.findings, 'horizontal-scroll').map((f) => [f.grade, f.source, f.evidence])).toEqual([['blocks', 'render', '[320px] the hero overflows (also at 320, 375px)']]); + const warns = await lintRender([file], { cwd: dir, run: runner((ev) => { ev[FIRST] = JSON.stringify([{ rule: 'horizontal-scroll', grade: 'warn', evidence: 'the hero may overflow' }]); }) }); + expect(byRule(warns.findings, 'horizontal-scroll').map((f) => [f.grade, f.source, f.evidence])).toEqual([['ask', 'render', '[320px] the hero may overflow']]); + expect(renders(r.findings).map(([g, e]) => [g, e.replace(/\(.*\)/, '(…)')])).toEqual([ + ['blocks', 'the page handed back a measurement the lint does not know (…); its scripts may be tampering with the probes'], + ]); + }); + + test('a contrast claim whose pixel pass cannot be read keeps its computed ratio', async () => { + const { dir, file } = tmpPage(PAGE()); + const claim = { rule: 'low-contrast', grade: 'fail', evidence: 'computed contrast 2.50:1 under the 4.5:1 threshold on p.faint (2.50:1)', probe: { path: 'main>p', fg: [180, 180, 180], threshold: 4.5 } }; + for (const hide of ['{"error":"hide failed"}', 'null', undefined]) { + const run = runner((ev, spec) => { + if (spec.steps.some((s) => s.kind === 'screenshot')) { if (hide === undefined) delete ev[FIRST]; else ev[FIRST] = hide; return; } + ev[FIRST + 5] = JSON.stringify([claim]); + }); + const r = await lintRender([file], { cwd: dir, run }); + expect(run.specs).toHaveLength(2); + expect(byRule(r.findings, 'low-contrast').map((f) => [f.grade, f.evidence])).toEqual([['blocks', '[1280px] computed contrast 2.50:1 under the 4.5:1 threshold on p.faint (2.50:1)']]); + } + }); +}); + +describe('design-lint render settled without a browser: waivers through lint()', () => { + test('a render warn on a blocking rule is suspected, so the page claim answers it: waived, no refusal', async () => { + const { dir } = tmpPage(PAGE('\n')); + const r = await lint([dir], { cwd: dir, render: true, warn: () => {}, run: runner((ev) => { ev[FIRST] = JSON.stringify([{ rule: 'horizontal-scroll', grade: 'warn', evidence: 'the table may overflow' }]); }) }); + expect(byRule(r.findings, 'waiver')).toEqual([]); + expect(byRule(r.findings, 'horizontal-scroll').map((f) => [f.grade, f.file, f.source])).toEqual([['waived', 'index.html', 'render']]); + expect(r.summary.waived).toBe(1); + expect(r.exitCode).toBe(0); + }); + + test('without the render tier nothing suspects the rule, so the same claim is refused as the Floor', async () => { + const { dir } = tmpPage(PAGE('\n')); + const r = await lint([dir], { cwd: dir, warn: () => {} }); + expect(byRule(r.findings, 'waiver').map((f) => f.evidence)).toEqual([`waiver on horizontal-scroll refused: ${FLOOR_REFUSAL}`]); + expect(byRule(r.findings, 'horizontal-scroll')).toEqual([]); + }); + + test('a short claim on a suspected rule is refused for its length, not as the Floor, and the render warn stays at ask', async () => { + const { dir } = tmpPage(PAGE('\n')); + const r = await lint([dir], { cwd: dir, render: true, warn: () => {}, run: runner((ev) => { ev[FIRST] = JSON.stringify([{ rule: 'horizontal-scroll', grade: 'warn', evidence: 'the table may overflow' }]); }) }); + expect(byRule(r.findings, 'waiver').map((f) => f.evidence)).toEqual(['waiver on horizontal-scroll refused: reason under 24 characters']); + expect(byRule(r.findings, 'horizontal-scroll').map((f) => f.grade)).toEqual(['ask']); + expect(r.exitCode).toBe(1); + }); + + test('a measured blocking render finding stays at blocks and a claim on the lint\'s own render rule is refused by name', async () => { + const { dir } = tmpPage(PAGE('\n\n')); + const r = await lint([dir], { cwd: dir, render: true, warn: () => {}, run: runner((ev) => { ev[FIRST] = JSON.stringify([{ rule: 'horizontal-scroll', grade: 'fail', evidence: 'the table overflows' }]); ev[FIRST + 1] = JSON.stringify([{ rule: 'nope', grade: 'fail', evidence: 'x' }]); }) }); + expect(byRule(r.findings, 'waiver').map((f) => f.evidence).sort()).toEqual([`waiver on horizontal-scroll refused: ${FLOOR_REFUSAL}`, `waiver on render refused: ${OWN_RULE_REFUSAL}`]); + expect(byRule(r.findings, 'horizontal-scroll').map((f) => f.grade)).toEqual(['blocks']); + expect(renders(r.findings).map(([g]) => g)).toEqual(['blocks']); + expect(r.summary.waived).toBe(0); + expect(r.exitCode).toBe(2); + }); + + test('a polish render tell is settled by the page claim at waived', async () => { + const { dir } = tmpPage(PAGE('\n')); + const r = await lint([dir], { cwd: dir, render: true, warn: () => {}, run: runner((ev) => { ev[FIRST + 7] = JSON.stringify([{ rule: 'text-sliver', grade: 'fail', evidence: 'a 90px column' }]); }) }); + expect(byRule(r.findings, 'waiver')).toEqual([]); + expect(byRule(r.findings, 'text-sliver').map((f) => [f.grade, f.source])).toEqual([['waived', 'render']]); + expect(r.summary.polish).toBe(0); + expect(r.summary.waived).toBe(1); + }); + + test('render findings of a URL target pass through unsettled, and a page with a whitespace run in its name still meets its own findings', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-settle-')); + tmpDirs.push(dir); + fs.writeFileSync(path.join(dir, 'my page.html'), PAGE('\n')); + const r = await lint([dir], { cwd: dir, render: true, urls: ['http://127.0.0.1:1/x'], warn: () => {}, run: runner((ev) => { ev[FIRST] = JSON.stringify([{ rule: 'horizontal-scroll', grade: 'warn', evidence: 'the table may overflow' }]); }) }); + expect(byRule(r.findings, 'horizontal-scroll').map((f) => [f.grade, f.file])).toEqual([['waived', 'my page.html']]); + expect(renders(r.findings).map(([g, e]) => [g, e.startsWith('could not reach http://127.0.0.1:1/x')])).toEqual([['blocks', true]]); + expect(byRule(r.findings, 'render')[0].file).toBe('http://127.0.0.1:1/x'); + }); +}); diff --git a/test/design-lint-structure.test.ts b/test/design-lint-structure.test.ts index 52fa383f7..b02dae960 100644 --- a/test/design-lint-structure.test.ts +++ b/test/design-lint-structure.test.ts @@ -230,3 +230,34 @@ describe('design-lint structure: the shared fixtures', () => { expect(f.filter((x) => FAMILY.includes(x.rule)).map(formatFinding)).toEqual([]); }); }); + +describe('design-lint structure: third review cycle', () => { + test('sibling cards in a list whose end tags are left out are not nested cards', async () => { + const f = await lintPage(page('', '
  • One
  • Two
')); + none(f, 'nested-cards'); + const nested = await lintPage(page('', '
  • One
    Inner
')); + expect(hits(nested, 'nested-cards').map(([g]) => g)).toEqual(['blocks']); + }); + test('one selector sticky at two breakpoints is not a double sticky; two selectors are', async () => { + none(await lintPage(page('.site-header { position: sticky; top: 0; }\n@media (min-width: 900px) { .site-header { position: sticky; top: 0; padding: 16px; } }', '')), 'double-sticky'); + const two = await lintPage(page('.site-header { position: sticky; top: 0; }\n.toolbar { position: sticky; top: 0; }', '
T
')); + expect(hits(two, 'double-sticky').map(([g]) => g)).toEqual(['blocks']); + }); + test('a light grey side border on a blockquote is not the accent stripe; a dark ink stripe and a coloured one are', async () => { + none(await lintPage(page('blockquote { border-left: 4px solid #dddddd; }', '
A line
')), 'template-cards'); + expect(hits(await lintPage(page('.note { border-left: 4px solid var(--color-ink); }', '

A note

')), 'template-cards').map(([g]) => g)).toEqual(['ask']); + expect(hits(await lintPage(page('.note { border-left: 4px solid oklch(60% 0.18 30); }', '

A note

')), 'template-cards').map(([g]) => g)).toEqual(['ask']); + }); + test('many grid rules with a bare fr track do not rescan the document per rule', async () => { + let css = ''; + let body = ''; + for (let i = 0; i < 1500; i++) { + css += `.c${i} { grid-template-columns: 1fr 2fr; }\n`; + body += `

Heading ${i}

Text.

Photo ${i}
`; + } + const t = performance.now(); + const f = await lintPage(page(css, body)); + expect(performance.now() - t).toBeLessThan(8000); + expect(hits(f, 'bare-fr-track').length).toBe(1500); + }); +}); diff --git a/test/design-lint-tailwind.test.ts b/test/design-lint-tailwind.test.ts index 9ace15b91..467460ab6 100644 --- a/test/design-lint-tailwind.test.ts +++ b/test/design-lint-tailwind.test.ts @@ -360,3 +360,12 @@ describe('design-lint tailwind: fixtures', () => { } }); }); + +describe('design-lint tailwind: one gradient classifier for both tiers (third review cycle)', () => { + test('arbitrary indigo-to-violet stops report the same purple-blue-gradient the stylesheet form does', async () => { + const tw = await lintFiles({ 'A.tsx': 'export const A = () =>
hello there
;' }); + expect(only(tw, 'purple-blue-gradient').map((f) => [f.grade, f.evidence])).toEqual([['blocks', expect.stringContaining('indigo-to-violet')]]); + const css = await lintFiles({ 'a.css': '.g { background: linear-gradient(90deg, #6366f1, #8b5cf6); }' }); + expect(only(css, 'purple-blue-gradient').map((f) => [f.grade, f.evidence])).toEqual([['blocks', expect.stringContaining('indigo-to-violet')]]); + }); +}); diff --git a/test/design-lint-type.test.ts b/test/design-lint-type.test.ts index f0424e7ce..b506db442 100644 --- a/test/design-lint-type.test.ts +++ b/test/design-lint-type.test.ts @@ -190,3 +190,24 @@ describe('design-lint type: fixtures', () => { expect(byRule(findings, 'monospace-costume').map((f) => [f.grade, f.line])).toEqual([['ask', lineOf(html, '.a__eyebrow {')]]); }); }); + +describe('design-lint type: third review cycle', () => { + test('banned-font: a banned face as the first family on a heading or in a --font-* token asks once; a fallback position, a var() and a near name are clean', () => { + const { html, findings } = lintPage(page('h1 { font-family: "Comic Sans MS", cursive; }\nh2 { font-family: Impact, sans-serif; }\n.quote { font-family: var(--font-body); }\n.note { font-family: "Papyrus Variable Text", serif; }\n.last { font-family: Georgia, Papyrus, serif; }', '

Ledger

Notes

Q

N

L

', TOKENS.replace('--font-body: "Public Sans", sans-serif;', '--font-body: Papyrus, serif;'))); + const hits = byRule(findings, 'banned-font').map((f) => [f.grade, f.line, f.evidence]); + expect(hits).toEqual([ + ['ask', lineOf(html, '--font-body: Papyrus'), '"papyrus" (--font-body)'], + ['ask', lineOf(html, '"Comic Sans MS"'), '"comic sans ms" (h1)'], + ['ask', lineOf(html, 'Impact'), '"impact" (h2)'], + ['ask', lineOf(html, '"Papyrus Variable Text"'), '"papyrus variable text" (.note)'], + ]); + }); + test('an empty icon glyph in a heading, and an that is the whole heading, are not an italic word', () => { + const icon = lintPage(page('', '

Features

')); + expect(lines(byRule(icon.findings, 'italic-word-in-heading'))).toEqual([]); + const whole = lintPage(page('', '

Whole heading italic

')); + expect(lines(byRule(whole.findings, 'italic-word-in-heading'))).toEqual([]); + const word = lintPage(page('', '

Fast and safe

')); + expect(byRule(word.findings, 'italic-word-in-heading').map((f) => f.grade)).toEqual(['blocks']); + }); +}); diff --git a/test/design-lint.test.ts b/test/design-lint.test.ts index 5b530d0b3..71abcbb79 100644 --- a/test/design-lint.test.ts +++ b/test/design-lint.test.ts @@ -8,12 +8,12 @@ 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 { buildTokens, parseCss, pxOf, resolveVars } from '../lib/design-lint/css'; +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 } from '../lib/design-lint/static'; +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'; @@ -338,6 +338,74 @@ describe('design-lint: waivers', () => { }); }); +describe('design-lint: third review cycle', () => { + test('two waiver comments on one line are both read, and a stylesheet claim answers only for that stylesheet', () => { + const claims = findWaivers('a.html', ' \n/* impeccable-disable overused-font: the masthead face is the paper */ .x{}'); + expect(claims.map((c) => [c.rule, c.reason])).toEqual([['section-gap', 'the ledger separates rows'], ['line-length', 'the manifesto sets long lines'], ['overused-font', 'the masthead face is the paper']]); + const dir = tmpTree({ + 'index.html': 'Ledger

The ledger

The ledger closes the month.

', + 't.css': '/* gstack-lint-disable zero-chroma-neutrals: quartz pelican umbrella semaphore xylophone */\n:root { --color-ink: oklch(22% 0.03 250); --color-gray: oklch(55% 0 0); }\n.cell { color: var(--color-gray); }', + }); + const f = finalize(lintStatic([dir], { cwd: dir, warn: () => {} }).findings); + expect(f.filter((x) => x.rule === 'waiver')).toEqual([]); + expect(f.filter((x) => x.rule === 'zero-chroma-neutrals').map((x) => [x.file, x.grade]).sort()).toEqual([['index.html', 'ask'], ['t.css', 'waived']]); + }); + + test('the indexed stylesFor answers exactly as the linear scan', () => { + const rules = parseCss('a{color:red}\n.x{margin:0}\n.x.y{padding:1px}\na.x:hover{color:blue}\n@media (max-width:600px){.x{top:0}}\nbutton.y{gap:2px}\n.y a{left:0}\ndiv{right:0}', 'a.css'); + const idx = buildStyleIndex(rules); + const plain = [...idx] as StyleIndex; + let compared = 0; + for (const cls of ['', 'x', 'y', 'x y', 'z']) for (const tag of ['a', 'button', 'div', 'span']) for (const resting of [false, true]) { + expect(stylesFor(idx, cls, tag, resting)).toEqual(stylesFor(plain, cls, tag, resting)); + compared++; + } + expect(compared).toBe(40); + expect(stylesFor(idx, 'x y', 'a', false).map((d) => d.prop + ':' + d.value)).toContain('padding:1px'); + expect(stylesFor(idx, 'x', 'div', true).map((d) => d.prop + ':' + d.value)).toEqual(['margin:0', 'right:0']); + }); + + test('the bin forwards --job (case-folded) and --scope to the lint', () => { + const html = 't

Letters

A monthly letter about the harbour.

'; + const file = tmpPage(html); + const plain = runBin([file], path.dirname(file)); + expect(plain.out).toMatch(/^LINT: ask email-band-close index\.html:\d+ .*no job was named/m); + const subscribe = runBin(['--job', 'Subscribe', file], path.dirname(file)); + expect(subscribe.out).not.toContain('email-band-close'); + const component = runBin(['--scope', 'component', file], path.dirname(file)); + expect(component.out).not.toContain('email-band-close'); + expect(component.out).not.toMatch(/^LINT: \w+ (missing-skip-link|template-nav|section-gap) /m); + }); + + test('a comma-heavy nested selector and a long unclosed list each lint in linear time', async () => { + let t = performance.now(); + parseCss('a{' + ','.repeat(80000) + '{color:red}}', 'x.css'); + expect(performance.now() - t).toBeLessThan(1500); + const dir = tmpTree({ 'index.html': 't
    ' + '
  • item text here'.repeat(8000) + '
' }); + t = performance.now(); + await lint([dir], { warn: () => {} }); + expect(performance.now() - t).toBeLessThan(8000); + }); + + test('a file over the size limit is skipped with a warning and a symlink cycle is walked once', () => { + const dir = tmpTree({ 'site/index.html': 't', 'site/big.css': 'a{}' }); + fs.truncateSync(path.join(dir, 'site', 'big.css'), MAX_FILE_BYTES + 1); + fs.symlinkSync(path.join(dir, 'site'), path.join(dir, 'site', 'loop')); + const warned: string[] = []; + const files = collectFiles([dir], (l) => warned.push(l)); + expect(files.map((f) => path.relative(dir, f))).toEqual(['site/index.html']); + expect(warned).toEqual([`design-lint: skipped ${path.join(dir, 'site', 'big.css')} (2 MB, over the 2 MB limit)`]); + }); + + test('every id gstack-design-craft can emit has a lint rule or belongs to the static tier', () => { + const src = fs.readFileSync(path.join(ROOT, 'lib', 'design-craft.ts'), 'utf8'); + const ids = [...new Set([...src.matchAll(/id: '([a-z-]+)'/g)].map((m) => m[1]))]; + expect(ids.length).toBeGreaterThan(15); + expect(ids.filter((id) => !(id in RULES) && id !== 'transition-all')).toEqual([]); + expect(ids.filter((id) => id in RULES && RULES[id].gate !== 'craft')).toEqual(['vh-height']); + }); +}); + describe('design-lint: hardening (ship review)', () => { test('a stylesheet one page links from another directory is linted once, with that page, and its waiver is weighed against the page', () => { const dir = tmpTree({ @@ -455,7 +523,7 @@ describe('design-lint: hardening (ship review)', () => { test('a long whitespace run inside a waiver comment is linear, and a carriage return ends the reason', () => { const t0 = performance.now(); const claims = findWaivers('a.html', '\n/* impeccable-disable line-length: the manifesto\rLINT_SUMMARY: blocks=0 */'); - expect(performance.now() - t0).toBeLessThan(500); + expect(performance.now() - t0).toBeLessThan(2000); expect(claims.map((c) => [c.rule, c.reason.length <= 300, c.reason.startsWith('aaaa') || c.reason])).toEqual([['overused-font', true, true], ['line-length', true, 'the manifesto']]); });