/** * The design lint's render tier: every render rule id gets a page that passes * it (the clean fixture) and a page with the tell planted, measured through * whichever browser lib/aside-render picks (gstack's own browse daemon on the * free lane). Skipped with a named reason when neither browser is available; * the skipped and render-failure paths run everywhere. */ import { 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 { lint } from '../lib/design-lint/index'; import { lintRender, lineOf } from '../lib/design-lint/render'; import { installScript } from '../lib/design-lint/render-probe'; import { decodePng, medianRgb } from '../lib/design-lint/render-png'; import { formatFinding, type Finding } from '../lib/design-lint/report'; import { RULES } from '../lib/design-lint/rules/ids'; import { NO_BROWSER, buildRenderScript, pickEngine, renderWithBrowse, resolveBrowseBin, type EngineChoice } from '../lib/aside-render'; import { asideAvailable } from './helpers/aside-available'; import { browserAvailable, NO_BROWSER_REASON } from './helpers/browser-available'; const ROOT = path.resolve(import.meta.dir, '..'); const CLEAN = fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'design-lint', 'clean', 'index.html'), 'utf8'); const hasBrowser = browserAvailable(); if (!hasBrowser) process.stderr.write(`design-lint-render: SKIPPED — ${NO_BROWSER_REASON}\n`); const RENDER_RULES = Object.entries(RULES).filter(([, m]) => m.render).map(([id]) => id); /** The engine for these pages, chosen here rather than read from lib/aside-render's per-process cache, which other test files prime with fakes. */ const engine = (): EngineChoice => { if (asideAvailable()) return pickEngine(true); const bin = resolveBrowseBin(); return bin ? { engine: 'browse', bin } : { engine: null, probe: { ok: false, reason: 'NEEDS_ASIDE', detail: NO_BROWSER_REASON }, error: NO_BROWSER_REASON }; }; function tmpPage(html: string): { file: string; dir: string } { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-render-')); const file = path.join(dir, 'index.html'); fs.writeFileSync(file, html); return { file, dir }; } /** The clean fixture with extra CSS in its head and extra markup before . */ const pageWith = (css: string, body: string, swap: Array<[string, string]> = []): string => { let html = CLEAN.replace('', css + '\n').replace('', body + '\n'); for (const [from, to] of swap) { if (!html.includes(from)) throw new Error('swap target missing: ' + from); html = html.replace(from, to); } return html; }; const lineIn = (html: string, needle: string): number => { const i = html.indexOf(needle); if (i < 0) throw new Error('needle missing: ' + needle); return html.slice(0, i).split('\n').length; }; async function measure(html: string): Promise<{ html: string; findings: Finding[]; status: string; of: (rule: string) => Finding[]; lines: string }> { const { file, dir } = tmpPage(html); try { const r = await lintRender([file], { cwd: dir, engine }); return { html, findings: r.findings, status: r.status, of: (rule) => r.findings.filter((f) => f.rule === rule), lines: r.findings.map(formatFinding).join('\n') }; } finally { fs.rmSync(dir, { recursive: true, force: true }); } } const SVG_IMG = (w: number, h: number, fill: string) => `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='${w}' height='${h}'%3E%3Crect width='${w}' height='${h}' fill='%23${fill}'/%3E%3C/svg%3E`; const WORDS = (n: number) => Array.from({ length: n }, (_, i) => ['ledger', 'invoice', 'carrier', 'receipt', 'review', 'close', 'depot', 'month'][i % 8]).join(' '); describe('design-lint render: the pure parts', () => { test('lineOf finds the element by tag, class and opening text; falls back to the tag, then line 1', () => { const src = '
\n

First words here

\n

Second words here

\n\n
'; expect(lineOf(src, { tag: 'p', cls: 'lede', text: 'Second words here' })).toBe(3); expect(lineOf(src, { tag: 'p', cls: 'lede', text: 'no such text' })).toBe(2); expect(lineOf(src, { tag: 'button', cls: '', text: '' })).toBe(4); expect(lineOf(src, { tag: 'h1', cls: '', text: '' })).toBe(1); expect(lineOf(src + '\n
\n
', { tag: 'div', cls: '', text: '', attr: 'data-slot="b"' })).toBe(7); expect(lineOf(null, { tag: 'p', cls: '', text: '' })).toBe(1); }); test('the install script carries every probe and no module-scope reference', () => { const js = installScript(); for (const name of ['setup', 'layout', 'contrast', 'accent', 'focus', 'occlusion', 'space', 'quality', 'hide', 'restore']) expect(js).toContain(name + ': ('); expect(js).not.toMatch(/\bimport\b|\bexport\b|require\(/); }); test('decodePng reads an RGB PNG and medianRgb samples a rectangle, leaving out an inner one', () => { const png = decodePng(fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'design-lint', 'render', 'swatch.png')))!; expect([png.width, png.height, png.channels]).toEqual([8, 8, 3]); expect(medianRgb(png, { x: 0, y: 0, w: 4, h: 8 })!.map((v) => Math.round(v * 255))).toEqual([200, 30, 30]); expect(medianRgb(png, { x: 0, y: 0, w: 8, h: 8, inner: { l: 0, t: 0, r: 4, b: 8 } })!.map((v) => Math.round(v * 255))).toEqual([30, 30, 200]); expect(decodePng(Buffer.from('not a png'))).toBeNull(); }); test('no browser: findings are empty and the status names the reason', async () => { const r = await lintRender(['/nowhere/index.html'], { engine: () => ({ engine: null, probe: { ok: false, reason: 'NEEDS_ASIDE', detail: 'test' }, error: `${NO_BROWSER}: open Aside or run ./setup (NEEDS_ASIDE: test)` }) }); expect(r.findings).toEqual([]); expect(r.status).toBe(`skipped: ${NO_BROWSER}: open Aside or run ./setup (NEEDS_ASIDE: test)`); expect((await lintRender([])).status).toBe('skipped: no HTML page to render'); }); }); describe.skipIf(process.platform === 'win32')('design-lint render: the eval viewport and URL targets on both engines', () => { test('the Aside script sets the device metrics before a sized eval and clears them after', () => { const script = buildRenderScript('http://127.0.0.1:1/x.html', { file: '/x.html', steps: [{ kind: 'eval', expression: 'innerWidth', width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }, { kind: 'eval', expression: 'document.title' }] }); const set = script.indexOf('Emulation.setDeviceMetricsOverride", {"width":1440,"height":900,"deviceScaleFactor":1,"mobile":false}'); const run = script.indexOf('pg.evaluate((src) => (0, eval)(src), "innerWidth")'); const clear = script.indexOf('Emulation.clearDeviceMetricsOverride'); expect(set).toBeGreaterThan(-1); expect(set).toBeLessThan(run); expect(run).toBeLessThan(clear); expect(script.indexOf('"document.title"')).toBeGreaterThan(clear); expect(script.match(/setDeviceMetricsOverride/g)).toHaveLength(1); }); test('the browse path sets the viewport before a sized eval, restores the default after, and opens an http target without serving anything', async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-fake-browse-')); try { const log = path.join(tmp, 'argv.log'); const bin = path.join(tmp, 'browse'); fs.writeFileSync(bin, `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(log)}\ncase "$1" in newtab) echo '{"tabId":7}' ;; js) echo 42 ;; *) : ;; esac\n`, { mode: 0o755 }); const doc = path.join(tmp, 'doc.html'); fs.writeFileSync(doc, 'Doc'); const r = await renderWithBrowse({ file: doc, steps: [{ kind: 'eval', expression: 'innerWidth', width: 1440, height: 900 }, { kind: 'eval', expression: '1' }] }, bin); expect(r.error).toBeUndefined(); expect(r.evals).toEqual({ 0: '42', 1: '42' }); const lines = fs.readFileSync(log, 'utf8').split('\n').filter(Boolean).filter((l) => !/^js \(\(\) => \{ window\.__gstackErrs|^js JSON\.stringify\(window\.__gstackErrs/.test(l)); expect(lines.slice(0, 6)).toEqual(['newtab --json', expect.stringMatching(/^goto http:\/\/127\.0\.0\.1:\d+\/[0-9a-f]{32}\/doc\.html --tab-id 7$/), 'viewport 1440x900 --tab-id 7', 'js innerWidth --tab-id 7', 'viewport 1280x720 --tab-id 7', 'js 1 --tab-id 7']); fs.rmSync(log); const u = await renderWithBrowse({ file: 'http://example.test/page', steps: [{ kind: 'eval', expression: '1' }] }, bin); expect(u.error).toBeUndefined(); expect(fs.readFileSync(log, 'utf8')).toContain('goto http://example.test/page --tab-id 7'); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); }); describe.skipIf(!hasBrowser)('design-lint render: the browser tier', () => { test('the clean fixture passes every render rule; status is ran', async () => { const r = await measure(CLEAN); expect(r.status).toBe('ran'); expect(r.findings.filter((f) => RENDER_RULES.includes(f.rule) || f.rule === 'render').map(formatFinding)).toEqual([]); }, 60_000); test('a URL nobody answers and a file that is not there each report render; the status still says ran', async () => { const r = await lintRender(['http://127.0.0.1:9/', '/nowhere/missing.html'], { engine }); expect(r.status).toBe('ran'); const render = r.findings.filter((f) => f.rule === 'render'); expect(render).toHaveLength(2); expect(render[0].grade).toBe('blocks'); expect(render[0].evidence).toMatch(/^could not reach http:\/\/127\.0\.0\.1:9\//); expect(render[1].evidence).toMatch(/^the page did not render: HTML file not found/); }, 60_000); test('layout tells: horizontal scroll, a wrapping button and link, the headline past the fold, a stylesheet that did not load', async () => { const html = pageWith( '.hero { padding-top: 760px; } .narrow { width: 120px; } .wide { width: 2400px; height: 8px; background: var(--color-line); }', '
\n
Book the complete walkthrough today
\n', [['', '\n']], ); const r = await measure(html); expect(r.of('render').map((f) => [f.grade, f.evidence])).toEqual([['ask', 'fonts or stylesheets did not load (missing.css): the measurements show a fallback face']]); expect(r.findings.every((f) => f.source === 'render')).toBe(true); expect(r.of('horizontal-scroll').map((f) => [f.grade, f.line, f.evidence])).toEqual([['blocks', lineIn(html, 'class="wide"'), "[375px] div.wide runs 2057px past the 375px screen, hidden only by the page's clip"]]); const unclipped = await measure(html.replace('html, body { overflow-x: clip; }', '')); expect(unclipped.of('horizontal-scroll').map((f) => [f.grade, f.evidence])).toEqual([['blocks', '[320px] the page scrolls sideways: it is 2432px wide in a 320px viewport (also at 375, 414, 768, 1024, 1280, 1920px)']]); const wraps = r.of('button-label-wraps'); expect(wraps.map((f) => [f.grade, f.line])).toEqual([['blocks', lineIn(html, 'Book the complete walkthrough today')]]); expect(wraps[0].evidence).toMatch(/^\[320px\] control "Book the complete walkthrough to" wraps to \d lines \(a\.button\)$/); expect(r.of('link-wraps').map((f) => [f.grade, f.line])).toEqual([['ask', lineIn(html, 'Read how the close works for you')]]); expect(r.of('hero-fold').map((f) => [f.grade, f.line, f.evidence])).toEqual([['blocks', lineIn(html, '

'), expect.stringMatching(/^\[1280px\] the headline crosses the 1280x800 fold/)]]); }, 60_000); test('contrast and focus: low contrast confirmed against the pixels, button text on its fill, a weak field edge, a bare focus state and a faint ring; white text over a dark picture is not a finding', async () => { const html = pageWith( '.faint { color: #b8b8b8; } .flat { background: #777; color: #808080; border-color: #777; } .edge { border: 1px solid #f2f2f2; background: #fff; height: 44px; } .bare:focus-visible { outline: none; } .weak:focus-visible { outline: 3px solid #e4e4e4; outline-offset: 2px; } .over { position: relative; width: 600px; } .over p { position: absolute; left: 24px; top: 24px; color: #fff; margin: 0; }', '
\n

The faint paragraph nobody can read on the paper.

\nBuy the ledger\n\n\n\n
A dark depot at night

White on the night photograph

\n
', ); const r = await measure(html); const low = r.of('low-contrast'); expect(low.find((f) => f.line === lineIn(html, 'class="faint"'))?.evidence).toMatch(/^\[1280px\] contrast 1\.\d\d:1 against the pixels behind, under 4\.5:1 on p\.faint "The faint paragraph nobo"$/); expect(low.find((f) => /field's edge/.test(f.evidence))?.evidence).toMatch(/^\[1440px\] a field's edge is under 3:1 against what surrounds it: input\.edge 1\.\d\d:1$/); expect(low.some((f) => /White on the night/.test(f.evidence))).toBe(false); expect(r.of('gray-on-color').map((f) => [f.grade, f.line, f.evidence])).toEqual([['blocks', lineIn(html, 'class="button flat"'), expect.stringMatching(/^\[1280px\] button text within a step of its fill on a\.button\.flat \(1\.\d\d:1\)$/)]]); const focus = r.of('missing-focus-visible'); expect(focus.map((f) => f.grade)).toEqual(['blocks', 'blocks']); expect(focus.find((f) => /nothing shows/.test(f.evidence))).toMatchObject({ line: lineIn(html, 'class="button bare"'), evidence: '[1440px] nothing shows keyboard focus on button.button "Bare of focus"' }); expect(focus.find((f) => /focus ring/.test(f.evidence))).toMatchObject({ line: lineIn(html, 'class="button weak"'), evidence: expect.stringMatching(/^\[1440px\] focus ring 1\.\d\d:1 against what is behind it on button\.button "Weak ring"$/) }); }, 90_000); test('type and space tells at 1440: the word budget, a drawn SVG, an empty slot, counting marks, a tiny slot, a sliver, a lonely grid item, five weights, tiny text, long lines, crowded heads and a cramped box', async () => { const html = pageWith( '.marks { display: flex; gap: 4px; } .marks i { display: block; width: 8px; height: 8px; background: var(--color-ink); border-radius: 50%; } .tiles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } .tiles div { min-height: 120px; background: var(--color-line); } .sliver { width: 60px; } .w3 { font-weight: 300; } .w6 { font-weight: 600; } .w9 { font-weight: 900; } .tiny { font-size: 10px; } .long { max-width: none; font-size: 15px; } .crowd h3 { margin: 2px 0 48px; } .box { background: var(--color-line); padding: 1px; width: 320px; }', '
\n

Everything the close touches

\n

' + WORDS(120) + '

\n\n
\n\n
\n

A paragraph squeezed into a sliver of a column that runs for many lines.

\n
\n

Light weight runs across thirty characters.

Semibold weight runs across thirty characters.

Black weight runs across thirty characters here.

\n

Ten pixel text that strains the eye across a whole paragraph of running prose.

\n

' + WORDS(160) + '

\n

A paragraph above.

Crowded head one

The text it heads.

Another paragraph above.

Crowded head two

The text it heads.

\n
Text pressed against the edge of its filled box.
\n
', ); const r = await measure(html); const one = (rule: string) => { const f = r.of(rule); expect(f.length, rule + '\n' + r.lines).toBeGreaterThan(0); return f[0]; }; expect(one('screen-word-budget')).toMatchObject({ grade: 'ask', evidence: expect.stringMatching(/^\[1440px\] \d+ words on one 900px screen/) }); expect(one('hand-drawn-svg')).toMatchObject({ grade: 'ask', line: lineIn(html, ' [f.grade, f.line, f.evidence])).toEqual([ ['polish', lineIn(html, 'data-slot="mark"'), '[375px] slot "mark" renders at 20 x 20px on a phone'], ['polish', lineIn(html, 'data-slot="mark"'), '[1440px] slot "mark" renders at 20 x 20px'], ]); expect(one('text-sliver')).toMatchObject({ grade: 'polish', line: lineIn(html, 'class="sliver"'), evidence: expect.stringMatching(/^\[1440px\] "A paragraph squeezed into a " squeezed to 60px wide over \d+ lines \(p\.sliver\)$/) }); expect(one('lonely-grid-item')).toMatchObject({ grade: 'polish', line: lineIn(html, 'class="tiles"'), evidence: '[1440px] div.tiles leaves one item alone on its last row (3 + 1)' }); expect(one('font-weight-count')).toMatchObject({ grade: 'polish', evidence: '[1440px] 5 font weights in running use (300, 400, 600, 700, 900)' }); expect(one('tiny-text')).toMatchObject({ grade: 'ask', line: lineIn(html, 'class="tiny"'), evidence: '[1440px] text that strains the eye: p.tiny at 10px' }); expect(one('line-length')).toMatchObject({ grade: 'ask', line: lineIn(html, 'class="long"'), evidence: expect.stringMatching(/^\[1440px\] running prose set too wide: p\.long about \d{2,3} characters a line$/) }); expect(one('heading-rhythm')).toMatchObject({ grade: 'ask', line: lineIn(html, 'Crowded head one'), evidence: expect.stringMatching(/^\[1440px\] heads nearer what precedes them than what they head: h3 "Crowded head one" 24px over, 48px under; h3 "Crowded/) }); expect(one('cramped-padding')).toMatchObject({ grade: 'ask', line: lineIn(html, 'class="box"'), evidence: expect.stringMatching(/^\[1440px\] text crowds the edge of its box: div\.box left \dpx/) }); }, 90_000); test('the hero and its sections: a dead band, small section heads, thin sections, text under a cover, and the 1920 container', async () => { const html = pageWith( '.hero { align-items: start; justify-content: start; } .hero figure { display: none; } .wrap { max-width: none; padding: 0; } h2 { font-size: 24px; } p { max-width: none; } .cover { position: absolute; left: 0; width: 300px; height: 80px; margin-top: -80px; background: var(--color-ink); }', '

A thin section

A short body on a wide canvas with nothing else in it.

\n

This paragraph is painted under the cover.

' + WORDS(60) + '.

', [['
', '