From 3f0c62582c204d703d944611a04607498bab6c12 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 10:06:28 +0000 Subject: [PATCH] design-lint: fifth review cycle tests and docs test/design-lint-fifth-cycle.test.ts (21 tests) pins the comment-dead placeholder, the linear-time digit run, 699k-rule sheet, unclosed JSX and tracked-label pages, per-sheet waivers and the remaining state-matrix cells, every wrong-shaped render field, and the rule judgements; the fourth-cycle shell test asserts the observable effect, the RSS assertion is structural, time bounds are widened for shared CI shards, the stale empty block is gone, and the FREE_FIXTURES inventory names both cycle files. docs/design-lint.md states the project-root bound on relative hrefs and the three narrow readings (whole-page focus ring, unconditional 4px scale, scroll-container-blind double-sticky). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/design-lint.md | 14 +- test/design-lint-fifth-cycle.test.ts | 239 ++++++++++++++++++++++++++ test/design-lint-fourth-cycle.test.ts | 17 +- test/design-lint.test.ts | 25 ++- test/helpers/free-fixtures-data.ts | 2 +- 5 files changed, 274 insertions(+), 23 deletions(-) create mode 100644 test/design-lint-fifth-cycle.test.ts diff --git a/docs/design-lint.md b/docs/design-lint.md index f2dc53ad4..825b3265a 100644 --- a/docs/design-lint.md +++ b/docs/design-lint.md @@ -32,7 +32,10 @@ gstack-design-lint [--render] [--url ] [--scope pa A page or stylesheet under a lint root that the lint skipped (over the cap, unreadable, unparseable) is also one `internal-error` finding at ask, so a page the lint could not read is not a pass; a linked stylesheet over the cap - is only warned about, because the page that links it is still read. + is only warned about, because the page that links it is still read. A + relative stylesheet href may not climb out of the project (the nearest + directory holding a `package.json` or `.git`); outside any project it is + bounded only by the disk, and only `.css` files are read. - Markup inside an HTML comment is dead for every rule, a commented-out `\n\n\n' + body + '\n\n\n'; + +function tree(files: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-cycle5-')); + tmpDirs.push(dir); + for (const [name, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, name)), { recursive: true }); + fs.writeFileSync(path.join(dir, name), text); + } + return dir; +} +const quiet = { warn: () => {} }; +const run = (html: string, files: Record = {}): Finding[] => { const dir = tree({ 'index.html': html, ...files }); return lintStatic([dir], { cwd: dir, ...quiet }).findings; }; +const only = (f: Finding[], rule: string) => f.filter((x) => x.rule === rule); +const timed = (fn: () => void): number => { const t = performance.now(); fn(); return performance.now() - t; }; + +describe('design-lint fifth cycle: dead markup and hostile inputs', () => { + test('an emoji placeholder inside an HTML comment is dead', () => { + const f = run(page('', '

Hi there

Text.

\n\n
')); + expect(only(f, 'emoji-decoration')).toEqual([]); + const live = run(page('', '

Hi there

')); + expect(only(live, 'emoji-decoration').length).toBe(1); + }); + + test('a long digit run, a long mono word and a run of scheme prefixes each cost their length', () => { + const dir = tree({ 'index.html': page('', '

Hi there

' + '7'.repeat(200_000) + '

' + 'https://'.repeat(20_000) + '

') }); + expect(timed(() => lintStatic([dir], { cwd: dir, ...quiet }))).toBeLessThan(4000); + expect(timed(() => twDataText('a'.repeat(400_000)))).toBeLessThan(500); + }); + + test('a 2 MB stylesheet of 699k rules lints instead of blowing the stack', () => { + const dir = tree({ 'big.css': 'a{}'.repeat(Math.floor(MAX_FILE_BYTES / 3) - 1), 'index.html': HEAD + '

Hello there

Body.

' }); + expect(() => lintStatic([dir], { cwd: dir, ...quiet })).not.toThrow(); + }); + + test('a JSX file of unclosed elements parses in linear time', () => { + const times = [5000, 10000].map((n) => timed(() => parseDoc('Page.tsx', 'export default function P(){ return (
' + 'text\n'.repeat(n) + '
); }'))); + expect(times[1]).toBeLessThan(Math.max(times[0] * 3, 300)); + expect(times[1]).toBeLessThan(4000); + }); + + test('a page of many tracked-caps labels lints in linear time and a well-formed JSX file still yields its classes', () => { + const times = [4000, 8000].map((n) => { const dir = tree({ 'index.html': page('', '

Hello world

' + 'Label one\n'.repeat(n) + '
') }); return timed(() => lintStatic([dir], { cwd: dir, ...quiet })); }); + expect(times[1]).toBeLessThan(Math.max(times[0] * 2.5, 500)); + const doc = parseDoc('Page.tsx', 'export default function P(){ return (
x
); }'); + expect(doc.classAt.size + doc.virtuals.length).toBeGreaterThan(0); + }); + + test('a fact after an inline close tag is counted in its own section', () => { + const sec = (i: number) => '

S' + i + '

Price: $5 a month

'; + const f = run(page('', '

Hi there

' + [0, 1, 2, 3].map(sec).join('') + '
')); + expect(only(f, 'repeated-fact').length).toBe(1); + }); + + test('a relative stylesheet href that climbs out of the project attaches nothing; inside the project it attaches', () => { + const dir = tree({ 'site/package.json': '{}', 'site/pages/index.html': HEAD + '

t

Body.

', 'outside.css': 'html, body { overflow-x: clip; }' }); + const r = lintStatic([path.join(dir, 'site')], { cwd: dir, ...quiet }); + expect(r.contexts[0].cssFiles).toEqual([]); + expect(only(r.findings, 'overflow-x-unclipped').length).toBe(1); + expect(resolveRef('css/site.css', path.join(dir, 'site'), path.join(dir, 'site'))).toBe(path.join(dir, 'site', 'css/site.css')); + expect(resolveRef('../outside.css', path.join(dir, 'site'), path.join(dir, 'site'))).toBeNull(); + }); + + test('an unreadable top-level path beside a readable page is one internal-error finding with its own fix', () => { + const dir = tree({ 'index.html': page('', '

Hi

Body.

') }); + const r = lintStatic([dir, path.join(dir, 'nowhere.html')], { cwd: dir, ...quiet }); + expect(r.files.map((f) => path.basename(f))).toEqual(['index.html']); + expect(only(r.findings, 'internal-error').map((f) => [f.grade, f.file, f.line, /cannot be read/.test(f.evidence), f.fix])).toEqual([['ask', 'nowhere.html', 1, true, 'check the path']]); + }); +}); + +describe('design-lint fifth cycle: waivers per sheet and the untested cells', () => { + const claim = (rule: string, reason: string) => ({ rule, reason, file: 'a.html', line: 3 }); + const PAGE_TEXT = 'The manifesto sets long declarative lines. The ledger separates its rows with rules. Three links for the paper modes.'; + + test('two linked stylesheets each waive the same rule for themselves; a short second claim is refused for its reason', () => { + const sheets = (b: string) => ({ + 'index.html': HEAD + '

The ledger

The ledger keeps its grey rows grey.

', + 'a.css': '/* gstack-lint-disable zero-chroma-neutrals: the ledger keeps its grey rows grey in the theme sheet */\n' + TOKENS + '\n:root { --color-gray: oklch(55% 0 0); }\n.row { border-color: var(--color-gray); }', + 'b.css': '/* gstack-lint-disable zero-chroma-neutrals: ' + b + ' */\n:root { --color-gray-print: oklch(45% 0 0); }\n.plate { color: var(--color-gray-print); }', + }); + const both = lintStatic([tree(sheets('the print sheet keeps grey plates grey for the press'))], { cwd: tmpDirs[tmpDirs.length - 1], ...quiet }).findings; + expect(only(both, 'waiver')).toEqual([]); + expect(only(both, 'zero-chroma-neutrals').map((x) => [x.file, x.grade]).sort()).toEqual([['a.css', 'waived'], ['b.css', 'waived']]); + const short = lintStatic([tree(sheets('short'))], { cwd: tmpDirs[tmpDirs.length - 1], ...quiet }).findings; + expect(only(short, 'waiver').map((x) => x.evidence)).toEqual(['waiver on zero-chroma-neutrals refused: reason under 24 characters']); + expect(only(short, 'zero-chroma-neutrals').map((x) => [x.file, x.grade]).sort()).toEqual([['a.css', 'waived'], ['b.css', 'ask']]); + }); + + test('a stylesheet claim with a boilerplate reason is refused and its finding stays ask', () => { + const dir = tree({ 'index.html': HEAD + '

Ledger

Rows.

', 't.css': '/* gstack-lint-disable zero-chroma-neutrals: this is intentional and by design for the sheet */\n' + TOKENS + '\n:root { --color-gray: oklch(55% 0 0); }\n.row { color: var(--color-gray); }' }); + const f = lintStatic([dir], { cwd: dir, ...quiet }).findings; + expect(only(f, 'waiver').map((x) => x.evidence)).toEqual(['waiver on zero-chroma-neutrals refused: boilerplate reason']); + expect(only(f, 'zero-chroma-neutrals').map((x) => x.grade)).toEqual(['ask']); + }); + + test('a second page claim on an accepted rule is refused as the duplicate even when its reason is short, and three accepted plus one refused is not a house style', () => { + const dup = resolveWaivers([claim('gradient-text', 'the ledger separates its rows with rules'), claim('gradient-text', 'short')], PAGE_TEXT); + expect([...dup.accepted.keys()]).toEqual(['gradient-text']); + expect(dup.refused.map((f) => f.evidence)).toEqual(['waiver on gradient-text refused: one waiver per rule']); + const four = resolveWaivers([claim('line-length', 'the manifesto sets long declarative lines'), claim('section-gap', 'the ledger separates its rows with rules'), claim('template-nav', 'three links for the paper modes'), claim('overused-font', 'short')], PAGE_TEXT); + expect(four.accepted.size).toBe(3); + expect(four.refused.map((f) => f.rule)).toEqual(['waiver']); + }); + + test('suspected leaves an ask or polish rule at its own tier', () => { + const r = new Reporter('page', '/cwd'); + r.report('section-gap', '/cwd/a.html', 1, 'x', { suspected: true }); + r.report('text-sliver', '/cwd/a.html', 1, 'x', { suspected: true, source: 'render' }); + expect(r.findings.map((f) => [f.tier, f.grade])).toEqual([['ask', 'ask'], ['polish', 'polish']]); + }); +}); + +describe('design-lint fifth cycle: render answers of the wrong shape, field by field', () => { + const PAGE = HEAD + '

t

Body.

Go
'; + const SWATCH = path.join(path.resolve(import.meta.dir, '..'), 'test', 'fixtures', 'design-lint', 'render', 'swatch.png'); + const SETUP = 1, FIRST = 2, FOCUS = 13; + const SCROLL = { rule: 'horizontal-scroll', grade: 'fail', evidence: 'the hero overflows' }; + type Patch = (evals: Record, spec: RenderSpec) => void; + const runner = (patch: Patch, stdout = ''): Runner => async (spec: RenderSpec): Promise => { + const evals: Record = {}; + const shot = spec.steps.findIndex((s) => s.kind === 'screenshot'); + if (shot < 0) { + evals[SETUP] = '{"lost":[]}'; + for (let i = FIRST; i <= FOCUS; i++) evals[i] = '[]'; + patch(evals, spec); + return { ok: true, engine: 'browse', outputs: [], evals, stdout }; + } + spec.steps.forEach((s, i) => { + if (s.kind !== 'screenshot') return; + fs.copyFileSync(SWATCH, s.out); + const width = s.width ?? 0; + evals[i - 1] = JSON.stringify([{ x: 0, y: 0, w: width / 2, h: width }]); + }); + return { ok: true, engine: 'browse', outputs: [], evals, stdout }; + }; + const measured = (f: Finding[]) => only(f, 'horizontal-scroll').map((x) => x.evidence); + const noAbort = (f: Finding[]) => only(f, 'render').filter((x) => /could not be measured/.test(x.evidence)); + + test('a non-string at.cls, a ring without its colour, a probe without fg and a non-string PAGE_ERRORS entry each keep the measured findings', async () => { + const dir = tree({ 'index.html': PAGE }); + const file = path.join(dir, 'index.html'); + const cls = await lintRender([file], { cwd: dir, run: runner((ev) => { ev[FIRST] = JSON.stringify([{ rule: 'nested-cards', grade: 'fail', evidence: 'x', at: { tag: 'p', text: 'Body.', cls: {} } }, SCROLL]); }) }); + expect(measured(cls.findings)).toEqual(['[320px] the hero overflows']); + expect(only(cls.findings, 'nested-cards').map((f) => f.line)).toEqual([4]); + expect(noAbort(cls.findings)).toEqual([]); + + const ring = await lintRender([file], { cwd: dir, run: runner((ev) => { ev[FIRST] = JSON.stringify([SCROLL]); ev[FOCUS] = JSON.stringify([{ key: 'a', path: 'main>a', at: { tag: 'a', text: 'Go' }, bare: false, ratio: 1.2, pad: 2 }]); }) }); + expect(measured(ring.findings)).toEqual(['[320px] the hero overflows']); + expect(noAbort(ring.findings)).toEqual([]); + expect(only(ring.findings, 'render').map((f) => /\((.*?)\)/.exec(f.evidence)![1])).toEqual(['focus']); + + const fg = await lintRender([file], { cwd: dir, run: runner((ev) => { ev[FIRST] = JSON.stringify([SCROLL]); ev[FIRST + 5] = JSON.stringify([{ 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', threshold: 4.5 } }, { rule: 'low-contrast', grade: 'fail', evidence: 'y', probe: true }]); }) }); + expect(measured(fg.findings)).toEqual(['[320px] the hero overflows']); + expect(only(fg.findings, 'low-contrast').map((f) => f.evidence)).toEqual(['[1280px] computed contrast 2.50:1 under the 4.5:1 threshold on p.faint (2.50:1)', '[1280px] y']); + expect(noAbort(fg.findings)).toEqual([]); + + const errs = await lintRender([file], { cwd: dir, run: runner((ev) => { ev[FIRST] = JSON.stringify([SCROLL]); }, 'PAGE_ERRORS=[1]\n') }); + expect(measured(errs.findings)).toEqual(['[320px] the hero overflows']); + expect(only(errs.findings, 'render')).toEqual([]); + }); + + test('a malformed entry claiming the render rule is reported as tampering with evidence on the line', async () => { + const dir = tree({ 'index.html': PAGE }); + const file = path.join(dir, 'index.html'); + const r = await lintRender([file], { cwd: dir, run: runner((ev) => { ev[FIRST] = JSON.stringify([{ rule: 'render', grade: 'x', evidence: 'y' }]); }) }); + expect(only(r.findings, 'render').map((f) => f.evidence)).toEqual([expect.stringMatching(/does not know \(render\)/)]); + expect(formatFinding(only(r.findings, 'render')[0])).toMatch(/^LINT: blocks render \S+:\d+ .+ \| fix: .+$/); + }); + + test('a short claim on a rule the render tier measured is refused as the Floor after both tiers settle', async () => { + const dir = tree({ 'index.html': '\n' + PAGE }); + const r = await lint([dir], { cwd: dir, render: true, warn: () => {}, run: runner((ev) => { ev[FIRST] = JSON.stringify([SCROLL]); }) }); + expect(only(r.findings, 'waiver').map((f) => f.evidence)).toEqual([`waiver on horizontal-scroll refused: ${FLOOR_REFUSAL}`]); + expect(only(r.findings, 'horizontal-scroll').map((f) => f.grade)).toEqual(['blocks']); + }); +}); + +describe('design-lint fifth cycle: rule judgement', () => { + test('a textarea taller than 52px is not a control-height finding; a button is', () => { + const body = '

Hi there

'; + expect(only(run(page('textarea { min-height: 120px; }', body)), 'control-height')).toEqual([]); + expect(only(run(page('button { height: 30px; }', body)), 'control-height').length).toBe(1); + }); + + test('a border width change on an attribute-selected input is input-state-shift', () => { + const f = run(page('input[type="text"] { border: 1px solid #333; } input[type="text"]:focus { border: 2px solid #000; outline: 2px solid red; }', '

Hi there

')); + expect(only(f, 'input-state-shift').length).toBe(1); + }); + + test('an holding only a glyph is not an italic word; a word is', () => { + expect(only(run(page('', '

Our favourite tools ★

Body.

')), 'italic-word-in-heading')).toEqual([]); + expect(only(run(page('', '

Our favourite tools and more

Body.

')), 'italic-word-in-heading').length).toBe(1); + }); + + test('a bounce keyframe by name on a UI selector is bounce-easing', () => { + const f = run(page('@keyframes bounce { from { transform: none } to { transform: translateY(-4px) } } .btn:hover { animation: bounce .4s; }', '

Hi there

Go
')); + expect(only(f, 'bounce-easing').map((x) => x.evidence)).toEqual([expect.stringMatching(/^bounce animation bounce on/)]); + }); + + test('a single-quoted inline style colour literal is literal-outside-tokens like its double-quoted twin', () => { + const single = run(page('', "

Hi there

x

")); + const double = run(page('', '

Hi there

x

')); + expect(only(single, 'literal-outside-tokens').map((x) => x.evidence)).toEqual(only(double, 'literal-outside-tokens').map((x) => x.evidence)); + expect(only(single, 'literal-outside-tokens').length).toBe(1); + }); + + test('a glass panel mixed from black names black; a tracked-only control is not called uppercase; emoji icon tiles stop at eight', () => { + const glass = run(page('.glass::after { backdrop-filter: blur(8px); background: color-mix(in oklch, black 40%, transparent); }', '

Hi there

x
')); + expect(only(glass, 'glow-wash').map((x) => x.evidence)).toEqual([expect.stringMatching(/translucent black/)]); + const tracked = run(page('.btn { font-size: 13px; letter-spacing: .06em; }', '

Hi there

Go now
')); + expect(only(tracked, 'wide-tracking').map((x) => x.evidence)).toEqual([expect.stringMatching(/^\.btn: a control tracked 0\.06em at 13px$/)]); + const tiles = run(page('', '

Hi there

' + Array.from({ length: 12 }, (_, i) => '
🚀

T' + i + '

').join('') + '
')); + expect(only(tiles, 'emoji-decoration').length).toBeLessThanOrEqual(8); + }); +}); diff --git a/test/design-lint-fourth-cycle.test.ts b/test/design-lint-fourth-cycle.test.ts index 6ebc089b0..feff242b6 100644 --- a/test/design-lint-fourth-cycle.test.ts +++ b/test/design-lint-fourth-cycle.test.ts @@ -54,9 +54,12 @@ describe('design-lint fourth cycle: commented-out markup is dead for every rule' expect(only(after, 'literal-outside-tokens').concat(only(after, 'italic-word-in-heading'), only(after, 'broken-image'), only(after, 'fake-avatar'), only(after, 'generic-hero-copy'))).toEqual([]); }); - test('a commented-out module script and stylesheet link do not make the page a build-only app', () => { - const live = run(page('', '

Harbour

Rows.

\n')); - expect(only(live, 'unbuilt-source')).toEqual([]); + test('a commented-out module script does not make the page a build-only shell, so its page rules still run', () => { + const body = '

Harbour

Rows.

'; + const shell = HEAD + '' + body + ''; + const commented = HEAD + '' + body + ''; + expect(only(run(shell), 'overflow-x-unclipped')).toEqual([]); + expect(only(run(commented), 'overflow-x-unclipped').length).toBe(1); }); }); @@ -97,7 +100,7 @@ describe('design-lint fourth cycle: one pairing for every tree reader', () => { } const t = performance.now(); scriptSites('