mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
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>
This commit is contained in:
1 parent
1595ff68c2
commit
3f0c62582c
5 files changed
+274
-23
No files matched your search
+13
-1
@@ -32,7 +32,10 @@ gstack-design-lint <file-or-dir ...> [--render] [--url <http://…>] [--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
|
||||
`<style>` or `<script>` included. A tree nests at most 512 elements deep
|
||||
(the browser's own cap); a start tag deeper than that is read as empty. An
|
||||
@@ -312,6 +315,15 @@ deduplicated at the end, so forty pages on one bundle cost forty passes over
|
||||
it. `overused-font` and `banned-font` match a catalog stem to the whole
|
||||
family name or its first words (`Cormorant Garamond`, `DM Serif Display`,
|
||||
`Comic Sans MS`), never to a longer word (`Interstate` is not `Inter`).
|
||||
Three checklist items are read more narrowly here than the LLM pass reads
|
||||
them: `missing-focus-visible` reports a removed outline only when no
|
||||
`:focus` rule anywhere on the page draws a ring (the per-element check is the
|
||||
render tier's focus walk); `off-scale-spacing` holds every px length to the
|
||||
4px scale whether or not DESIGN.md declares one (`raw-spacing` is the rule
|
||||
gated on `--space-*` tokens); and `double-sticky` pairs any two `top: 0`
|
||||
sticky selectors without reading their scroll containers, so a sticky table
|
||||
header inside an `overflow: auto` wrapper under a sticky nav reports and
|
||||
wants a waiver on the record.
|
||||
|
||||
## What is deliberately not ported
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* The fifth review cycle's regressions: the last comment-blanked reader, hostile inputs that stayed quadratic or blew
|
||||
* the stack (a digit run, 699k rules, unclosed JSX, thousands of tracked labels), per-sheet waivers, the render tier
|
||||
* holding its measured findings against every wrong-shaped field, and the rule judgements the design pass asked for.
|
||||
*/
|
||||
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 { parseDoc } from '../lib/design-lint/html';
|
||||
import { lint } from '../lib/design-lint/index';
|
||||
import { lintRender, type Runner } from '../lib/design-lint/render';
|
||||
import { formatFinding, Reporter, type Finding } from '../lib/design-lint/report';
|
||||
import { lintStatic, MAX_FILE_BYTES, resolveRef } from '../lib/design-lint/static';
|
||||
import { twDataText } from '../lib/design-lint/tailwind';
|
||||
import { FLOOR_REFUSAL, resolveWaivers } from '../lib/design-lint/waivers';
|
||||
|
||||
const tmpDirs: string[] = [];
|
||||
afterAll(() => { for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); });
|
||||
|
||||
const TOKENS = ':root { --color-paper: oklch(97% 0.01 80); --color-ink: oklch(20% 0.02 250); --color-accent: oklch(50% 0.16 30); --color-accent-ink: oklch(98% 0.01 30); --color-muted: oklch(50% 0.02 250); --font-display: "Literata", serif; --font-body: "Public Sans", sans-serif; }';
|
||||
const HEAD = '<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8"><title>Harbour office</title><meta name="description" content="A test page."><link rel="icon" href="data:,">\n';
|
||||
const page = (css: string, body: string): string => HEAD + '<style>\n' + TOKENS + '\n' + css + '\n</style>\n</head>\n<body>\n' + body + '\n</body>\n</html>\n';
|
||||
|
||||
function tree(files: Record<string, string>): 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<string, string> = {}): 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('', '<main><h1>Hi there</h1><p>Text.</p>\n<!-- <input placeholder="🚀 Search"> -->\n</main>'));
|
||||
expect(only(f, 'emoji-decoration')).toEqual([]);
|
||||
const live = run(page('', '<main><h1>Hi there</h1><form><input placeholder="🚀 Search"></form></main>'));
|
||||
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('', '<main><h1>Hi there</h1><p>' + '7'.repeat(200_000) + '</p><p>' + 'https://'.repeat(20_000) + '</p></main>') });
|
||||
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 + '<link rel="stylesheet" href="big.css"></head><body><main><h1>Hello there</h1><p>Body.</p></main></body></html>' });
|
||||
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 (<div>' + '<Card className="p-4">text\n'.repeat(n) + '</div>); }')));
|
||||
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('', '<main><h1>Hello world</h1>' + '<span class="text-xs uppercase tracking-widest">Label one</span>\n'.repeat(n) + '</main>') }); 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 (<div className="p-4"><Card className="rounded-xl">x</Card></div>); }');
|
||||
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) => '<section><h2>S' + i + '</h2><p><strong>Price:</strong> $5 a month</p></section>';
|
||||
const f = run(page('', '<main><h1>Hi there</h1>' + [0, 1, 2, 3].map(sec).join('') + '</main>'));
|
||||
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 + '<link rel="stylesheet" href="../../outside.css"></head><body><main><h1>t</h1><p>Body.</p></main></body></html>', '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('', '<main><h1>Hi</h1><p>Body.</p></main>') });
|
||||
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 + '<link rel="stylesheet" href="a.css"><link rel="stylesheet" href="b.css"></head><body><main><h1>The ledger</h1><p>The ledger keeps its grey rows grey.</p></main></body></html>',
|
||||
'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 + '<link rel="stylesheet" href="t.css"></head><body><main><h1>Ledger</h1><p>Rows.</p></main></body></html>', '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 + '</head><body><main><h1>t</h1><p class="faint">Body.</p><a href="#">Go</a></main></body></html>';
|
||||
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<number, string>, spec: RenderSpec) => void;
|
||||
const runner = (patch: Patch, stdout = ''): Runner => async (spec: RenderSpec): Promise<RenderResult> => {
|
||||
const evals: Record<number, string> = {};
|
||||
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': '<!-- gstack-lint-disable horizontal-scroll: table scrolls -->\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 = '<main><h1>Hi there</h1><form><textarea></textarea><button>Go</button></form></main>';
|
||||
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; }', '<main><h1>Hi there</h1><form><input type="text"></form></main>'));
|
||||
expect(only(f, 'input-state-shift').length).toBe(1);
|
||||
});
|
||||
|
||||
test('an <i> holding only a glyph is not an italic word; a word is', () => {
|
||||
expect(only(run(page('', '<main><h1>Our favourite tools <i class="icon">★</i></h1><p>Body.</p></main>')), 'italic-word-in-heading')).toEqual([]);
|
||||
expect(only(run(page('', '<main><h1>Our <i>favourite</i> tools and more</h1><p>Body.</p></main>')), '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; }', '<main><h1>Hi there</h1><a class="btn" href="#">Go</a></main>'));
|
||||
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('', "<main><h1>Hi there</h1><p style='color:#ff0000'>x</p></main>"));
|
||||
const double = run(page('', '<main><h1>Hi there</h1><p style="color:#ff0000">x</p></main>'));
|
||||
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); }', '<main><h1>Hi there</h1><div class="glass">x</div></main>'));
|
||||
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; }', '<main><h1>Hi there</h1><a class="btn" href="#">Go now</a></main>'));
|
||||
expect(only(tracked, 'wide-tracking').map((x) => x.evidence)).toEqual([expect.stringMatching(/^\.btn: a control tracked 0\.06em at 13px$/)]);
|
||||
const tiles = run(page('', '<main><h1>Hi there</h1>' + Array.from({ length: 12 }, (_, i) => '<div class="card"><span class="icon">🚀</span><h3>T' + i + '</h3></div>').join('') + '</main>'));
|
||||
expect(only(tiles, 'emoji-decoration').length).toBeLessThanOrEqual(8);
|
||||
});
|
||||
});
|
||||
@@ -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('', '<main><h1>Harbour</h1><p>Rows.</p></main>\n<!-- <script type="module" src="/src/main.tsx"></script> -->'));
|
||||
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 = '<main><h1>Harbour</h1><p>Rows.</p></main>';
|
||||
const shell = HEAD + '<script type="module" src="/src/main.tsx"></script></head><body>' + body + '</body></html>';
|
||||
const commented = HEAD + '<!-- <script type="module" src="/src/main.tsx"></script> --></head><body>' + body + '</body></html>';
|
||||
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('<template>' + '<script setup>\nconst a = 1;\n'.repeat(8000) + '</template>', 'vue');
|
||||
expect(performance.now() - t).toBeLessThan(500);
|
||||
expect(performance.now() - t).toBeLessThan(2000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,7 +110,7 @@ describe('design-lint fourth cycle: hostile CSS', () => {
|
||||
expect(resolveVars('var(--a)', { '--a': 'var(--a) ' + 'x'.repeat(40) })).toContain('x');
|
||||
expect(resolveVars('var(--a)', { '--a': 'var(--b)', '--b': 'var(--a)' })).toMatch(/var\(--[ab]\)/);
|
||||
expect(resolveVars('var(--a)', { '--a': 'var(--b) 1px', '--b': 'red' })).toBe('red 1px');
|
||||
expect(performance.now() - t).toBeLessThan(1000);
|
||||
expect(performance.now() - t).toBeLessThan(2000);
|
||||
const grown = resolveVars('var(--a)', { '--a': 'var(--b) var(--b)', '--b': 'var(--c) var(--c)', '--c': 'var(--d) var(--d)', '--d': 'var(--e) var(--e)', '--e': 'y'.repeat(2048) });
|
||||
expect(grown.length).toBeLessThan(40000);
|
||||
});
|
||||
@@ -221,7 +224,7 @@ describe('design-lint fourth cycle: render answers of the wrong shape', () => {
|
||||
}) });
|
||||
expect(only(odd.findings, 'horizontal-scroll').map((f) => f.evidence)).toEqual(['[320px] the hero overflows']);
|
||||
expect(only(odd.findings, 'nested-cards').map((f) => [f.grade, f.line])).toEqual([['blocks', 1]]);
|
||||
expect(only(odd.findings, 'render').map((f) => /\((.*?)\)/.exec(f.evidence)![1])).toEqual(['nested-cards', '7']);
|
||||
expect(only(odd.findings, 'render').map((f) => /\((.*?)\)/.exec(f.evidence)![1])).toEqual(['nested-cards', '7', 'focus']);
|
||||
expect(only(odd.findings, 'missing-focus-visible')).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -248,7 +251,7 @@ describe('design-lint fourth cycle: never-waivable classes refuse by class befor
|
||||
test('a short reason on an own rule, a measured Floor rule and a Floor rule nobody found is refused by class, never for its length', () => {
|
||||
const measured = new Reporter('page', '/x');
|
||||
measured.report('nested-cards', '/x/a.html', 4, 'div.card > div.card');
|
||||
const claims = ['render', 'nested-cards', 'horizontal-scroll'].map((rule) => ({ rule, reason: 'fine here', file: '/x/a.html', line: 1, raw: '' }));
|
||||
const claims = ['render', 'nested-cards', 'horizontal-scroll'].map((rule) => ({ rule, reason: 'fine here', file: '/x/a.html', line: 1 }));
|
||||
const set = resolveWaivers(claims, 'the ledger closes the month', '/x', suspectedRules(measured.findings));
|
||||
expect(set.refused.map((f) => f.evidence)).toEqual([
|
||||
`waiver on render refused: ${OWN_RULE_REFUSAL}`,
|
||||
|
||||
+11
-14
@@ -240,7 +240,7 @@ describe('design-lint: the static tier\'s files and contexts', () => {
|
||||
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', { reason: 'the ledger separates its rows with rules', sheet: 'site/local.css' }], ['template-nav', { reason: 'the ledger keeps three links for its three modes', sheet: 'shared/theme.css' }]]);
|
||||
expect([...set.accepted.entries()]).toEqual([['section-gap@site/local.css', { reason: 'the ledger separates its rows with rules', sheet: 'site/local.css' }], ['template-nav@shared/theme.css', { reason: 'the ledger keeps three links for its three modes', sheet: 'shared/theme.css' }]]);
|
||||
expect(set.refused).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -283,7 +283,7 @@ describe('design-lint: the copy tier', () => {
|
||||
|
||||
describe('design-lint: waivers', () => {
|
||||
const page = 'The manifesto sets long declarative lines on black paper for the ledger.';
|
||||
const claim = (rule: string, reason: string) => ({ rule, reason, file: 'a.html', line: 3, raw: '' });
|
||||
const claim = (rule: string, reason: string) => ({ rule, reason, file: 'a.html', line: 3 });
|
||||
|
||||
test('an ask or polish rule is waived with the rule id and a reason about the page; the Floor is refused', () => {
|
||||
const set = resolveWaivers([claim('line-length', 'the manifesto sets long declarative lines'), claim('nested-cards', 'the manifesto nests its ledger on purpose for the paper')], page);
|
||||
@@ -380,7 +380,7 @@ describe('design-lint: third review cycle', () => {
|
||||
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);
|
||||
expect(performance.now() - t).toBeLessThan(3000);
|
||||
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: () => {} });
|
||||
@@ -487,15 +487,15 @@ describe('design-lint: hardening (ship review)', () => {
|
||||
const suspected = new Reporter('page', '/x');
|
||||
suspected.report('invented-proof', '/x/a.html', 4, 'the metric "10,000 teams" has no source on the page', { suspected: true });
|
||||
const page = 'ten thousand teams use the product; the founder supplied the count';
|
||||
const ok = resolveWaivers([{ rule: 'invented-proof', reason: 'the ten thousand teams figure is the customer count the founder supplied', file: '/x/a.html', line: 2, raw: '' }], page, '/x', suspectedRules(suspected.findings));
|
||||
const ok = resolveWaivers([{ rule: 'invented-proof', reason: 'the ten thousand teams figure is the customer count the founder supplied', file: '/x/a.html', line: 2 }], page, '/x', suspectedRules(suspected.findings));
|
||||
expect(applyWaivers(suspected.findings, ok).map((f) => [f.rule, f.tier, f.grade])).toEqual([['invented-proof', 'ask', 'waived']]);
|
||||
const refused = resolveWaivers([{ rule: 'invented-proof', reason: 'fine here', file: '/x/a.html', line: 2, raw: '' }], page, '/x', suspectedRules(suspected.findings));
|
||||
const refused = resolveWaivers([{ rule: 'invented-proof', reason: 'fine here', file: '/x/a.html', line: 2 }], page, '/x', suspectedRules(suspected.findings));
|
||||
expect(applyWaivers(suspected.findings, refused).map((f) => [f.rule, f.tier, f.grade])).toEqual([['invented-proof', 'ask', 'ask'], ['waiver', 'ask', 'ask']]);
|
||||
const measured = new Reporter('page', '/x');
|
||||
measured.report('invented-proof', '/x/a.html', 4, 'measured');
|
||||
measured.report('invented-proof', '/x/a.html', 9, 'suspected too', { suspected: true });
|
||||
expect(suspectedRules(measured.findings).size).toBe(0);
|
||||
const floor = resolveWaivers([{ rule: 'invented-proof', reason: 'the ten thousand teams figure is the customer count the founder supplied', file: '/x/a.html', line: 2, raw: '' }], page, '/x', suspectedRules(measured.findings));
|
||||
const floor = resolveWaivers([{ rule: 'invented-proof', reason: 'the ten thousand teams figure is the customer count the founder supplied', file: '/x/a.html', line: 2 }], page, '/x', suspectedRules(measured.findings));
|
||||
expect(floor.refused.map((f) => f.evidence)).toEqual(['waiver on invented-proof refused: it blocks; the Floor is never waivable']);
|
||||
});
|
||||
|
||||
@@ -530,14 +530,12 @@ describe('design-lint: hardening (ship review)', () => {
|
||||
test("the lint's own meta rules cannot be waived", () => {
|
||||
const page = 'The manifesto sets long declarative lines on black paper for the ledger.';
|
||||
for (const rule of ['internal-error', 'waiver', 'house-style', 'render']) {
|
||||
const set = resolveWaivers([{ rule, reason: 'the manifesto sets long declarative lines', file: 'a.html', line: 1, raw: '' }], page);
|
||||
const set = resolveWaivers([{ rule, reason: 'the manifesto sets long declarative lines', file: 'a.html', line: 1 }], page);
|
||||
expect([...set.accepted.keys()]).toEqual([]);
|
||||
expect(set.refused.map((f) => f.evidence)).toEqual([`waiver on ${rule} refused: the lint's own rule, never waivable`]);
|
||||
}
|
||||
const unknown = resolveWaivers([{ rule: 'no-such-thing', reason: 'the manifesto sets long declarative lines', file: 'a.html', line: 1, raw: '' }], page);
|
||||
const unknown = resolveWaivers([{ rule: 'no-such-thing', reason: 'the manifesto sets long declarative lines', file: 'a.html', line: 1 }], page);
|
||||
expect(unknown.refused.map((f) => f.evidence)).toEqual(['waiver on no-such-thing refused: no such rule']);
|
||||
{
|
||||
}
|
||||
});
|
||||
|
||||
test('the tree stays shallow when optional end tags are left open', () => {
|
||||
@@ -576,9 +574,8 @@ describe('design-lint: hardening (ship review)', () => {
|
||||
test('deep nesting costs its depth, not its square', () => {
|
||||
const src = '<html><body>' + '<div>'.repeat(20000) + 'x' + '</div>'.repeat(20000) + '</body></html>';
|
||||
const doc = parseDoc('a.html', src);
|
||||
const before = process.memoryUsage().rss;
|
||||
const { parent } = treeOf(doc);
|
||||
expect(process.memoryUsage().rss - before).toBeLessThan(200e6);
|
||||
const { parent, close } = treeOf(doc);
|
||||
expect([parent.length, close.length]).toEqual([doc.tags.length, doc.tags.length]);
|
||||
expect(ancestorsOf(doc, 20001).length).toBe(512);
|
||||
expect(innerOf(doc, 20001)!.inner).toBe('');
|
||||
expect(innerOf(doc, 400)!.inner.length).toBeGreaterThan(1000);
|
||||
@@ -640,7 +637,7 @@ describe('design-lint: hardening (ship review)', () => {
|
||||
});
|
||||
|
||||
test('a refused polish waiver leaves the polish finding alone and adds one ask refusal, so the exit code rises to 1', () => {
|
||||
const set = resolveWaivers([{ rule: 'balanced-display-head', reason: 'intentional by design', file: 'a.html', line: 2, raw: '' }], 'the manifesto sets long lines');
|
||||
const set = resolveWaivers([{ rule: 'balanced-display-head', reason: 'intentional by design', file: 'a.html', line: 2 }], 'the manifesto sets long lines');
|
||||
const out = applyWaivers([{ rule: 'balanced-display-head', tier: 'polish', grade: 'polish', file: 'a.html', line: 9, evidence: 'e', fix: 'f', source: 'static' }], set);
|
||||
expect(out.map((f) => [f.rule, f.grade])).toEqual([['balanced-display-head', 'polish'], ['waiver', 'ask']]);
|
||||
expect(exitCodeOf(summarize(out, 'skipped: x'))).toBe(1);
|
||||
|
||||
@@ -75,7 +75,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
|
||||
'test/fixtures/design-count-native-8525.json': ['test/plan-count-completion.test.ts'],
|
||||
'test/fixtures/design-count-native-issue-fields.json': ['test/plan-count-completion.test.ts'],
|
||||
'test/fixtures/design-detector-source-public.json': ['test/design-detector-source-fixture.test.ts'],
|
||||
'test/fixtures/design-lint/**': ['test/design-lint.test.ts', 'test/design-lint-color.test.ts', 'test/design-lint-type.test.ts', 'test/design-lint-structure.test.ts', 'test/design-lint-motion.test.ts', 'test/design-lint-chrome.test.ts', 'test/design-lint-assets.test.ts', 'test/design-lint-finish.test.ts', 'test/design-lint-tailwind.test.ts', 'test/design-lint-render.test.ts'],
|
||||
'test/fixtures/design-lint/**': ['test/design-lint.test.ts', 'test/design-lint-color.test.ts', 'test/design-lint-type.test.ts', 'test/design-lint-structure.test.ts', 'test/design-lint-motion.test.ts', 'test/design-lint-chrome.test.ts', 'test/design-lint-assets.test.ts', 'test/design-lint-finish.test.ts', 'test/design-lint-tailwind.test.ts', 'test/design-lint-render.test.ts', 'test/design-lint-fourth-cycle.test.ts', 'test/design-lint-fifth-cycle.test.ts'],
|
||||
'test/fixtures/design-floor-focus-36597762183.json': ['test/plan-design-floor-fixture.test.ts'],
|
||||
'test/fixtures/design-floor-focus-review-focus.json': ['test/plan-design-floor-fixture.test.ts'],
|
||||
'test/fixtures/design-floor-focus-template-0d.json': ['test/plan-design-floor-fixture.test.ts'],
|
||||
|
||||
Reference in new issue
Block a user