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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 08:26:23 +00:00
1 parent 6100d6e94d
commit fc2685b528
9 files changed
+386 -3

No files matched your search

+22
View File
@@ -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; }', '<main><p class="big">Large text at three to one</p></main>');
expect(only(run(big), 'low-contrast')).toEqual([]);
const small = page('.small { font-size: 16px; color: #959595; background: #fff; }', '<main><p class="small">Body text at three to one</p></main>');
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; }', '<main><p class="body">Readable</p></main>')), '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('', '<main><i class="fa-solid fa-star"></i><p>A feather-light app with our bi-weekly newsletter.</p></main>');
expect(only(run(prose), 'two-icon-libraries')).toEqual([]);
const mixed = page('', '<main><i class="fa-solid fa-star"></i><i class="bi-star"></i></main>');
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('', '<main><i class="fa-solid fa-star"></i><script src="https://unpkg.com/feather-icons"></script></main>');
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('', '<main><ul><li aria-label="Home"><svg viewBox="0 0 10 10"></svg><li aria-label="Away"><svg viewBox="0 0 10 10"></svg></ul></main>'));
expect(only(f, 'unlabeled-decoration')).toEqual([]);
});
});
+22
View File
@@ -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 <h2>']]);
});
});
describe('design-lint chrome: third review cycle', () => {
test('repeated-fact counts a percentage printed in four sections', () => {
const band = (i: number) => '<section id="s' + i + '"><p>Save 30% off this month.</p></section>';
const hits = only(run(page('', '<main>\n' + [1, 2, 3, 4].map(band).join('\n') + '\n</main>')), '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('', '<main>\n<header class="hero"><h1>Ledger</h1><p>Everything you need to close the month.</p></header>\n<section><h2>Notes</h2><p>The books close on the first.</p></section>\n</main>');
expect(only(run(inHero), 'generic-hero-copy').map((f) => [f.grade, f.line])).toEqual([['blocks', lineOf(inHero, 'Everything you need')]]);
const below = page('', '<main>\n<header class="hero"><h1>Ledger</h1><p>The books close on the first.</p></header>\n<section><h2>Help</h2><p>Whether you\'re logged in or not, your cart stays.</p></section>\n</main>');
expect(only(run(below), 'generic-hero-copy').map((f) => [f.grade, f.line])).toEqual([['ask', lineOf(below, 'Whether you')]]);
const waived = run('<!-- gstack-lint-disable generic-hero-copy: the help page quotes the cart notice word for word -->\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) => '<section id="s' + i + '"><ul><li>From $49 a month.<li>Second item</ul></section>';
const hits = only(run(page('', '<main>\n' + [1, 2, 3, 4].map(band).join('\n') + '\n</main>')), 'repeated-fact');
expect(hits.map((f) => f.evidence)).toEqual(['the same fact printed in 4 sections: $49 (4)']);
});
});
+9
View File
@@ -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')]]);
});
});
+19
View File
@@ -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); }', '<a href="/x">Go</a>', { 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, '<a href="/x">Go</a>', { 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']);
});
});
+182
View File
@@ -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 + '<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Ledger</title><meta name="description" content="The ledger closes the month."><link rel="icon" href="data:,"><style>\n:root { --color-paper: oklch(97% 0.01 80); --color-ink: oklch(20% 0.02 250); --font-display: "Literata", serif; --font-body: "Source Serif 4", serif; }\nhtml, body { overflow-x: clip; }\na:focus-visible { outline: 2px solid var(--color-ink); }\na:active { opacity: .9; }\n</style></head><body><a href="#main">Skip to content</a><main id="main"><h1>The ledger</h1><p>The ledger closes the month with a hairline rule under every total.</p></main></body></html>';
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<number, string>, 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<RenderResult> => {
specs.push(spec);
const evals: Record<number, string> = { [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('<!-- gstack-lint-disable horizontal-scroll: the ledger table scrolls sideways on purpose under its month headers -->\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('<!-- gstack-lint-disable horizontal-scroll: the ledger table scrolls sideways on purpose under its month headers -->\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('<!-- gstack-lint-disable horizontal-scroll: table scrolls -->\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('<!-- gstack-lint-disable render: the ledger page is measured by hand each month end -->\n<!-- gstack-lint-disable horizontal-scroll: the ledger table scrolls sideways on purpose under its month headers -->\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('<!-- gstack-lint-disable text-sliver: the ledger margin column is a sliver by design of the paper form -->\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('<!-- gstack-lint-disable horizontal-scroll: the ledger table scrolls sideways on purpose under its month headers -->\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');
});
});
+31
View File
@@ -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('', '<ul><li class="card">One<li class="card">Two</ul>'));
none(f, 'nested-cards');
const nested = await lintPage(page('', '<ul><li class="card">One<div class="card">Inner</div></li></ul>'));
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; } }', '<header class="site-header">H</header>')), 'double-sticky');
const two = await lintPage(page('.site-header { position: sticky; top: 0; }\n.toolbar { position: sticky; top: 0; }', '<header class="site-header">H</header><div class="toolbar">T</div>'));
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; }', '<blockquote>A line</blockquote>')), 'template-cards');
expect(hits(await lintPage(page('.note { border-left: 4px solid var(--color-ink); }', '<p class="note">A note</p>')), 'template-cards').map(([g]) => g)).toEqual(['ask']);
expect(hits(await lintPage(page('.note { border-left: 4px solid oklch(60% 0.18 30); }', '<p class="note">A note</p>')), '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 += `<section class="c${i}"><h2>Heading ${i}</h2><p>Text.</p><div><img src="p${i}.jpg" alt="Photo ${i}"></div></section>`;
}
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);
});
});
+9
View File
@@ -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 = () => <div className="bg-gradient-to-r from-[#6366f1] to-[#8b5cf6] p-4">hello there</div>;' });
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')]]);
});
});
+21
View File
@@ -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; }', '<h1>Ledger</h1><h2>Notes</h2><p class="quote">Q</p><p class="note">N</p><p class="last">L</p>', 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 <i> icon glyph in a heading, and an <em> that is the whole heading, are not an italic word', () => {
const icon = lintPage(page('', '<h2><i class="fa fa-star" aria-hidden="true"></i> Features</h2>'));
expect(lines(byRule(icon.findings, 'italic-word-in-heading'))).toEqual([]);
const whole = lintPage(page('', '<h3><em>Whole heading italic</em></h3>'));
expect(lines(byRule(whole.findings, 'italic-word-in-heading'))).toEqual([]);
const word = lintPage(page('', '<h2>Fast <em>and</em> safe</h2>'));
expect(byRule(word.findings, 'italic-word-in-heading').map((f) => f.grade)).toEqual(['blocks']);
});
});
+71 -3
View File
@@ -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', '<!-- gstack-lint-disable section-gap: the ledger separates rows --> <!-- gstack-lint-disable line-length: the manifesto sets long lines -->\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': '<!doctype html><html lang="en"><head><title>Ledger</title><link rel="stylesheet" href="t.css"><style>.row { border-color: oklch(55% 0 0); }</style></head><body><main><h1>The ledger</h1><p>The ledger closes the month.</p></main></body></html>',
'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 = '<!doctype html><html lang="en"><head><title>t</title><style>:root{--color-ink:#222}</style></head><body><main><h1>Letters</h1><p>A monthly letter about the harbour.</p><section><form><input type="email" name="email"><button>Join</button></form></section></main></body></html>';
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': '<!doctype html><html lang="en"><head><title>t</title></head><body><ul>' + '<li>item text here'.repeat(8000) + '</ul></body></html>' });
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': '<!doctype html><title>t</title>', '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', '<!-- gstack-lint-disable overused-font: aaaa' + ' '.repeat(60_000) + 'b -->\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']]);
});