mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 16:34:00 +01:00
design-lint: twelfth review cycle tests and docs
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
734e07f0a5
commit
f1dcdea65c
2 files changed
+37
-15
No files matched your search
+5
-1
@@ -49,7 +49,11 @@ gstack-design-lint <file-or-dir ...> [--render] [--url <http://…>] [--scope pa
|
||||
open heading is closed by the next heading start tag, an open `<p>` by the
|
||||
next block start, and a repeated `<li>`, `<button>`, `<a>`, `<label>`,
|
||||
`<select>`, `<form>` or table cell by its next start tag, as the browser
|
||||
does, so a page of unclosed elements costs its length to lint.
|
||||
does, so a page of unclosed elements costs its length to lint. A nested
|
||||
CSS selector is expanded against its parent (`&` replaced, a bare selector
|
||||
descendant-joined) up to 8192 characters, and a sheet may store at most
|
||||
four times its length plus 64 KB of expanded selectors; past either bound
|
||||
the nested selector stands as written, so a sheet costs its length too.
|
||||
- A page is linted with the stylesheets it links (`<link rel=stylesheet>`,
|
||||
`@import`, and anything they reach); a directory of components shares the
|
||||
project's tokens and global state styles, so a focus ring declared once in
|
||||
|
||||
@@ -78,7 +78,7 @@ describe('design-lint eighth cycle: a file costs its length', () => {
|
||||
test('a heading whose tail is thousands of bare < characters lints in linear time', () => {
|
||||
const dir = tree({ 'index.html': HEAD + '</head><body><h1>' + ' x <'.repeat(80_000) });
|
||||
expect(timed(() => lintStatic([dir], { cwd: dir, ...quiet }))).toBeLessThan(5000);
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('a page whose tail is a run of <1 or of unclosed comment starts costs its length, and a doctype survives', () => {
|
||||
const dir = tree({
|
||||
@@ -92,7 +92,7 @@ describe('design-lint eighth cycle: a file costs its length', () => {
|
||||
expect(doc.src.includes('<!--')).toBe(false);
|
||||
expect(doc.tags.map((t) => t.name)).toEqual(['p', 'p']);
|
||||
expect(parseHtml('x.html', '<p>Fees are <1% of volume and <b>nothing</b> else</p>').text.replace(/\s+/g, ' ').trim()).toBe('Fees are 1% of volume and nothing else');
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('repeated unclosed <script>, <nav>, <style> and <template> openers cost the copy tier their length', () => {
|
||||
for (const tag of ['<script>', '<nav>', '<style>', '<template>']) {
|
||||
@@ -104,7 +104,7 @@ describe('design-lint eighth cycle: a file costs its length', () => {
|
||||
const reporter = new Reporter('page', '/');
|
||||
runCopy({ docs: [doc] } as never, reporter);
|
||||
expect(reporter.findings.map((f) => f.rule)).toContain('missing-aria-current');
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('a template of thousands of unclosed component tags costs its length', () => {
|
||||
const dir = tree({ 'Card.vue': '<template>\n' + '<Foo class="p-4">x\n'.repeat(80_000) + '</template>' });
|
||||
@@ -188,20 +188,20 @@ describe('design-lint eighth cycle: a file costs its length', () => {
|
||||
expect(timed(() => lintStatic([spaced], { cwd: spaced, ...quiet }))).toBeLessThan(3000);
|
||||
const nav = tree({ 'Nav.tsx': "import { Check } from 'lucide-react';\nimport { StarIcon } from '@heroicons/react/24/solid';\nexport const Nav = () => <nav><Check /><StarIcon /></nav>;\n" });
|
||||
expect(only(lintStatic([nav], { cwd: nav, ...quiet }).findings, 'two-icon-libraries').map((f) => f.evidence)).toEqual(['icon libraries mixed on one page: heroicons and lucide']);
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('thousands of distinct families, narrow prose classes, nowrap rules, wrapping rules or em rules beside thousands of elements cost their length', () => {
|
||||
const fams = tree({ 'style.css': Array.from({ length: 40_000 }, (_, i) => `.f${i}{font-family:"Fam${i}"}`).join('\n') });
|
||||
expect(timed(() => lintStatic([fams], { cwd: fams, ...quiet })), 'families').toBeLessThan(5000);
|
||||
expect(timed(() => lintStatic([fams], { cwd: fams, ...quiet })), 'families').toBeLessThan(10_000);
|
||||
const prose = tree({ 'index.html': HEAD + '<link rel="stylesheet" href="style.css"></head><body><main>' + '<p class="x">hi</p>\n'.repeat(40_000) + '</main></body></html>', 'style.css': Array.from({ length: 20_000 }, (_, i) => `.copy${i}{max-width:30ch}`).join('\n') });
|
||||
expect(timed(() => lintStatic([prose], { cwd: prose, ...quiet })), 'prose').toBeLessThan(5000);
|
||||
expect(timed(() => lintStatic([prose], { cwd: prose, ...quiet })), 'prose').toBeLessThan(10_000);
|
||||
const nowrap = tree({ 'index.html': HEAD + '<link rel="stylesheet" href="style.css"></head><body><main>' + '<button class="btn">A long label that certainly wraps around</button>\n'.repeat(8_000) + '</main></body></html>', 'style.css': Array.from({ length: 20_000 }, (_, i) => `.nw${i}{white-space:nowrap}`).join('\n') });
|
||||
expect(timed(() => lintStatic([nowrap], { cwd: nowrap, ...quiet })), 'nowrap').toBeLessThan(5000);
|
||||
expect(timed(() => lintStatic([nowrap], { cwd: nowrap, ...quiet })), 'nowrap').toBeLessThan(10_000);
|
||||
const wrap = tree({ 'index.html': HEAD + '<link rel="stylesheet" href="style.css"></head><body>' + '<main><h1>Supercalifragilisticexpialidocious here</h1></main>\n'.repeat(8_000) + '</body></html>', 'style.css': Array.from({ length: 60_000 }, (_, i) => `.a${i}{color:red}`).join('\n') });
|
||||
expect(timed(() => lintStatic([wrap], { cwd: wrap, ...quiet })), 'wrap').toBeLessThan(5000);
|
||||
expect(timed(() => lintStatic([wrap], { cwd: wrap, ...quiet })), 'wrap').toBeLessThan(10_000);
|
||||
const em = tree({ 'index.html': HEAD + '<link rel="stylesheet" href="style.css"></head><body><main>' + '<h2>Hello <em>there</em> friend</h2>\n'.repeat(10_000) + '</main></body></html>', 'style.css': Array.from({ length: 10_000 }, (_, i) => `.n${i} em{font-style:normal}`).join('\n') });
|
||||
expect(timed(() => lintStatic([em], { cwd: em, ...quiet })), 'em').toBeLessThan(4000);
|
||||
});
|
||||
expect(timed(() => lintStatic([em], { cwd: em, ...quiet })), 'em').toBeLessThan(10_000);
|
||||
}, 60_000);
|
||||
|
||||
test('an unclosed JSX expression at the end of a deep element, or a malformed close tag under it, costs the file its length', () => {
|
||||
const src = 'export default function A(){ return (' + '<div>'.repeat(200) + '{ ' + 'x '.repeat(1_000_000) + ')';
|
||||
@@ -223,7 +223,7 @@ describe('design-lint eighth cycle: a file costs its length', () => {
|
||||
const spans = Array.from({ length: 4000 }, (_, i) => '<span class="c">w' + i + '</span> ').join('');
|
||||
const dir = tree({ 'index.html': HEAD + '</head><body><main><h1>' + spans + '</h1><p>Body.</p></main></body></html>' });
|
||||
expect(timed(() => lintStatic([dir], { cwd: dir, ...quiet })), 'spans').toBeLessThan(1500);
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('the render locator costs the page its length when a class value holds a >, and refuses a tag that is not one', () => {
|
||||
const src = parseHtml('x.html', '<html><body><div class="' + 'a '.repeat(150_000) + '>"><p>x</p></div></body></html>').src;
|
||||
@@ -233,14 +233,14 @@ describe('design-lint eighth cycle: a file costs its length', () => {
|
||||
expect(lineOf('<x>\n<p class="lede big">Hello</p>', { tag: 'p', cls: 'big', text: 'hello' })).toBe(2);
|
||||
expect(lineOf('<x>\n<p data-class="x" class="lede big">Hello</p>', { tag: 'p', cls: 'big', text: 'hello' })).toBe(2);
|
||||
expect(lineOf('<x>\n<p class="lede big">Hello</p>', { tag: 'p', cls: 'x'.repeat(401), text: 'hello' })).toBe(2);
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('thousands of ticker elements beside thousands of text elements cost their length', () => {
|
||||
const dir = tree({ 'index.html': HEAD + '</head><body>' + '<i class="ticker"></i><span>x</span>'.repeat(40_000) + '</body></html>' });
|
||||
expect(timed(() => lintStatic([dir], { cwd: dir, ...quiet }))).toBeLessThan(4000);
|
||||
const f = lintDir({ 'index.html': HEAD + '</head><body><div class="ticker"><p><span>One · two · three</span></p></div><p>Four · five · six</p></body></html>' }).findings;
|
||||
expect(only(f, 'template-punctuation').map((x) => x.evidence)).toEqual(['middle dots join a meta string: "Four · five · six"']);
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('a class attribute with 700k tokens does not overflow the stack', () => {
|
||||
const { warns } = lintDir({ 'index.html': HEAD + '</head><body><div class="' + 'a '.repeat(700_000) + '"><h1>Hi</h1></div></body></html>\n' });
|
||||
@@ -270,11 +270,29 @@ describe('design-lint eighth cycle: rule judgements', () => {
|
||||
|
||||
test('the probes read their viewport from the page, not a literal', () => {
|
||||
for (const fn of [glLayout, glAccent]) expect(fn.toString()).not.toMatch(/\b800\b|1280x800/);
|
||||
for (const fn of [glHeroSpace, glWide, glWords]) expect(fn.toString()).not.toMatch(/1440x900|\b900\b|1920x1080|at 1920/);
|
||||
for (const fn of [glHeroSpace, glWide, glWords]) expect(fn.toString()).not.toMatch(/1440x900|\b900(?:px)?\b|1920x1080|at 1920/);
|
||||
expect([...selectorNames([':is(a, button)']).tags].sort()).toEqual(['a', 'button']);
|
||||
expect([...selectorNames(['nav :where(h1, h2) em']).tags].sort()).toEqual(['em', 'h1', 'h2', 'nav']);
|
||||
});
|
||||
|
||||
test('thousands of nested children under a long parent cost the sheet its length, and a whitespace run does not defeat the stripTags limit', () => {
|
||||
const src = '.' + 'a'.repeat(8000) + '{' + '&{}'.repeat(200_000) + '}';
|
||||
let rules: ReturnType<typeof parseCss> = [];
|
||||
expect(timed(() => { rules = parseCss(src, 'x.css'); }), 'amp').toBeLessThan(3000);
|
||||
expect(rules.length).toBe(200_001);
|
||||
expect(rules.reduce((n, x) => n + x.selector.length, 0)).toBeLessThan(src.length * 8);
|
||||
expect(rules[1].selector.startsWith('.aaaa')).toBe(true);
|
||||
expect(rules[200_000].selector).toBe('&');
|
||||
const kids = Array.from({ length: 200_000 }, (_, k) => `.a${k}{}`).join('');
|
||||
const desc = parseCss('.' + 'a'.repeat(8000) + '{' + kids + '}', 'y.css');
|
||||
expect(desc.reduce((n, x) => n + x.selector.length, 0)).toBeLessThan((kids.length + 8002) * 8);
|
||||
expect(desc[200_000].selector).toBe('.a199999');
|
||||
const ws = ' '.repeat(1_000_000);
|
||||
expect(timed(() => { for (let k = 0; k < 500; k++) stripTags(ws, false, 4); }), 'spaces').toBeLessThan(500);
|
||||
expect(timed(() => { for (let k = 0; k < 500; k++) stripTags('\u00a0'.repeat(1_000_000), false, 4); }), 'nbsp').toBeLessThan(1500);
|
||||
expect(stripTags(' '.repeat(5000) + 'hello world', false, 4).trim()).toBe('');
|
||||
}, 60_000);
|
||||
|
||||
test('the emoji classes spell their variation selector and joiner as escapes', () => {
|
||||
expect(fs.readFileSync(path.join(import.meta.dir, '../lib/design-lint/rules/assets.ts'), 'utf8')).not.toMatch(/[\uFE0F\u200D]/);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user