From 5f9b7ef2090757dbe5dcd3a3d57eff1ee5f6ed6f Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 7 Nov 2026 11:56:12 -0800 Subject: [PATCH] Let the design lint check one section of a running app for crowded rows, cramped help text, loud notes and small tap targets (#4138) * feat(design-lint): --scope app measures one app section for row spacing, flush help copy, loud notes and small targets (GST-1741 PR 1) Adds an opt-in, render-only app profile to gstack-design-lint: its own scheduler (lib/design-lint/render-app.ts) and probes (lib/design-lint/render-probe-app.ts on window.__gstackLintApp), five app-only rules at ask (row-crowding, copy-flush-under-list, note-in-row-weight, small-hit-area, section-overflow), --within matched through open shadow roots, readiness and state assertions, a LINT_APP verdict with exit 0 clean / 1 open / 4 incomplete / 3 usage, --waivers, URL redaction everywhere and private trace files for probe errors. Behavior change: --url with no browser now exits 4 with RENDER_UNAVAILABLE under every scope. Page and component output is otherwise byte-identical to e58be61 (test/design-lint-scope-guard.test.ts). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(design-lint): app profile scope, label-target and select-in-row cases (GST-1741 PR 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-lint): app rows read laid-out unboxed lists, and subpixel noise never fires a threshold (GST-1741 PR 1) Measured against six real qm builds from the probe: the shipped access list (flex rows, no border) slipped past row-crowding, and 8px padding read as 7.99px and fired. Rows that set an avatar, text and control side by side now count as a list of rows; thresholds ignore the last 0.05px. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (GST-1741 PR 1) Redact secret-looking #fragment parameters like query ones; keep two --url states whose redacted names match as two states (name~2) and refuse only the same URL twice; a trace that cannot be written keeps the state PROBE_ERROR instead of crashing to exit 1; a section selector the browser refuses no longer holds the soft readiness wait, and a NOT_READY without --wait-for names the section. Review-Finding: p1/specialist-batched#1 reader=security Review-Finding: p1/specialist-batched#2 reader=testing Review-Finding: p1/red-team#1 reader=red-team Review-Finding: p1/red-team#2 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: a generated state name never collides with a literal one (GST-1741 PR 1) Names for --url states are given out from the set of names already used, so /p, /p#x and /p~2 measure as three states. Review-loop exception a-7c954c6c661f. Review-Finding: p2/outside-voice#1 reader=outside-voice Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: design-lint usage and project map name --scope app (GST-1741 PR 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Describe the app section check in everyday words in the changelog Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-design-lint | 35 +- changelog.d/gst1741-app-lint.md | 5 + docs/PROJECT_STRUCTURE.md | 2 +- docs/design-lint.md | 161 ++++++- lib/design-lint/app-cli.ts | 80 ++++ lib/design-lint/app-input.ts | 193 +++++++++ lib/design-lint/render-app.ts | 260 ++++++++++++ lib/design-lint/render-probe-app.ts | 397 ++++++++++++++++++ lib/design-lint/report.ts | 15 +- lib/design-lint/rules/ids.ts | 8 + test/design-lint-app-contract.test.ts | 386 +++++++++++++++++ test/design-lint-app-profile.test.ts | 247 +++++++++++ test/design-lint-scope-guard.test.ts | 40 ++ test/design-lint.test.ts | 3 +- test/fixtures/module-size-ratchet.json | 4 + test/fixtures/ui-spacing/async.html | 16 + test/fixtures/ui-spacing/corpus.json | 95 +++++ test/fixtures/ui-spacing/fixed.html | 54 +++ test/fixtures/ui-spacing/larger-note.html | 37 ++ test/fixtures/ui-spacing/mobile-note.html | 36 ++ test/fixtures/ui-spacing/negatives.html | 70 +++ test/fixtures/ui-spacing/nested-shadow.html | 22 + test/fixtures/ui-spacing/never-ready.html | 14 + test/fixtures/ui-spacing/one-row.html | 36 ++ test/fixtures/ui-spacing/reader.html | 39 ++ test/fixtures/ui-spacing/replica.css | 19 + test/fixtures/ui-spacing/replica.html | 35 ++ .../ui-spacing/scope-guard-golden.json | 131 ++++++ test/fixtures/ui-spacing/shadow-closed.html | 19 + test/fixtures/ui-spacing/shadow-open.html | 19 + test/fixtures/ui-spacing/shadow-slotted.html | 21 + test/fixtures/ui-spacing/sharing.js | 2 + test/fixtures/ui-spacing/structural.html | 51 +++ test/helpers/free-fixtures-data.ts | 3 +- 34 files changed, 2542 insertions(+), 13 deletions(-) create mode 100644 changelog.d/gst1741-app-lint.md create mode 100644 lib/design-lint/app-cli.ts create mode 100644 lib/design-lint/app-input.ts create mode 100644 lib/design-lint/render-app.ts create mode 100644 lib/design-lint/render-probe-app.ts create mode 100644 test/design-lint-app-contract.test.ts create mode 100644 test/design-lint-app-profile.test.ts create mode 100644 test/design-lint-scope-guard.test.ts create mode 100644 test/fixtures/ui-spacing/async.html create mode 100644 test/fixtures/ui-spacing/corpus.json create mode 100644 test/fixtures/ui-spacing/fixed.html create mode 100644 test/fixtures/ui-spacing/larger-note.html create mode 100644 test/fixtures/ui-spacing/mobile-note.html create mode 100644 test/fixtures/ui-spacing/negatives.html create mode 100644 test/fixtures/ui-spacing/nested-shadow.html create mode 100644 test/fixtures/ui-spacing/never-ready.html create mode 100644 test/fixtures/ui-spacing/one-row.html create mode 100644 test/fixtures/ui-spacing/reader.html create mode 100644 test/fixtures/ui-spacing/replica.css create mode 100644 test/fixtures/ui-spacing/replica.html create mode 100644 test/fixtures/ui-spacing/scope-guard-golden.json create mode 100644 test/fixtures/ui-spacing/shadow-closed.html create mode 100644 test/fixtures/ui-spacing/shadow-open.html create mode 100644 test/fixtures/ui-spacing/shadow-slotted.html create mode 100644 test/fixtures/ui-spacing/sharing.js create mode 100644 test/fixtures/ui-spacing/structural.html diff --git a/bin/gstack-design-lint b/bin/gstack-design-lint index 3ff9d5624..351df1096 100755 --- a/bin/gstack-design-lint +++ b/bin/gstack-design-lint @@ -2,23 +2,32 @@ // gstack-design-lint — the free, deterministic design lint behind /design-html // and /design-review. Static tier over .html .css .jsx .tsx .vue .svelte .astro // (Tailwind class strings included); --render measures the page in gstack's own -// browser (Aside first, the browse daemon otherwise). +// browser (Aside first, the browse daemon otherwise). --scope app is the +// render-only app profile: five spacing rules inside one --within section. // // Usage: gstack-design-lint [--render] [--url ] // [--scope page|component] [--job ] [--json] [--rules] +// gstack-design-lint --scope app (--url ... | --state-file ) --within +// [--viewport WxH ...] [--wait-for ] [--waivers ] [--json] [--verbose] // // Prints one "LINT: : | fix: " // line per finding, then "LINT_SUMMARY: blocks=N ask=N waived=N polish=N render=". -// Exit 2 on blocks, 1 on unanswered asks, 0 otherwise; 3 on a usage error. -// docs/design-lint.md is the contract. +// Exit 2 on blocks, 1 on unanswered asks, 0 otherwise; 3 on a usage error; 4 when +// --url was given and no browser measured it. Under --scope app: 0 clean, 1 open, +// 4 incomplete, 3 usage, and a LINT_APP verdict line. docs/design-lint.md is the contract. import { formatResult, lint, type LintOptions } from '../lib/design-lint/index'; +import { appMain, renderUnavailable, type AppCliArgs } from '../lib/design-lint/app-cli'; import { ruleTable } from '../lib/design-lint/report'; -const USAGE = 'usage: gstack-design-lint [--render] [--url ] [--scope page|component] [--job ] [--json] [--rules]'; +const USAGE = 'usage: gstack-design-lint [--render] [--url ] [--scope page|component] [--job ] [--json] [--rules]\n' + + ' gstack-design-lint --scope app (--url ... | --state-file ) --within [--viewport WxH ...] [--wait-for ] [--waivers ] [--json] [--verbose]'; const args = process.argv.slice(2); const opts: LintOptions & { json: boolean; rules: boolean } = { render: false, urls: [], scope: 'page', job: null, json: false, rules: false }; +const app: AppCliArgs = { paths: [], urls: [], viewports: [], verbose: false, json: false }; +let appScope = false; +const appFlags: string[] = []; const paths: string[] = []; const take = (flag: string): string => { const v = args[++i]; @@ -30,10 +39,16 @@ for (; i < args.length; i++) { const a = args[i]; if (a === '--render') opts.render = true; else if (a === '--url') { opts.urls!.push(take(a)); opts.render = true; } - else if (a === '--scope') { const s = take(a); if (s !== 'page' && s !== 'component') { console.error('--scope must be page or component'); process.exit(3); } opts.scope = s; } + else if (a === '--scope') { const s = take(a); if (s === 'app') appScope = true; else if (s !== 'page' && s !== 'component') { console.error('--scope must be page, component or app'); process.exit(3); } else opts.scope = s; } else if (a === '--job') opts.job = take(a).trim().toLowerCase(); else if (a === '--json') opts.json = true; else if (a === '--rules') opts.rules = true; + else if (a === '--verbose') app.verbose = true; + else if (a === '--within') { appFlags.push(a); app.within = take(a); } + else if (a === '--viewport') { appFlags.push(a); app.viewports.push(take(a)); } + else if (a === '--wait-for') { appFlags.push(a); app.waitFor = take(a); } + else if (a === '--waivers') { appFlags.push(a); app.waivers = take(a); } + else if (a === '--state-file') { appFlags.push(a); app.stateFile = take(a); } else if (a === '--help' || a === '-h') { console.log(USAGE); process.exit(0); } else if (a.startsWith('--')) { console.error(`unknown flag ${a}`); console.error(USAGE); process.exit(3); } else paths.push(a); @@ -42,6 +57,14 @@ if (opts.rules) { for (const r of ruleTable()) console.log(`${r.tier.padEnd(6)} ${r.id.padEnd(26)} ${r.gate.padEnd(7)} ${r.title}`); process.exit(0); } +if (appScope) { + const code = await appMain({ ...app, paths, urls: opts.urls!, json: opts.json }, { out: (s) => console.log(s), err: (s) => console.error(s) }); + process.exit(code); +} +if (appFlags.length) { + console.error(`gstack-design-lint: APP_ONLY: ${appFlags[0]} only with --scope app | fix: add --scope app, or drop ${appFlags[0]}`); + process.exit(3); +} if (!paths.length && !opts.urls!.length) { console.error(USAGE); process.exit(3); } if (opts.urls!.some((u) => !/^https?:\/\//.test(u))) { console.error('--url takes an http or https address'); process.exit(3); } @@ -52,4 +75,6 @@ if (!result.files.length && !opts.urls!.length) { } if (opts.json) console.log(JSON.stringify({ summary: result.summary, files: result.files.length, findings: result.findings }, null, 2)); else console.log(formatResult(result)); +const missing = renderUnavailable(opts.urls!, result.summary.render); +if (missing) { console.error(missing); process.exit(4); } process.exit(result.exitCode); diff --git a/changelog.d/gst1741-app-lint.md b/changelog.d/gst1741-app-lint.md new file mode 100644 index 000000000..9890a47bb --- /dev/null +++ b/changelog.d/gst1741-app-lint.md @@ -0,0 +1,5 @@ +--- +bump: minor +--- +- **The design lint can now check one section of a running app screen for crowded rows, help text jammed under a list, notes as loud as names, and small tap targets.** It is render-only and opt-in: `--url ` (one state each) or `--state-file `, a required `--within ` matched through open shadow roots, `--viewport WxH` (1440x900 and 375x812 by default), `--wait-for`, `--waivers ` and `--verbose`. Five new rules at ask report only under `--scope app`: `row-crowding`, `copy-flush-under-list`, `note-in-row-weight`, `small-hit-area` and `section-overflow`, each finding with its state, viewport, a shadow-piercing locator, the measured px and the threshold. On gstack's replica of the qm#3186 Sharing panel it reports the four defect rules; on the fixed copy, the negative controls and a 12-page false-positive corpus it reports none. A `LINT_APP: verdict= code=…` line follows `LINT_SUMMARY`, and the exit is 0 clean, 1 open, 4 incomplete (no browser, unreachable, timeout, not ready, state mismatch, no match, closed shadow root, probe error, missing stylesheet), 3 usage. URLs are redacted in every output and probe failures write a private trace file. See docs/design-lint.md "App profile (--scope app)". +- **Behavior change for every caller: `gstack-design-lint --url` with no browser now exits 4.** It prints `gstack-design-lint: RENDER_UNAVAILABLE: nothing was measured at () | fix: gstack-browser-ensure, then rerun` on stderr instead of passing with exit 0. `--render` over files without a browser is unchanged. `--within`, `--viewport`, `--wait-for`, `--waivers` and `--state-file` without `--scope app` exit 3, and the usage text gains the app line. Page and component output and exit codes are otherwise byte-identical (test/design-lint-scope-guard.test.ts). diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index f9592f4b5..ca59c24fc 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -75,7 +75,7 @@ gstack/ ├── freeze/ # /freeze skill; bin/check-freeze.sh (PreToolUse edit-boundary hook; sources careful/bin/hook-extract.sh, fails closed) ├── guard/, unfreeze/ # /guard (careful + freeze in one), /unfreeze ├── gstack-upgrade/ # /gstack-upgrade skill + migrations/ (run after ./setup during an upgrade) -├── bin/ # CLI utilities (gstack-render.ts = render a local HTML file through Aside or the engine, gstack-design-detect.ts = probe/scan through a user-installed impeccable engine; gstack-design-md.ts = open DESIGN.md check/convert/tokens/mark; gstack-design-lint = the free deterministic design lint over HTML/CSS/components, --render measures in the browser (docs/design-lint.md); gstack-repo-mode, gstack-slug, gstack-config, gstack-wtree, gstack-evidence, gstack-issue-guard, gstack-relink, gstack-memorable, etc.) +├── bin/ # CLI utilities (gstack-render.ts = render a local HTML file through Aside or the engine, gstack-design-detect.ts = probe/scan through a user-installed impeccable engine; gstack-design-md.ts = open DESIGN.md check/convert/tokens/mark; gstack-design-lint = the free deterministic design lint over HTML/CSS/components, --render measures in the browser, --scope app measures one app section's spacing (docs/design-lint.md); gstack-repo-mode, gstack-slug, gstack-config, gstack-wtree, gstack-evidence, gstack-issue-guard, gstack-relink, gstack-memorable, etc.) ├── document-release/ # /document-release skill (every-ship pre-verification audit; standalone doc updates + Diataxis coverage map) ├── document-generate/ # /document-generate skill (Diataxis doc generator: tutorial/how-to/reference/explanation) ├── cso/ # /cso skill (OWASP Top 10 + STRIDE security audit) diff --git a/docs/design-lint.md b/docs/design-lint.md index ead69e1d0..7c76dbb9e 100644 --- a/docs/design-lint.md +++ b/docs/design-lint.md @@ -21,8 +21,12 @@ every finalized page and /design-review on each audited page. ``` gstack-design-lint [--render] [--url ] [--scope page|component] [--job ] [--json] [--rules] +gstack-design-lint --scope app (--url ... | --state-file ) --within [--viewport WxH ...] [--wait-for ] [--waivers ] [--json] [--verbose] ``` +The second form is the render-only app profile; see +[App profile](#app-profile---scope-app). + - Reads `.html`, `.css`, `.jsx`, `.tsx`, `.vue`, `.svelte` and `.astro`. Tailwind class strings in components are read too, including `cn()`, `clsx()`, `cva()` and their kin. `node_modules`, dot folders and build @@ -63,7 +67,11 @@ gstack-design-lint [--render] [--url ] [--scope pa `globals.css` is not reported missing on every component. - `--render` measures every HTML page found; `--url` measures a running dev server (and implies `--render`). When no browser is available the render - tier is skipped and the summary says why. + tier is skipped and the summary says why. With `--url`, under any scope, + a skipped render also prints `gstack-design-lint: RENDER_UNAVAILABLE: + nothing was measured at () | fix: gstack-browser-ensure, then + rerun` on stderr and exits **4**: a URL nobody measured is never a pass. + `--render` over files without a browser keeps the skip and its exit code. - `--scope component` skips the page-only rules (the nav, the footer, the eyebrow, section gaps, the wireframe count) when linting a component alone. - `--job ` names what the visitor came to do (`book`, `buy`, @@ -87,8 +95,10 @@ separator (it is printed as ` / fix: `), so splitting a line on its first `--json`. Exit **2** when anything blocks, **1** when an ask is unanswered, **0** -otherwise (**3** on a usage error). Findings sort blocks, ask, waived, polish, -then by rule, file and line. +otherwise (**3** on a usage error, **4** when `--url` was given and no browser +measured it). Findings sort blocks, ask, waived, polish, then by rule, file +and line. `--scope app` has its own verdict and exit codes: see +[App profile](#app-profile---scope-app). ## Tiers @@ -300,6 +310,151 @@ than three waivers, polish), `render` (a page that did not render, blocks: an unrendered page is never a pass) and `internal-error` (a page or stylesheet the lint could not read, or a rule family that threw on a page, ask: the page is not a pass until the lint has read it). +## App profile (--scope app) + +``` +gstack-design-lint --scope app (--url ... | --state-file ) --within + [--viewport WxH ...] [--wait-for ] [--waivers ] [--json] [--verbose] +``` + +The page and component profiles grade a whole page against hallmark's gates. +The app profile measures one section of an app screen while it is being +built, for the spacing defects qm#3186 shipped (two-line rows crowding the next +row, help copy flush under a list, a status note at full weight inside a row) +and small hit areas. It is render-only (no static tier, and paths are a usage +error), reports only its five rules, and the page and component profiles never +report them. Its scheduler (`lib/design-lint/render-app.ts`) and probes +(`lib/design-lint/render-probe-app.ts`, installed on +`window.__gstackLintApp`) are separate from the page tier's, which is +unchanged. + +- **States.** Each `--url` is one state, named by its path and query + (redacted); a later URL whose name is taken gets `~2`, `~3` after it, and + the same URL given twice exits 3 (`DUPLICATE_STATE`). + `--state-file` names them instead: + `{"version":1,"states":[{"name":"owner","url":"http://127.0.0.1:5300/p?as=owner","expect":"","within":""}]}`. + `name`, `expect` and `within` are optional; an unknown key, a missing or + non-http `url`, a bad selector or a repeated name exits 3. A URL off + loopback is allowed and noted (`LINT_APP_NOTE:`). `expect` proves the state + rendered: it must match in the composed tree after readiness, or the state + is incomplete with `STATE_MISMATCH`. +- **`--within`** (required) is matched through the light DOM and every open + shadow root, slotted content included; a state's own `within` overrides it. + A malformed selector exits 3 before any page opens. A selector that matches + nothing is `WITHIN_NO_MATCH`, naming the trees searched. +- **Readiness.** The page is opened and the engine waits up to 15 s for the + `--wait-for` selector, else the `--within` selector, through open shadow + roots. Then the probe polls up to 5 s until the section holds a list of two + or more rows; an explicit `--wait-for` skips that list wait (use it for a + section with no list). A section that never gets its list is `NOT_READY`; an + explicit `--wait-for` that never matches is `NOT_READY` too. +- **Viewports.** 1440x900 and 375x812 by default; `--viewport WxH` (repeatable) + replaces them. Width 320 to 3840, height 480 to 2400, `^\d{3,4}x\d{3,4}$`; + anything else exits 3. One page load per state, one measurement per + viewport. +- `--within`, `--viewport`, `--wait-for`, `--waivers` and `--state-file` + without `--scope app` exit 3 (`APP_ONLY: only with --scope app`). + `--verbose` is accepted under every scope; under `--scope app` it prints a + failed probe's stack. + +### What it measures + +The thresholds are fixed in this release; DESIGN.md does not override them. +Every rule is at `ask`, but the tier does not decide the verdict: any app +finding a waiver did not answer makes the section open. + +| Rule | Tier | The tell (measured px, threshold) | The fix | +|---|---|---|---| +| `row-crowding` | ask | in a list of rows, a row whose text spans two or more lines sits under 8px from a drawn row edge (its own border or surface, the neighbouring row's separator, or the list's edge), or under 16px from the next row's text | 8px or more inside the row, 16px or more between rows' text | +| `copy-flush-under-list` | ask | the first text block after a list (later siblings of the list container inside the section, before the next heading) starts under 12px below it | 12px or more under the list | +| `note-in-row-weight` | ask | in a list of rows, a note with no icon and no surface of its own, OKLCH lightness within 0.15 of the name's and size 0.9 or more of it; or, at 1200px and wider, a note of three lines or more | mute the note, or give it an icon or a callout | +| `small-hit-area` | ask | at 480px and narrower, a control inside a list row under 32px in either dimension (an input's label counts toward its box; a link inside a run of text is exempt) | a 32px target that keeps the glyph its size | +| `section-overflow` | ask | an element inside the section whose right edge passes the viewport (outside any scroller), or a non-scrolling element whose content is wider than its box | wrap or shrink it, or give it a scroller | + +How it reads the page, with no test hooks (no `data-testid`, no attribute at +all): a **list** is two or more adjacent siblings of one tag and class list, +widths within 10%, stacked (each top at or below the previous bottom less +1px), each holding visible text; tables, anything inside `nav` or a +navigation, grid, table, menu, listbox, tablist or tree role, headings, +paragraphs, sections and forms are never rows, and siblings side by side are a +grid, not a list. A list is a **list of rows** when it or a row draws a +border, background or shadow, or when every row is a flex or grid box that sets +two or more boxes side by side (an avatar, the text, a control); +`row-crowding` and `note-in-row-weight` read lists of rows only (an unboxed +bullet list is prose). With no drawn edge, `row-crowding` measures only the +gap to the next row's text. Measurements within 0.05px of a threshold count as +meeting it, so layout's subpixel rounding never fires a rule. A row's **text runs** are its +visible text nodes in rendered order through the composed tree, leaving out +text inside controls; positions are line boxes, so a gap is the padding and +margins the CSS set. The **name** is the run on the row's first line (the +leftmost when two share it). A **note** is a text block whose first line starts +below the name's line; a role label beside the name is never a note. +**Controls** are `button`, `a[href]`, `input`, `select` and the button, +menuitem, switch and tab roles. + +### Verdict, output and exit + +Each finding is a `LINT:` line whose evidence reads +`[ px] at `; the locator pierces +shadow roots (`qm-sharing >>> section.panel > div.rows:nth-of-type(1) > div.row:nth-of-type(2) "Registry token Pushes…"`). +Then one `LINT_APP_STATE:` line per state and viewport, `LINT_SUMMARY`, and +the verdict: + +``` +LINT_APP: verdict= code= states=/ viewports= within= matched= open= waived= +``` + +`states` counts states measured at every viewport; `matched` is the fewest +elements `--within` matched at any state and viewport. Exit **0** clean (every +planned state and viewport measured, nothing open), **1** open (complete +coverage, open findings), **4** incomplete (any state or viewport not +measured: incomplete wins over open, and partial coverage never prints clean), +**3** usage only. `--json` prints `{ summary, files, findings, app }`, where +`app` is `{ verdict, code, codes, within, matched, viewports, states: [{ name, +url, viewport, status, code, matched, detail?, trace? }], open, waived, notes }` +and each app finding carries `app: { state, viewport, locator, measured, +threshold }`, never truncated. Findings that differ only in their row or +viewport stay separate. + +When several codes apply, `code` is the first in this order and `codes` lists +them all: + +| Code | Means | Fix | +|---|---|---| +| `NO_BROWSER` | no browser could open the page (also `RENDER_UNAVAILABLE` on stderr) | gstack-browser-ensure, then rerun | +| `UNREACHABLE` | the URL did not answer 2xx, or the page did not render | start the server or fix the URL, then rerun | +| `TIMEOUT` | the render ran past its 120 s budget | make the page load faster or measure fewer viewports, then rerun | +| `NOT_READY` | the section never held a list, or `--wait-for` never matched | pass `--wait-for` with a selector that appears once the data is in, or make the data load | +| `STATE_MISMATCH` | the state's `expect` selector matched nothing | make the URL load the state it names, or fix `expect` | +| `WITHIN_NO_MATCH` | `--within` matched nothing in the document or any open shadow root | pass the custom element's tag or a selector inside the light DOM | +| `CLOSED_SHADOW` | a custom element in the section (or, when nothing matched, on the page) renders a closed shadow root: no shadow root, no light DOM, 40px or taller | attach the shadow root with mode "open" in development, or pass a `--within` outside it | +| `PROBE_ERROR` | a probe threw or answered in a shape the lint does not know | read the trace file; a page script that redefines DOM APIs breaks the probes | +| `ASSET_MISSING` | a linked stylesheet did not load (a font that did not load is only a `LINT_APP_NOTE`) | fix the stylesheet path or wait for the server, then rerun | + +Each `LINT_APP_STATE` line for an incomplete state names its code, the +detail, the fix and its anchor here. Nothing is retried: fix the named cause +and rerun. A probe that throws writes `{probe, state, viewport, stack, url}` +to a private file (mode 0600, in a 0700 `gstack-design-lint-traces` +directory under the system temp directory) and names it on stdout and +stderr; `--verbose` also prints the stack. When the trace cannot be +written, the state is still `PROBE_ERROR` and stderr says why. + +### Waivers and redaction + +URL targets have no source comment to carry a waiver, so `--waivers ` +does: `{"version":1,"waivers":[{"rule":"small-hit-area","locator":"","reason":""}]}`. The rule must +be an app rule and the reason 8 characters or more; an unknown rule or key +exits 3. A finding whose rule matches and whose locator contains the waiver's +`locator` prints at `waived` with its reason; a waiver that matched nothing +prints as a `waiver` finding (unused), which does not change the verdict. + +Every URL a person sees (text, JSON, stderr, traces, state names) has its +userinfo dropped and the value of any query or `#fragment` parameter whose +name contains token, key, secret, auth, session, sig, signature, password, +pass, code, jwt or bearer replaced with `REDACTED`; the page is opened at the +real URL. + ## Token names the rules read Several rules key on hallmark's token names, which are also the names diff --git a/lib/design-lint/app-cli.ts b/lib/design-lint/app-cli.ts new file mode 100644 index 000000000..f8d5303a5 --- /dev/null +++ b/lib/design-lint/app-cli.ts @@ -0,0 +1,80 @@ +/** + * The --scope app half of bin/gstack-design-lint: validates every input before + * a browser starts (exit 3 with a code and a fix line), runs the app + * scheduler and prints its contract. Kept apart from the bin so the contract + * tests can inject a runner instead of a browser. + */ +import * as fs from 'node:fs'; +import { AppUsageError, checkSelector, parseStateFile, parseViewport, parseWaiverFile, redactUrl, stateNameOf, type Viewport } from './app-input'; +import { formatAppLines, lintApp, type AppOptions, type AppState } from './render-app'; +import { formatFinding, formatSummary } from './report'; +import { ruleMeta } from './rules/ids'; + +export interface AppCliArgs { + paths: string[]; + urls: string[]; + within?: string; + viewports: string[]; + waitFor?: string; + waivers?: string; + stateFile?: string; + verbose: boolean; + json: boolean; +} + +export interface Io { out: (s: string) => void; err: (s: string) => void } + +export type AppDeps = Pick; + +export const usageLine = (e: AppUsageError): string => `gstack-design-lint: ${e.code}: ${e.message} | fix: ${e.fix}`; + +/** The stderr line for a run that was asked to measure URLs and measured nothing because no browser was available; null otherwise. */ +export function renderUnavailable(urls: string[], status: string): string | null { + if (!urls.length || !/^skipped: no browser available/.test(status)) return null; + return `gstack-design-lint: RENDER_UNAVAILABLE: nothing was measured at ${urls.map(redactUrl).join(', ')} (${status.slice('skipped: '.length)}) | fix: gstack-browser-ensure, then rerun`; +} + +const read = (flag: string, file: string, code: string): string => { + try { return fs.readFileSync(file, 'utf8'); } catch (e) { throw new AppUsageError(code, `${flag} ${file} could not be read: ${(e as Error).message}`, `pass a readable JSON file to ${flag}`); } +}; + +/** Every input settled before work: states, within, viewports, wait-for and waivers. */ +export function planApp(a: AppCliArgs): { states: AppState[]; notes: string[]; within: string; viewports: Viewport[]; waivers: AppOptions['waivers'] } { + if (a.paths.length) throw new AppUsageError('APP_PATHS', `--scope app measures URLs, not files (got ${a.paths[0]})`, 'pass --url or --state-file , or lint files without --scope app'); + if (!a.within) throw new AppUsageError('NO_WITHIN', '--scope app needs --within ', 'pass --within with the selector of the section you are building'); + checkSelector('--within', a.within); + if (a.waitFor !== undefined) checkSelector('--wait-for', a.waitFor); + if (a.urls.length && a.stateFile) throw new AppUsageError('BOTH_TARGETS', '--url and --state-file are two ways to name the states; pass one', 'put every URL in the state file, or drop it'); + if (!a.urls.length && !a.stateFile) throw new AppUsageError('NO_TARGET', '--scope app needs --url or --state-file ', 'pass the URL of each state to measure'); + const bad = a.urls.find((u) => !/^https?:\/\/[^/]/.test(u)); + if (bad) throw new AppUsageError('BAD_URL', `--url ${redactUrl(bad)} is not an http or https address`, 'pass the dev server URL, like http://127.0.0.1:5300/p'); + const viewports = a.viewports.map(parseViewport); + const twice = a.urls.find((u, i) => a.urls.indexOf(u) !== i); + if (twice) throw new AppUsageError('DUPLICATE_STATE', `--url ${redactUrl(twice)} is given twice`, 'measure each URL once'); + const used = new Set(); + const parsed = a.stateFile ? parseStateFile(read('--state-file', a.stateFile, 'BAD_STATE_FILE')) : { + states: a.urls.map((url) => { const base = stateNameOf(url); let name = base; for (let n = 2; used.has(name); n++) name = `${base}~${n}`; used.add(name); return { name, url }; }), + notes: [], + }; + const waivers = a.waivers ? parseWaiverFile(read('--waivers', a.waivers, 'BAD_WAIVERS'), (id) => !!ruleMeta(id)?.app) : []; + return { states: parsed.states, notes: parsed.notes, within: a.within, viewports, waivers }; +} + +export async function appMain(a: AppCliArgs, io: Io, deps: AppDeps = {}): Promise { + let plan: ReturnType; + try { plan = planApp(a); } catch (e) { + if (!(e instanceof AppUsageError)) throw e; + io.err(usageLine(e)); + return 3; + } + const r = await lintApp({ states: plan.states, within: plan.within, viewports: plan.viewports, waitFor: a.waitFor, waivers: plan.waivers, ...deps }); + r.app.notes.unshift(...plan.notes); + for (const line of r.stderr) io.err(line); + if (a.verbose) for (const c of r.app.states) if (c.trace) { try { io.err(`gstack-design-lint: trace ${c.trace}:\n${fs.readFileSync(c.trace, 'utf8')}`); } catch (e) { io.err(`gstack-design-lint: trace ${c.trace} could not be read: ${(e as Error).message}`); } } + const missing = renderUnavailable(plan.states.map((s) => s.url), r.summary.render); + if (missing) io.err(missing); + if (a.json) { io.out(JSON.stringify({ summary: r.summary, files: 0, findings: r.findings, app: r.app }, null, 2)); return r.exitCode; } + const lines = formatAppLines(r.app); + io.out([...r.findings.map(formatFinding), ...r.app.notes.map((n) => `LINT_APP_NOTE: ${n}`), ...lines.states, formatSummary(r.summary), lines.verdict].join('\n')); + return r.exitCode; +} diff --git a/lib/design-lint/app-input.ts b/lib/design-lint/app-input.ts new file mode 100644 index 000000000..04c534029 --- /dev/null +++ b/lib/design-lint/app-input.ts @@ -0,0 +1,193 @@ +/** + * What `gstack-design-lint --scope app` reads before any page opens: the + * viewports, the --within selector, a --state-file and a --waivers file, plus + * the one redaction every URL goes through on its way to a person (text, + * JSON, stderr, traces). Every input error is an AppUsageError: exit 3, a + * code, and a fix line, raised before a browser starts. + */ + +export class AppUsageError extends Error { + constructor(readonly code: string, message: string, readonly fix: string) { + super(message); + } +} + +export interface Viewport { width: number; height: number } + +export const DEFAULT_VIEWPORTS: Viewport[] = [{ width: 1440, height: 900 }, { width: 375, height: 812 }]; + +export const viewportLabel = (v: Viewport): string => `${v.width}x${v.height}`; + +export function parseViewport(spec: string): Viewport { + const m = /^(\d{3,4})x(\d{3,4})$/.exec(spec); + const width = m ? Number(m[1]) : NaN; + const height = m ? Number(m[2]) : NaN; + if (!m || width < 320 || width > 3840 || height < 480 || height > 2400) { + throw new AppUsageError('BAD_VIEWPORT', `--viewport ${JSON.stringify(spec)} is not x with width 320 to 3840 and height 480 to 2400`, 'pass a size like 1440x900 or 375x812'); + } + return { width, height }; +} + +const SECRET_PARAM = /token|key|secret|auth|session|sig|signature|password|pass|code|jwt|bearer/i; + +/** Parameters with every secret-looking value replaced with REDACTED, or null when none looks secret. */ +function redactParams(text: string): string | null { + const params = new URLSearchParams(text); + if (![...params.keys()].some((k) => SECRET_PARAM.test(k))) return null; + return new URLSearchParams([...params.entries()].map(([k, v]) => [k, SECRET_PARAM.test(k) ? 'REDACTED' : v])).toString(); +} + +/** A URL fit to print: userinfo dropped, secret-looking query and fragment parameter values replaced with REDACTED. Navigation always uses the real URL. */ +export function redactUrl(raw: string): string { + let u: URL; + try { u = new URL(raw); } catch { return raw.replace(/\/\/[^/@\s]*@/, '//'); } + u.username = ''; + u.password = ''; + const search = redactParams(u.search.slice(1)); + if (search !== null) u.search = search; + const hash = u.hash.includes('=') ? redactParams(u.hash.slice(1)) : null; + if (hash !== null) u.hash = hash; + return u.toString(); +} + +/** Every http(s) URL inside free text (an error message, a stack) redacted. */ +export const redactText = (s: string): string => s.replace(/https?:\/\/[^\s"'<>)]+/g, (u) => redactUrl(u)); + +/** A state's name when only its URL is given: its path and query, redacted. */ +export function stateNameOf(url: string): string { + try { + const u = new URL(redactUrl(url)); + return u.pathname + u.search; + } catch { + return redactUrl(url); + } +} + +const QUOTED = /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g; + +/** + * Why a selector is malformed, or null, without a browser: balanced brackets, + * parentheses and quotes, a name after every . # and :, and a compound on + * both sides of every combinator and comma. Conservative: it can accept a + * selector the browser refuses (the probe then reports PROBE_ERROR), never + * the other way round. + */ +export function selectorError(sel: string): string | null { + const s = sel.trim(); + if (!s) return 'the selector is empty'; + if (s.length > 1000) return 'the selector is over 1000 characters'; + if (/[{};]/.test(s.replace(QUOTED, ''))) return 'braces and semicolons belong to CSS rules, not a selector'; + const stack: string[] = []; + let compound = false; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === '\\') { i++; compound = true; continue; } + if (c === '"' || c === "'") { + if (!stack.length) return `a quote outside [] or () at ${i + 1}`; + let end = i + 1; + while (end < s.length && s[end] !== c) end += s[end] === '\\' ? 2 : 1; + if (end >= s.length) return 'an unclosed quote'; + i = end; + continue; + } + if (c === '[' || c === '(') { stack.push(c); compound = true; continue; } + if (c === ']' || c === ')') { + if (stack.pop() !== (c === ']' ? '[' : '(')) return `an unbalanced ${c} at ${i + 1}`; + continue; + } + if (stack.length || /\s/.test(c)) continue; + if (c === ',' || c === '>' || c === '+' || c === '~') { + if (!compound) return `nothing before ${c} at ${i + 1}`; + compound = false; + continue; + } + if (!/[\w\-.#:*|\u00a0-\uffff]/.test(c)) return `${c} cannot appear in a selector (at ${i + 1})`; + if ((c === '.' || c === '#') && !/[\w\-\\\u00a0-\uffff]/.test(s[i + 1] ?? '')) return `a ${c} with no name after it (at ${i + 1})`; + if (c === ':' && !/[\w\-:]/.test(s[i + 1] ?? '')) return `a : with no pseudo-class after it (at ${i + 1})`; + compound = true; + } + if (stack.length) return `an unclosed ${stack[stack.length - 1]}`; + if (!compound) return 'the selector ends in a combinator or a comma'; + return null; +} + +export function checkSelector(flag: string, sel: string): void { + const why = selectorError(sel); + if (why) throw new AppUsageError('BAD_SELECTOR', `${flag} ${JSON.stringify(sel)} is not a valid selector: ${why}`, "pass a CSS selector the page's querySelectorAll accepts, like main .access or my-panel"); +} + +export interface AppStateInput { + name: string; + url: string; + expect?: string; + within?: string; +} + +const isLoopback = (url: string): boolean => { + try { return /^(127\.\d+\.\d+\.\d+|localhost|\[::1\])$/.test(new URL(url).hostname); } catch { return false; } +}; + +const STATE_KEYS = new Set(['name', 'url', 'expect', 'within']); + +type Bad = (why: string) => AppUsageError; + +/** The versioned top level shared by --state-file and --waivers: an object with exactly version 1 and one list. */ +function versioned(text: string, list: string, bad: Bad): unknown[] { + let doc: unknown; + try { doc = JSON.parse(text); } catch (e) { throw bad(`not JSON (${(e as Error).message})`); } + if (!doc || typeof doc !== 'object' || Array.isArray(doc)) throw bad('the top level is not an object'); + const top = doc as Record; + const extra = Object.keys(top).find((k) => k !== 'version' && k !== list); + if (extra) throw bad(`unknown key ${extra}`); + if (top.version !== 1) throw bad('version must be 1'); + if (!Array.isArray(top[list])) throw bad(`${list} must be a list`); + return top[list] as unknown[]; +} + +function entry(raw: unknown, where: string, keys: Set, bad: Bad): Record { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw bad(`${where} is not an object`); + const unknown = Object.keys(raw).find((k) => !keys.has(k)); + if (unknown) throw bad(`${where} has unknown key ${unknown}`); + return raw as Record; +} + +/** A --state-file: `{"version":1,"states":[{"name","url","expect"?,"within"?}]}`. Returns the states and notes (a non-loopback URL is allowed, and noted). */ +export function parseStateFile(text: string): { states: AppStateInput[]; notes: string[] } { + const bad: Bad = (why) => new AppUsageError('BAD_STATE_FILE', `--state-file: ${why}`, 'write {"version":1,"states":[{"name":"owner","url":"http://127.0.0.1:5300/p?as=owner","expect":""}]}'); + const list = versioned(text, 'states', bad); + if (!list.length) throw bad('states is empty'); + const notes: string[] = []; + const names = new Set(); + const states = list.map((raw, i): AppStateInput => { + const st = entry(raw, `states[${i}]`, STATE_KEYS, bad); + if (typeof st.url !== 'string' || !st.url) throw bad(`states[${i}] has no url`); + if (!/^https?:\/\/[^/]/.test(st.url)) throw bad(`states[${i}].url is not an http or https address`); + for (const k of ['name', 'expect', 'within']) if (st[k] !== undefined && (typeof st[k] !== 'string' || !st[k])) throw bad(`states[${i}].${k} must be a non-empty string`); + const name = typeof st.name === 'string' ? st.name : stateNameOf(st.url); + if (names.has(name)) throw bad(`two states are named ${name}`); + names.add(name); + if (typeof st.expect === 'string') checkSelector(`states[${i}].expect`, st.expect); + if (typeof st.within === 'string') checkSelector(`states[${i}].within`, st.within); + if (!isLoopback(st.url)) notes.push(`state ${name} is not on a loopback address (${redactUrl(st.url)})`); + return { name, url: st.url, ...(typeof st.expect === 'string' ? { expect: st.expect } : {}), ...(typeof st.within === 'string' ? { within: st.within } : {}) }; + }); + return { states, notes }; +} + +export interface AppWaiver { rule: string; locator: string; reason: string } + +export const APP_WAIVER_MIN_REASON = 8; + +const WAIVER_KEYS = new Set(['rule', 'locator', 'reason']); + +/** A --waivers file: `{"version":1,"waivers":[{"rule","locator","reason"}]}`; the rule must be an app rule, the locator a non-empty substring to match, the reason 8 characters or more. */ +export function parseWaiverFile(text: string, isAppRule: (id: string) => boolean): AppWaiver[] { + const bad: Bad = (why) => new AppUsageError('BAD_WAIVERS', `--waivers: ${why}`, 'write {"version":1,"waivers":[{"rule":"small-hit-area","locator":"","reason":""}]}'); + return versioned(text, 'waivers', bad).map((raw, i): AppWaiver => { + const w = entry(raw, `waivers[${i}]`, WAIVER_KEYS, bad); + if (typeof w.rule !== 'string' || !isAppRule(w.rule)) throw bad(`waivers[${i}].rule ${JSON.stringify(w.rule)} is not an app rule`); + if (typeof w.locator !== 'string' || !w.locator.trim()) throw bad(`waivers[${i}].locator must be a non-empty string`); + if (typeof w.reason !== 'string' || w.reason.trim().length < APP_WAIVER_MIN_REASON) throw bad(`waivers[${i}].reason must say why, about this page, in ${APP_WAIVER_MIN_REASON} characters or more`); + return { rule: w.rule, locator: w.locator, reason: w.reason.trim() }; + }); +} diff --git a/lib/design-lint/render-app.ts b/lib/design-lint/render-app.ts new file mode 100644 index 000000000..15394bd7d --- /dev/null +++ b/lib/design-lint/render-app.ts @@ -0,0 +1,260 @@ +/** + * The app profile's scheduler (gstack-design-lint --scope app): render-only, + * one page load per state, one measurement per viewport, confined to the + * --within section. Separate from render.ts on purpose: the page and + * component profiles' plan, probes and output do not change. + * + * The verdict is the machine signal, never the tier: clean when every + * planned state x viewport was measured and no app finding is open, open when + * coverage is complete and one is, incomplete (exit 4) when any state x + * viewport was not measured, whatever else it found. docs/design-lint.md + * "App profile (--scope app)" is the contract. + */ +import { randomBytes } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { pickEngine, render, renderWithBrowse, type EngineChoice, type RenderStep } from '../aside-render'; +import { DEFAULT_VIEWPORTS, redactText, redactUrl, viewportLabel, type AppWaiver, type Viewport } from './app-input'; +import { appInstallScript, appProbeScript, composedWaitExpression, type AppProbeFinding, type AppProbeResult, type AppSetupReport } from './render-probe-app'; +import type { Runner } from './render'; +import { finalize, oneLine, summarize, type Finding, type Summary } from './report'; +import { ruleMeta } from './rules/ids'; + +export const APP_CODES = ['NO_BROWSER', 'UNREACHABLE', 'TIMEOUT', 'NOT_READY', 'STATE_MISMATCH', 'WITHIN_NO_MATCH', 'CLOSED_SHADOW', 'PROBE_ERROR', 'ASSET_MISSING'] as const; +export type AppCode = typeof APP_CODES[number]; + +/** One line each: what to do about a state that was not measured. Anchors: docs/design-lint.md#app-code-. */ +export const APP_CODE_FIX: Record = { + NO_BROWSER: 'gstack-browser-ensure, then rerun', + UNREACHABLE: 'start the server or fix the URL, then rerun', + TIMEOUT: 'the page took longer than the render budget; make it load faster or measure fewer viewports, then rerun', + NOT_READY: 'pass --wait-for that appears once the section has its data, or make the data load', + STATE_MISMATCH: "make the URL load the state it names, or fix the state's expect selector", + WITHIN_NO_MATCH: "pass the custom element's tag or a selector inside the light DOM", + CLOSED_SHADOW: 'attach the shadow root with mode "open" in development, or pass a --within outside it', + PROBE_ERROR: 'read the trace file; a page script that redefines DOM APIs breaks the probes', + ASSET_MISSING: 'fix the stylesheet path or wait for the server to serve it, then rerun', +}; + +export const appCodeAnchor = (code: AppCode): string => `docs/design-lint.md#app-code-${code.toLowerCase()}`; + +export interface AppState { name: string; url: string; expect?: string; within?: string } + +export interface AppOptions { + states: AppState[]; + within: string; + viewports?: Viewport[]; + /** An explicit --wait-for: the readiness selector, and no list is required before measuring. */ + waitFor?: string; + waivers?: AppWaiver[]; + engine?: () => EngineChoice; + run?: Runner; + /** Replaces the reachability check (tests inject one); null means reachable. */ + reach?: (url: string) => Promise; + traceDir?: string; + /** How long the probe waits for a list after the section matched; 5000 by default. */ + readyMs?: number; + /** How long the engine waits for the section (or --wait-for) to match; 15000 by default. */ + sectionWaitMs?: number; +} + +export interface AppCoverage { name: string; url: string; viewport: string; status: 'measured' | 'incomplete'; code: AppCode | 'OK'; matched: number; detail?: string; trace?: string } + +export interface AppVerdict { + verdict: 'clean' | 'open' | 'incomplete'; + code: string; + codes: AppCode[]; + within: string; + matched: number; + viewports: string[]; + states: AppCoverage[]; + open: number; + waived: number; + notes: string[]; +} + +export interface AppResult { findings: Finding[]; summary: Summary; app: AppVerdict; exitCode: 0 | 1 | 4; stderr: string[] } + +const SECTION_WAIT_MS = 15_000; +const READY_MS = 5_000; +const RENDER_TIMEOUT_MS = 120_000; +const MAX_LOCATOR = 1000; + +export const defaultTraceDir = (): string => path.join(os.tmpdir(), 'gstack-design-lint-traces'); + +function writeTrace(dir: string, rec: { probe: string; state: string; viewport: string; stack: string; url: string }): string { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + const file = path.join(dir, `trace-${Date.now()}-${randomBytes(4).toString('hex')}.json`); + fs.writeFileSync(file, JSON.stringify(rec, null, 2) + '\n', { mode: 0o600 }); + fs.chmodSync(file, 0o600); + return file; +} + +async function reachable(url: string): Promise { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(10_000), redirect: 'follow' }); + return res.ok || res.status === 304 ? null : `HTTP ${res.status}`; + } catch (e) { + return (e as Error).message; + } +} + +const codeOfRunError = (e: string): AppCode => (/^no browser available/i.test(e) ? 'NO_BROWSER' : /waitFor/.test(e) ? 'NOT_READY' : /timed? ?out|timeout/i.test(e) ? 'TIMEOUT' : 'UNREACHABLE'); + +const INCOMPLETE_FROM_PROBE = new Set(['NOT_READY', 'STATE_MISMATCH', 'WITHIN_NO_MATCH', 'CLOSED_SHADOW']); + +type Parsed = { value: unknown } | { error: string; stack?: string }; + +function parseEval(text: string | undefined): Parsed { + if (text === undefined) return { error: 'no result came back' }; + try { + const v = JSON.parse(text) as unknown; + if (v && typeof v === 'object' && 'error' in v && typeof (v as { error: unknown }).error === 'string') { + const e = v as { error: string; stack?: unknown }; + return { error: e.error, ...(typeof e.stack === 'string' ? { stack: e.stack } : {}) }; + } + return v === null ? { error: 'empty result' } : { value: v }; + } catch (e) { + return { error: (e as Error).message }; + } +} + +const isFinding = (f: unknown): f is AppProbeFinding => { + if (!f || typeof f !== 'object') return false; + const x = f as Record; + return typeof x.rule === 'string' && !!ruleMeta(x.rule)?.app && typeof x.sentence === 'string' && x.sentence.length <= 400 + && typeof x.locator === 'string' && x.locator.length <= MAX_LOCATOR && typeof x.measured === 'number' && Number.isFinite(x.measured) && typeof x.threshold === 'number'; +}; + +/** A probe answer of the shape glAppMeasure returns, or why not. */ +function probeShape(v: unknown): { ok: AppProbeResult } | { bad: string } { + if (!v || typeof v !== 'object') return { bad: 'the answer is not an object' }; + const r = v as AppProbeResult; + if (r.status !== 'measured' && r.status !== 'incomplete') return { bad: 'the answer has no status' }; + if (typeof r.matched !== 'number' || !Array.isArray(r.findings)) return { bad: 'the answer has no match count or finding list' }; + if (r.status === 'incomplete' && !INCOMPLETE_FROM_PROBE.has(String(r.code))) return { bad: `the answer names an unknown code (${String(r.code).slice(0, 40)})` }; + if (!r.findings.every(isFinding)) return { bad: 'the page handed back a measurement the lint does not know; its scripts may be tampering with the probes' }; + return { ok: r }; +} + +const field = (s: string): string => oneLine(redactText(s)).replace(/ \| fix: /g, ' / fix: '); + +interface StateRun { coverage: AppCoverage[]; findings: Finding[]; notes: string[]; stderr: string[] } + +/** One state: reachability, one render with the install, setup and one measurement per viewport, then each answer settled. */ +async function lintState(state: AppState, opts: AppOptions, run: Runner, viewports: Viewport[]): Promise { + const out: StateRun = { coverage: [], findings: [], notes: [], stderr: [] }; + const shown = redactUrl(state.url); + const within = state.within ?? opts.within; + const all = (code: AppCode, detail: string, trace?: string) => { + for (const v of viewports) out.coverage.push({ name: state.name, url: shown, viewport: viewportLabel(v), status: 'incomplete', code, matched: 0, detail: field(detail), ...(trace ? { trace } : {}) }); + }; + const trace = (probe: string, viewport: string, stack: string): string | undefined => { + let file: string; + try { file = writeTrace(opts.traceDir ?? defaultTraceDir(), { probe, state: state.name, viewport, stack: redactText(stack), url: shown }); } catch (e) { + out.stderr.push(`gstack-design-lint: PROBE_ERROR: the ${probe} probe threw at ${state.name} ${viewport}; no trace written (${field((e as Error).message)})`); + return undefined; + } + out.stderr.push(`gstack-design-lint: PROBE_ERROR: the ${probe} probe threw at ${state.name} ${viewport}; trace ${file}`); + return file; + }; + if (/^https?:\/\//.test(state.url)) { + const err = await (opts.reach ?? reachable)(state.url); + if (err) { all('UNREACHABLE', `could not reach ${shown}: ${err}`); return out; } + } + const first = viewports[0]; + const sized = (expression: string, v: Viewport, maxInline = 400_000): RenderStep => ({ kind: 'eval', expression, width: v.width, height: v.height, deviceScaleFactor: 1, mobile: false, maxInline }); + const args = (v: Viewport) => JSON.stringify({ within, expect: state.expect ?? null, explicitWait: !!opts.waitFor, readyMs: opts.readyMs ?? READY_MS }); + const steps: RenderStep[] = [sized(appInstallScript(), first, 10), sized(appProbeScript('P.setup(P)'), first), ...viewports.map((v) => sized(appProbeScript(`P.measure(P, ${args(v)})`), v))]; + const sectionWait = opts.sectionWaitMs ?? SECTION_WAIT_MS; + const waitFor = { expression: composedWaitExpression(opts.waitFor ?? within, opts.waitFor ? null : sectionWait), timeoutMs: sectionWait + (opts.waitFor ? 0 : 1_000) }; + const res = await run({ file: state.url, waitFor, steps, timeoutMs: RENDER_TIMEOUT_MS }); + if (!res.ok) { + const err = res.error ?? 'unknown error'; + const code = codeOfRunError(err); + all(code, code === 'NOT_READY' ? `${opts.waitFor ? `the --wait-for selector ${opts.waitFor}` : `the section ${within}`} did not match within ${sectionWait / 1000}s` : `the page did not render: ${err.slice(0, 300)}`); + return out; + } + const setup = parseEval(res.evals[1]); + if ('error' in setup) { all('PROBE_ERROR', `the setup probe failed: ${setup.error}`, trace('setup', viewportLabel(first), setup.stack ?? setup.error)); return out; } + const report = setup.value as AppSetupReport; + if (!Array.isArray(report?.lostSheets) || !Array.isArray(report?.lostFonts)) { all('PROBE_ERROR', 'the setup answer has no list of lost assets'); return out; } + if (report.lostFonts.length) out.notes.push(field(`${state.name}: fonts did not load (${report.lostFonts.slice(0, 3).join(', ')}); measured with a fallback face`)); + if (report.lostSheets.length) { all('ASSET_MISSING', `stylesheets did not load: ${report.lostSheets.slice(0, 3).join(', ')}`); return out; } + viewports.forEach((v, i) => { + const label = viewportLabel(v); + const cov: AppCoverage = { name: state.name, url: shown, viewport: label, status: 'incomplete', code: 'PROBE_ERROR', matched: 0 }; + out.coverage.push(cov); + const p = parseEval(res.evals[2 + i]); + if ('error' in p) { cov.detail = field(`the measure probe failed: ${p.error}`); cov.trace = trace('measure', label, p.stack ?? p.error); return; } + const shape = probeShape(p.value); + if ('bad' in shape) { cov.detail = field(shape.bad); return; } + const r = shape.ok; + cov.matched = r.matched; + if (r.status === 'incomplete') { cov.code = r.code as AppCode; cov.detail = field(r.detail ?? ''); return; } + cov.status = 'measured'; + cov.code = 'OK'; + for (const f of r.findings) { + const meta = ruleMeta(f.rule)!; + const locator = field(f.locator); + out.findings.push({ rule: f.rule, tier: meta.tier, grade: meta.tier, file: oneLine(shown), line: 1, evidence: field(`[${state.name} ${v.width}px] ${f.sentence} at ${locator}`), fix: meta.fix, source: 'render', + app: { state: state.name, viewport: label, locator, measured: f.measured, threshold: f.threshold } }); + } + }); + return out; +} + +/** Waivers by rule and locator substring: a match prints at waived with its reason; a waiver that matched nothing is a waiver finding. */ +function applyAppWaivers(findings: Finding[], waivers: AppWaiver[]): Finding[] { + const used = new Set(); + const out = findings.map((f) => { + const i = waivers.findIndex((w) => w.rule === f.rule && !!f.app && f.app.locator.includes(w.locator)); + if (i < 0) return f; + used.add(i); + return { ...f, grade: 'waived' as const, waiver: oneLine(waivers[i].reason) }; + }); + waivers.forEach((w, i) => { + if (used.has(i)) return; + out.push({ rule: 'waiver', tier: 'ask', grade: 'ask', file: '--waivers', line: i + 1, evidence: field(`waiver on ${w.rule} at "${w.locator}" matched no finding (unused)`), fix: 'remove the waiver, or correct its locator to part of the finding\'s', source: 'render' }); + }); + return out; +} + +export async function lintApp(opts: AppOptions): Promise { + const viewports = opts.viewports?.length ? opts.viewports : DEFAULT_VIEWPORTS; + let run = opts.run; + let status = 'ran'; + const runs: StateRun[] = []; + if (!run) { + const choice = (opts.engine ?? pickEngine)(); + if (choice.engine) run = (spec) => (choice.engine === 'browse' ? renderWithBrowse(spec, choice.bin) : render(spec)); + else { + status = `skipped: ${choice.error}`; + runs.push({ findings: [], notes: [], stderr: [], coverage: opts.states.flatMap((s) => viewports.map((v): AppCoverage => ({ name: s.name, url: redactUrl(s.url), viewport: viewportLabel(v), status: 'incomplete', code: 'NO_BROWSER', matched: 0, detail: field(choice.error) }))) }); + } + } + if (run) for (const s of opts.states) runs.push(await lintState(s, opts, run, viewports)); + const coverage = runs.flatMap((r) => r.coverage); + const findings = finalize(applyAppWaivers(runs.flatMap((r) => r.findings), opts.waivers ?? [])); + const codes = APP_CODES.filter((c) => coverage.some((x) => x.code === c)); + const open = findings.filter((f) => ruleMeta(f.rule)?.app && f.grade !== 'waived').length; + const waived = findings.filter((f) => ruleMeta(f.rule)?.app && f.grade === 'waived').length; + const verdict: AppVerdict['verdict'] = codes.length || !coverage.length ? 'incomplete' : open ? 'open' : 'clean'; + const app: AppVerdict = { + verdict, code: verdict === 'incomplete' ? codes[0] ?? 'NO_BROWSER' : verdict === 'open' ? 'OPEN' : 'OK', codes, + within: opts.within, matched: coverage.length ? Math.min(...coverage.map((c) => c.matched)) : 0, + viewports: viewports.map(viewportLabel), states: coverage, open, waived, notes: runs.flatMap((r) => r.notes), + }; + return { findings, summary: summarize(findings, status), app, exitCode: verdict === 'clean' ? 0 : verdict === 'open' ? 1 : 4, stderr: runs.flatMap((r) => r.stderr) }; +} + +/** The LINT line's companions under --scope app: one LINT_APP_STATE line per state x viewport before LINT_SUMMARY, the LINT_APP verdict after it. */ +export function formatAppLines(app: AppVerdict): { states: string[]; verdict: string } { + const states = app.states.map((c) => oneLine(`LINT_APP_STATE: state=${c.name} viewport=${c.viewport} status=${c.status} code=${c.code} matched=${c.matched}` + + (c.status === 'incomplete' ? ` ${c.detail ?? ''}${c.trace ? ` (trace: ${c.trace})` : ''} | fix: ${APP_CODE_FIX[c.code as AppCode]} (${appCodeAnchor(c.code as AppCode)})` : ''))); + const names = [...new Set(app.states.map((c) => c.name))]; + const measured = names.filter((n) => app.states.every((c) => c.name !== n || c.status === 'measured')).length; + const verdict = oneLine(`LINT_APP: verdict=${app.verdict} code=${app.code} states=${measured}/${names.length} viewports=${app.viewports.join(',')} within=${app.within} matched=${app.matched} open=${app.open} waived=${app.waived}`); + return { states, verdict }; +} diff --git a/lib/design-lint/render-probe-app.ts b/lib/design-lint/render-probe-app.ts new file mode 100644 index 000000000..1f508d328 --- /dev/null +++ b/lib/design-lint/render-probe-app.ts @@ -0,0 +1,397 @@ +/** + * The app profile's in-page measurements (gstack-design-lint --scope app). + * Installed on window.__gstackLintApp by their own install step, apart from + * the page probes in render-probe.ts, which stay as they are. Each function + * is serialised with Function.prototype.toString and sees only its arguments, + * the helpers object and the DOM. + * + * Everything walks the composed tree: light DOM, every open shadow root and + * slotted content in the order it renders. Nothing reads a test hook + * (data-testid or any other attribute): a list is found by shape (siblings of + * one tag and class list, similar widths, stacked, each holding text), a + * row's name by position (the first run on its first line), a note by + * position (a text block starting below the name's line). + * + * Text positions are line boxes: a text rect widened by its half-leading + * (Chrome floors the half above the glyphs and gives the rest below), so a gap + * measured to a row edge is the padding and margins the CSS set. + */ + +export interface AppProbeFinding { rule: string; sentence: string; locator: string; measured: number; threshold: number } + +export interface AppProbeResult { + status: 'measured' | 'incomplete'; + /** Set when incomplete: NOT_READY, STATE_MISMATCH, WITHIN_NO_MATCH or CLOSED_SHADOW. */ + code?: string; + detail?: string; + /** Elements the --within selector matched in the composed tree. */ + matched: number; + /** Trees searched: the document plus every open shadow root. */ + roots: number; + lists: number; + findings: AppProbeFinding[]; +} + +export interface AppSetupReport { lostSheets: string[]; lostFonts: string[] } + +export interface AppMeasureArgs { within: string; expect: string | null; explicitWait: boolean; readyMs: number } + +interface Run { node: Text; el: Element; block: Element; rects: DOMRect[]; top: number; bottom: number; left: number; lineTop: number; lineBottom: number } + +interface AppList { container: Element; rows: Element[]; last: Element; whole: boolean } + +/** Composed-tree helpers, built once by the install step. */ +export function glAppHelpers() { + const kids = (n: Node): Node[] => { + if ((n as Element).tagName === 'SLOT') { const a = (n as HTMLSlotElement).assignedNodes({ flatten: true }); return a.length ? a : Array.from(n.childNodes); } + const sr = (n as Element).shadowRoot; + return Array.from(sr ? sr.childNodes : n.childNodes); + }; + const ekids = (n: Node): Element[] => kids(n).filter((c) => c.nodeType === 1) as Element[]; + /** The element children that render as boxes: a display: contents child (a slot, a wrapper) is replaced by its own. */ + const boxKids = (n: Node): Element[] => ekids(n).flatMap((k) => (getComputedStyle(k).display === 'contents' ? boxKids(k) : [k])); + const cparent = (n: Node): Element | null => { + const slot = (n as Element).assignedSlot; + if (slot) return slot; + const p = n.parentNode; + if (!p) return null; + return p.nodeType === 11 ? ((p as ShadowRoot).host || null) : (p as Element).nodeType === 1 ? (p as Element) : null; + }; + const inside = (el: Node, root: Element): boolean => { for (let e: Node | null = el; e; e = cparent(e)) if (e === root) return true; return false; }; + /** Every element under root in the composed tree, root first. */ + const desc = (root: Element): Element[] => { const out: Element[] = []; const go = (e: Element) => { out.push(e); for (const k of ekids(e)) go(k); }; go(root); return out; }; + const roots = (): Array => { + const out: Array = [document]; + for (let i = 0; i < out.length; i++) for (const e of out[i].querySelectorAll('*')) if (e.shadowRoot) out.push(e.shadowRoot); + return out; + }; + const qAll = (sel: string): Element[] => { const out: Element[] = []; for (const r of roots()) for (const e of r.querySelectorAll(sel)) out.push(e); return out; }; + const shown = (el: Element): boolean => { const cs = getComputedStyle(el); return cs.display !== 'none' && cs.visibility !== 'hidden' && parseFloat(cs.opacity) !== 0; }; + const vis = (el: Element): boolean => { if (!shown(el)) return false; const r = el.getBoundingClientRect(); return r.width >= 1 && r.height >= 1; }; + const CONTROL = 'button, a[href], select, input, textarea, [role=button], [role=menuitem], [role=menuitemradio], [role=menuitemcheckbox], [role=tab], [role=switch], [role=option]'; + const isControl = (el: Element): boolean => el.matches(CONTROL); + const blockOf = (el: Element, stop: Element): Element => { + let e: Element = el; + while (e !== stop) { const d = getComputedStyle(e).display; if (d !== 'inline' && d !== 'contents') break; const p = cparent(e); if (!p) break; e = p; } + return e; + }; + /** Visible text runs under root in rendered order, text inside controls left out. */ + const runsOf = (root: Element): Run[] => { + const out: Run[] = []; + const go = (n: Node, ctl: boolean): void => { + for (const c of kids(n)) { + if (c.nodeType === 3) { + if (ctl || !(c.textContent || '').trim()) continue; + const el = cparent(c); + if (!el) continue; + const rg = document.createRange(); rg.selectNodeContents(c); + const rects = Array.from(rg.getClientRects()).filter((q) => q.width > 0 && q.height > 0); + if (!rects.length) continue; + const lh = parseFloat(getComputedStyle(el).lineHeight); + const above = (q: DOMRect) => (Number.isFinite(lh) ? Math.floor((lh - q.height) / 2) : 0); + const below = (q: DOMRect) => (Number.isFinite(lh) ? lh - q.height - above(q) : 0); + out.push({ node: c as Text, el, block: blockOf(el, root), rects, top: rects[0].top, bottom: Math.max(...rects.map((q) => q.bottom)), left: rects[0].left, + lineTop: Math.min(...rects.map((q) => q.top - above(q))), lineBottom: Math.max(...rects.map((q) => q.bottom + below(q))) }); + } else if (c.nodeType === 1) { + const e = c as Element; + if (/^(SCRIPT|STYLE|TEMPLATE|NOSCRIPT)$/.test(e.tagName) || !shown(e)) continue; + go(e, ctl || isControl(e)); + } + } + }; + go(root, isControl(root)); + return out; + }; + const lineCount = (runs: Run[]): number => { + const tops = runs.flatMap((r) => r.rects.map((q) => q.top + q.height / 2)).sort((a, b) => a - b); + let n = 0, last = -Infinity; + for (const t of tops) { if (t - last > 4) n++; last = t; } + return n; + }; + const cv = document.createElement('canvas'); cv.width = cv.height = 1; + const cx = cv.getContext('2d', { willReadFrequently: true }) as CanvasRenderingContext2D; + const rgb = (s: string): number[] => { cx.clearRect(0, 0, 1, 1); cx.fillStyle = 'rgba(0, 0, 0, 0)'; cx.fillStyle = s; cx.fillRect(0, 0, 1, 1); const d = cx.getImageData(0, 0, 1, 1).data; return [d[0] / 255, d[1] / 255, d[2] / 255, d[3] / 255]; }; + const surface = (el: Element): boolean => { + const cs = getComputedStyle(el); + if (rgb(cs.backgroundColor)[3] > 0.02 || cs.backgroundImage !== 'none' || cs.boxShadow !== 'none') return true; + return (['Top', 'Right', 'Bottom', 'Left'] as const).some((s) => parseFloat(cs.getPropertyValue('border-' + s.toLowerCase() + '-width')) > 0 && !/none|hidden/.test(cs.getPropertyValue('border-' + s.toLowerCase() + '-style')) && rgb(cs.getPropertyValue('border-' + s.toLowerCase() + '-color'))[3] > 0.02); + }; + /** The px of a drawn edge on one side: its border width, 0 when only a background or shadow draws it, null when nothing does. */ + const drawn = (el: Element, side: 'top' | 'bottom'): number | null => { + const cs = getComputedStyle(el); + const w = parseFloat(cs.getPropertyValue('border-' + side + '-width')); + if (w > 0 && !/none|hidden/.test(cs.getPropertyValue('border-' + side + '-style')) && rgb(cs.getPropertyValue('border-' + side + '-color'))[3] > 0.02) return w; + return rgb(cs.backgroundColor)[3] > 0.02 || cs.backgroundImage !== 'none' || cs.boxShadow !== 'none' ? 0 : null; + }; + const bgOf = (el: Element): number[] => { + for (let n: Element | null = el; n; n = cparent(n)) { const c = rgb(getComputedStyle(n).backgroundColor); if (c[3] >= 0.9) return c; } + return [1, 1, 1, 1]; + }; + const lightness = (el: Element, stop: Element): number => { + const a = rgb(getComputedStyle(el).color); + let op = 1; + for (let n: Element | null = el; n && n !== stop; n = cparent(n)) op *= parseFloat(getComputedStyle(n).opacity); + const bg = bgOf(el); + const c = [0, 1, 2].map((i) => a[i] * a[3] * op + bg[i] * (1 - a[3] * op)); + const lin = c.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); + const l = Math.cbrt(0.4122214708 * lin[0] + 0.5363325363 * lin[1] + 0.0514459929 * lin[2]); + const m = Math.cbrt(0.2119034982 * lin[0] + 0.6806995451 * lin[1] + 0.1073969566 * lin[2]); + const s = Math.cbrt(0.0883024619 * lin[0] + 0.2817188376 * lin[1] + 0.6299787005 * lin[2]); + return 0.2104542553 * l + 0.793617785 * m - 0.0040720403 * s; + }; + const seg = (e: Element): string => { + const cls = (e.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 2); + const p = e.parentElement || (e.parentNode as ShadowRoot | null); + const same = p ? Array.from(p.children).filter((x) => x.tagName === e.tagName) : []; + return e.tagName.toLowerCase() + (cls.length ? '.' + cls.join('.') : '') + (same.length > 1 ? ':nth-of-type(' + (same.indexOf(e) + 1) + ')' : ''); + }; + /** A shadow-piercing path to el: each host, then the steps inside el's own tree (up to eight), then its opening text, or a control's label. */ + const locOf = (el: Element): string => { + const path: string[] = []; + for (let e: Element | null = el; e && path.length < 8; e = e.parentElement) { if (e === document.body || e === document.documentElement) break; path.unshift(seg(e)); } + const hosts: string[] = []; + for (let r = el.getRootNode(); r.nodeType === 11; r = (r as ShadowRoot).host.getRootNode()) hosts.unshift(seg((r as ShadowRoot).host)); + const own = runsOf(el).map((r) => r.node.textContent || '').join(' '); + const words = (own.trim() ? own : el.getAttribute('aria-label') || el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 40); + return [...hosts, path.join(' > ') || seg(el)].join(' >>> ') + (words ? ' "' + words + '"' : ''); + }; + const px = (n: number): number => Math.round(n * 100) / 100; + /** m is under the threshold by more than layout's subpixel noise (0.05px). */ + const under = (m: number, t: number): boolean => m < t - 0.05; + return { kids, ekids, boxKids, cparent, inside, desc, roots, qAll, shown, vis, isControl, runsOf, lineCount, rgb, surface, drawn, lightness, locOf, px, under }; +} + +export type AppHelpers = ReturnType; + +/** The page before measuring: transitions off, running animations finished, fonts settled; which stylesheets and fonts did not load. */ +export async function glAppSetup(P: AppProbes): Promise { + const quiet = document.createElement('style'); + quiet.textContent = '* { transition: none !important } html, body { scroll-behavior: auto !important }'; + document.head.appendChild(quiet); + for (const a of document.getAnimations()) { try { a.finish(); } catch { /* an infinite animation keeps running */ } } + if (document.fonts && document.fonts.status !== 'loaded') await Promise.race([document.fonts.ready, new Promise((r) => setTimeout(r, 3000))]); + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + const lostSheets: string[] = []; + for (const root of P.H.roots()) for (const l of root.querySelectorAll('link[rel~="stylesheet"][href]')) if (!(l as HTMLLinkElement).sheet) lostSheets.push((l.getAttribute('href') || '').split('?')[0].split('/').pop() || 'stylesheet'); + for (const e of performance.getEntriesByType('resource') as PerformanceResourceTiming[]) if ((e.initiatorType === 'link' || e.initiatorType === 'css') && /\.css(\?|$)/.test(e.name) && e.responseStatus >= 400) lostSheets.push(e.name.split('?')[0].split('/').pop() || 'stylesheet'); + const lostFonts: string[] = []; + if (document.fonts) for (const f of document.fonts) if (f.status === 'error') lostFonts.push(f.family.replace(/^["']|["']$/g, '')); + return { lostSheets: Array.from(new Set(lostSheets)), lostFonts: Array.from(new Set(lostFonts)) }; +} + +/** Every list under root: two or more adjacent siblings of one tag and class list (never headings, paragraphs, sections or forms), widths within 10%, stacked top to bottom, each holding text; never a table, a nav, a menu-like role or a grid of columns. */ +export function glAppLists(P: AppProbes, root: Element): AppList[] { + const { boxKids, cparent, vis, runsOf } = P.H; + const out: AppList[] = []; + const banned = (e: Element): boolean => { + for (let n: Element | null = e; n; n = cparent(n)) if (/^(TABLE|THEAD|TBODY|TFOOT|TR|TD|TH|NAV)$/.test(n.tagName) || /^(navigation|grid|table|menu|menubar|listbox|tablist|tree)$/.test(n.getAttribute('role') || '')) return true; + return false; + }; + const walk = (parent: Element): void => { + const kids = boxKids(parent).filter((k) => vis(k)); + const keyOf = (k: Element) => k.tagName + '|' + (k.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).sort().join(' '); + const groups: Element[][] = []; + for (const k of kids) { + const g = groups[groups.length - 1]; + if (g && keyOf(g[0]) === keyOf(k)) g.push(k); else groups.push([k]); + } + for (const g of groups) { + if (g.length < 2 || /^(H[1-6]|P|SECTION|MAIN|HEADER|FOOTER|ASIDE|FORM|FIELDSET)$/.test(g[0].tagName) || banned(g[0])) continue; + const boxes = g.map((e) => e.getBoundingClientRect()); + const widths = boxes.map((b) => b.width); + if (Math.max(...widths) > Math.min(...widths) * 1.1) continue; + const order = g.map((e, i) => ({ e, b: boxes[i] })).sort((a, b) => a.b.top - b.b.top); + if (order.some((x, i) => i > 0 && x.b.top < order[i - 1].b.bottom - 1)) continue; + if (order.some((x) => !runsOf(x.e).length)) continue; + const rows = order.map((x) => x.e); + out.push({ container: parent, rows, last: rows[rows.length - 1], whole: kids.every((k) => rows.includes(k)) }); + } + for (const k of kids) walk(k); + }; + walk(root); + return out; +} + +/** row-crowding and note-in-row-weight over a list of rows (bounded: a row or the list draws an edge; or laid out: every row sets two or more boxes side by side), small-hit-area over any list's. */ +export function glAppRows(P: AppProbes, list: AppList, vw: number): AppProbeFinding[] { + const { runsOf, lineCount, surface, drawn, lightness, locOf, px, under, cparent, vis, desc, boxKids } = P.H; + const out: AppProbeFinding[] = []; + const rows = list.rows; + const texts = rows.map((r) => runsOf(r)); + const boxes = rows.map((r) => r.getBoundingClientRect()); + const cb = list.container.getBoundingClientRect(); + const sideBySide = (r: Element): boolean => { + if (!/flex|grid/.test(getComputedStyle(r).display)) return false; + const bs = boxKids(r).filter((k) => vis(k)).map((k) => k.getBoundingClientRect()); + return bs.some((a, i) => bs.some((b, j) => j > i && a.top < b.bottom && b.top < a.bottom && (a.right <= b.left || b.right <= a.left))); + }; + const bounded = surface(list.container) || rows.some((r) => surface(r)) || rows.every(sideBySide); + const inner = (i: number, side: 'top' | 'bottom'): number | null => { + const own = drawn(rows[i], side); + if (own != null) return side === 'top' ? boxes[i].top + own : boxes[i].bottom - own; + const j = side === 'top' ? i - 1 : i + 1; + if (rows[j] && drawn(rows[j], side === 'top' ? 'bottom' : 'top') != null) return side === 'top' ? boxes[j].bottom : boxes[j].top; + const c = !rows[j] && list.whole ? drawn(list.container, side) : null; + return c == null ? null : side === 'top' ? cb.top + c : cb.bottom - c; + }; + rows.forEach((row, i) => { + const runs = texts[i]; + const lines = lineCount(runs); + if (bounded && lines >= 2) { + const topEdge = inner(i, 'top'); + const bottomEdge = inner(i, 'bottom'); + const top = topEdge == null ? Infinity : Math.min(...runs.map((r) => r.lineTop)) - topEdge; + const bottom = bottomEdge == null ? Infinity : bottomEdge - Math.max(...runs.map((r) => r.lineBottom)); + const next = texts[i + 1]; + const gap = px(next && next.length ? Math.min(...next.map((r) => r.lineTop)) - Math.max(...runs.map((r) => r.lineBottom)) : Infinity); + const edge = px(Math.min(top, bottom)); + if (under(edge, 8)) out.push({ rule: 'row-crowding', sentence: 'a ' + lines + '-line row leaves ' + edge + 'px between its text and its ' + (top <= bottom ? 'top' : 'bottom') + ' edge (under 8px)', locator: locOf(row), measured: edge, threshold: 8 }); + else if (under(gap, 16)) out.push({ rule: 'row-crowding', sentence: 'a ' + lines + '-line row leaves ' + gap + 'px between its last text line and the next row\'s text (under 16px)', locator: locOf(row), measured: gap, threshold: 16 }); + } + if (bounded && runs.length) { + const first = Math.min(...runs.map((r) => r.top)); + const name = runs.filter((r) => r.top <= first + 1).sort((a, b) => a.left - b.left)[0]; + const blocks = new Map(); + for (const r of runs) if (r.block !== name.block) blocks.set(r.block, [...(blocks.get(r.block) || []), r]); + for (const [block, own] of blocks) { + if (Math.min(...own.map((r) => r.top)) < name.bottom - 1) continue; + let callout = false; + for (let e: Element | null = block; e && e !== row; e = cparent(e)) if (surface(e)) { callout = true; break; } + const ICON = 'svg, img, [aria-hidden="true"]'; + const iconed = !!block.querySelector(ICON) || [block.previousElementSibling, block.nextElementSibling].some((s) => !!s && vis(s) && (s.matches(ICON) || (!(s.textContent || '').trim() && !!s.querySelector('svg, img')))); + if (callout || iconed) continue; + const dl = Math.abs(lightness(own[0].el, row) - lightness(name.el, row)); + const ratio = parseFloat(getComputedStyle(own[0].el).fontSize) / parseFloat(getComputedStyle(name.el).fontSize); + const noteLines = lineCount(own); + if (dl < 0.15 && ratio >= 0.9) out.push({ rule: 'note-in-row-weight', sentence: 'a note in a row reads at the name\'s weight: lightness ' + dl.toFixed(3) + ' from the name\'s (under 0.15) at ' + ratio.toFixed(2) + ' of its size (0.9 or more)', locator: locOf(block), measured: Math.round(dl * 1000) / 1000, threshold: 0.15 }); + else if (vw >= 1200 && noteLines >= 3) out.push({ rule: 'note-in-row-weight', sentence: 'a note in a row runs ' + noteLines + ' lines at ' + vw + 'px (3 or more)', locator: locOf(block), measured: noteLines, threshold: 3 }); + } + } + if (vw <= 480) { + const controls = desc(row).filter((c) => c !== row && c.matches('button, a[href], input:not([type=hidden]), select, [role=button], [role^=menuitem], [role=switch], [role=tab]') && vis(c)); + for (const c of controls) { + if (c.tagName === 'A' && getComputedStyle(c).display === 'inline') { + const host = cparent(c); + if (host && runsOf(host).some((r) => !c.contains(r.node))) continue; + } + let b = c.getBoundingClientRect(); + const labels = (c as HTMLInputElement).labels; + if (labels && labels.length) { + const all = [b, ...Array.from(labels).map((l) => l.getBoundingClientRect())]; + const l = Math.min(...all.map((q) => q.left)), t = Math.min(...all.map((q) => q.top)); + b = new DOMRect(l, t, Math.max(...all.map((q) => q.right)) - l, Math.max(...all.map((q) => q.bottom)) - t); + } + if (under(b.width, 32) || under(b.height, 32)) out.push({ rule: 'small-hit-area', sentence: 'a row control is ' + px(b.width) + 'x' + px(b.height) + 'px at ' + vw + 'px (under 32px)', locator: locOf(c), measured: px(Math.min(b.width, b.height)), threshold: 32 }); + } + } + }); + return out; +} + +/** copy-flush-under-list: the first text block after the list, before the next heading, inside the measured section. */ +export function glAppAfter(P: AppProbes, list: AppList, root: Element): AppProbeFinding[] { + const { boxKids, cparent, vis, runsOf, locOf, px, under } = P.H; + const HEADING = /^H[1-6]$/; + const isHeading = (e: Element) => HEADING.test(e.tagName) || e.getAttribute('role') === 'heading'; + let from: Element = list.whole ? list.container : list.last; + for (let depth = 0; depth < 3; depth++) { + let parent = cparent(from); + while (parent && getComputedStyle(parent).display === 'contents' && parent !== root) parent = cparent(parent); + if (!parent) return []; + const sibs = boxKids(parent); + for (const s of sibs.slice(sibs.indexOf(from) + 1)) { + if (!vis(s)) continue; + if (isHeading(s)) return []; + const runs = runsOf(s); + if (!runs.length) continue; + const first = runs.slice().sort((a, b) => a.lineTop - b.lineTop)[0]; + for (let e: Element | null = first.el; e && e !== s; e = cparent(e)) if (isHeading(e)) return []; + const gap = px(first.lineTop - from.getBoundingClientRect().bottom); + return under(gap, 12) ? [{ rule: 'copy-flush-under-list', sentence: 'the text under the list starts ' + gap + 'px below it (under 12px)', locator: locOf(first.block), measured: gap, threshold: 12 }] : []; + } + if (parent === root || isHeading(parent)) return []; + from = parent; + } + return []; +} + +/** section-overflow: inside root, an element past the viewport's right edge, or a non-scrolling block wider inside than out. The innermost three. */ +export function glAppOverflow(P: AppProbes, root: Element, vw: number): AppProbeFinding[] { + const { ekids, vis, locOf, px } = P.H; + const hits: Array<{ el: Element; sentence: string; m: number }> = []; + const go = (el: Element, clipped: boolean): void => { + if (el.tagName === 'svg' || /^(SCRIPT|STYLE|TEMPLATE)$/.test(el.tagName)) return; + const cs = getComputedStyle(el); + if (cs.display === 'contents') { for (const k of ekids(el)) go(k, clipped); return; } + if (!vis(el) || cs.position === 'fixed') return; + const b = el.getBoundingClientRect(); + if (!clipped && b.width > 1 && b.height > 1 && b.right > vw + 1) hits.push({ el, sentence: el.tagName.toLowerCase() + ' runs ' + px(b.right - vw) + 'px past the ' + vw + 'px viewport', m: px(b.right - vw) }); + else if (cs.overflowX === 'visible' && cs.display !== 'inline' && el.clientWidth > 0 && el.scrollWidth > el.clientWidth + 1) hits.push({ el, sentence: el.tagName.toLowerCase() + ' overflows its own box by ' + (el.scrollWidth - el.clientWidth) + 'px', m: el.scrollWidth - el.clientWidth }); + for (const k of ekids(el)) go(k, clipped || cs.overflowX !== 'visible'); + }; + go(root, false); + const inner = hits.filter((h) => !hits.some((o) => o !== h && P.H.inside(o.el, h.el))); + return inner.slice(0, 3).map((h) => ({ rule: 'section-overflow', sentence: h.sentence + ' (over 1px)', locator: locOf(h.el), measured: h.m, threshold: 1 })); +} + +/** One state at one viewport: wait for the section and a list (or the caller's --wait-for), check the state's expect selector, then every app rule. */ +export async function glAppMeasure(P: AppProbes, a: AppMeasureArgs): Promise { + const { qAll, inside, roots, desc } = P.H; + const t0 = Date.now(); + const outermost = (els: Element[]) => els.filter((e) => !els.some((o) => o !== e && inside(e, o))); + let matches: Element[] = []; + let lists: AppList[] = []; + let expectOk = true; + let host: Element | undefined; + const closed = (scope: Element[]): Element | undefined => + scope.flatMap((s) => desc(s)).find((e) => e.tagName.includes('-') && !e.shadowRoot && !e.children.length && !(e.textContent || '').trim() && e.getBoundingClientRect().height >= 40); + for (;;) { + matches = outermost(qAll(a.within)); + lists = matches.flatMap((m) => P.lists(P, m)); + expectOk = !a.expect || qAll(a.expect).length > 0; + host = matches.length && !lists.length ? closed(matches) : undefined; + if (host || (matches.length && (lists.length || a.explicitWait) && expectOk) || Date.now() - t0 > a.readyMs) break; + await new Promise((r) => setTimeout(r, 150)); + } + const matched = qAll(a.within).length; + const nRoots = roots().length; + const base = { matched, roots: nRoots, lists: lists.length, findings: [] as AppProbeFinding[] }; + if (!matches.length) { + const host = closed([document.body]); + if (host) return { ...base, status: 'incomplete', code: 'CLOSED_SHADOW', detail: '<' + host.tagName.toLowerCase() + '> renders a closed shadow root the lint cannot read, and nothing matched ' + a.within + ' in the document or its ' + (nRoots - 1) + ' open shadow roots' }; + return { ...base, status: 'incomplete', code: 'WITHIN_NO_MATCH', detail: 'nothing matched ' + a.within + ' in the document or its ' + (nRoots - 1) + ' open shadow roots' }; + } + if (host) return { ...base, status: 'incomplete', code: 'CLOSED_SHADOW', detail: '<' + host.tagName.toLowerCase() + '> inside ' + a.within + ' renders a closed shadow root the lint cannot read' }; + if (!lists.length && !a.explicitWait) return { ...base, status: 'incomplete', code: 'NOT_READY', detail: 'no list of two or more rows appeared inside ' + a.within + ' within ' + Math.round(a.readyMs / 1000) + 's' }; + if (!expectOk) return { ...base, status: 'incomplete', code: 'STATE_MISMATCH', detail: 'the state\'s expect selector ' + a.expect + ' matched nothing after readiness' }; + const vw = innerWidth; + const findings: AppProbeFinding[] = []; + for (const l of lists) findings.push(...P.rows(P, l, vw), ...P.after(P, l, matches.find((m) => inside(l.container, m)) || l.container)); + for (const m of matches) findings.push(...P.overflow(P, m, vw)); + return { ...base, status: 'measured', findings }; +} + +const APP_FNS = { setup: glAppSetup, lists: glAppLists, rows: glAppRows, after: glAppAfter, overflow: glAppOverflow, measure: glAppMeasure }; + +export interface AppProbes extends AppFns { H: AppHelpers } +type AppFns = typeof APP_FNS; + +export const APP_PROBE_GLOBAL = 'window.__gstackLintApp'; + +/** The JS that installs the app probes, as one expression. */ +export function appInstallScript(): string { + const entries = Object.entries(APP_FNS).map(([k, fn]) => k + ': (' + fn.toString() + ')'); + return '(' + APP_PROBE_GLOBAL + ' = { H: (' + glAppHelpers.toString() + ')(), ' + entries.join(', ') + ' }, 1)'; +} + +/** One app probe call as an eval expression: its JSON, or {error, stack} when it throws (the stack feeds the trace file). Written without the word await (see render-probe.ts probeScript). */ +export function appProbeScript(call: string): string { + return '(() => { const P = ' + APP_PROBE_GLOBAL + '; return Promise.resolve().then(() => (' + call + ')).then((v) => JSON.stringify(v), (e) => JSON.stringify({ error: String((e && e.message) || e).slice(0, 300), stack: String((e && e.stack) || e).slice(0, 4000) })); })()'; +} + +/** The readiness expression handed to the engine's waitFor: a selector matched through every open shadow root. With soft, it also turns true after ms, so a selector that never matches reaches the probe and reports WITHIN_NO_MATCH instead of failing the render. */ +export function composedWaitExpression(selector: string, soft: number | null): string { + const find = '(() => { const s = ' + JSON.stringify(selector) + '; const rs = [document]; try { for (let i = 0; i < rs.length; i++) { if (rs[i].querySelector(s)) return true; for (const e of rs[i].querySelectorAll("*")) if (e.shadowRoot) rs.push(e.shadowRoot); } } catch (e) { return ' + (soft != null) + '; } return false; })()'; + if (soft == null) return find; + return '(() => { const w = window; w.__gstackLintAppWait = w.__gstackLintAppWait || Date.now(); return ' + find + ' || Date.now() - w.__gstackLintAppWait > ' + soft + '; })()'; +} diff --git a/lib/design-lint/report.ts b/lib/design-lint/report.ts index 59c12ace3..4f0892a9e 100644 --- a/lib/design-lint/report.ts +++ b/lib/design-lint/report.ts @@ -26,6 +26,16 @@ export interface Finding { source: Source; /** Set when a waiver answered the finding: the reason it recorded. */ waiver?: string; + /** An app-profile finding's measurement (--scope app): kept whole, never truncated. */ + app?: AppMeasure; +} + +export interface AppMeasure { + state: string; + viewport: string; + locator: string; + measured: number; + threshold: number; } export interface ReportOptions { @@ -47,13 +57,14 @@ export const relPath = (file: string, cwd = process.cwd()): string => export class Reporter { readonly findings: Finding[] = []; - constructor(readonly scope: 'page' | 'component' = 'page', readonly cwd = process.cwd()) {} + constructor(readonly scope: 'page' | 'component' | 'app' = 'page', readonly cwd = process.cwd()) {} /** Records one finding and returns it; null when the rule is page-only and the scope is component. */ report(rule: string, file: string, line: number | null | undefined, evidence: string, opts: ReportOptions = {}): Finding | null { const meta = ruleMeta(rule); if (!meta) throw new Error(`design-lint: unknown rule id ${rule}`); if (this.scope === 'component' && meta.pageOnly) return null; + if (meta.app && this.scope !== 'app') return null; const tier: Tier = opts.suspected && meta.tier === 'blocks' ? 'ask' : meta.tier; const finding: Finding = { rule, tier, grade: tier, @@ -76,7 +87,7 @@ const GRADE_RANK: Record = { blocks: 0, ask: 1, waived: 2, polish export function finalize(findings: Finding[]): Finding[] { const seen = new Set(); const unique = findings.filter((f) => { - const k = `${f.rule}|${f.grade}|${f.file}|${f.line}|${f.evidence}`; + const k = `${f.rule}|${f.grade}|${f.file}|${f.line}|${f.evidence}` + (f.app ? `|${f.app.state}|${f.app.viewport}|${f.app.locator}` : ''); if (seen.has(k)) return false; seen.add(k); return true; diff --git a/lib/design-lint/rules/ids.ts b/lib/design-lint/rules/ids.ts index 6a328fa99..741a920df 100644 --- a/lib/design-lint/rules/ids.ts +++ b/lib/design-lint/rules/ids.ts @@ -31,6 +31,8 @@ export interface RuleMeta { pageOnly?: boolean; /** The render tier reports this rule; the static tier never does. */ render?: boolean; + /** An app-profile rule: only `--scope app` reports it, and `--scope app` reports nothing else (render-app.ts). */ + app?: boolean; } export const RULES: Record = { @@ -170,6 +172,12 @@ export const RULES: Record = { 'missing-skip-link': { tier: 'ask', gate: 'craft', title: 'no skip link reaching
', fix: 'add a "Skip to content" link first in whose #id exists on
', pageOnly: true }, 'missing-aria-current': { tier: 'ask', gate: 'craft', title: 'route navigation without aria-current', fix: 'mark the current nav link aria-current="page"', pageOnly: true }, 'empty-mount-node': { tier: 'ask', gate: 'craft', title: 'an empty mount node: the body has no text and a script renders it, so the lint read none of its content', fix: 'lint the rendered page: save document.documentElement.outerHTML to a file and lint that', pageOnly: true }, + // ── App profile (--scope app only; render-app.ts and render-probe-app.ts, GST-1741) ── + 'row-crowding': { tier: 'ask', gate: 'app', title: 'a list row whose text spans two or more lines sits under 8px from its row edge, or under 16px from the next row\'s text', fix: 'pad the row 8px or more top and bottom and leave 16px or more between one row\'s text and the next', render: true, app: true }, + 'copy-flush-under-list': { tier: 'ask', gate: 'app', title: 'the first text block after a list starts under 12px below the list', fix: 'leave 12px or more between the list and the copy under it', render: true, app: true }, + 'note-in-row-weight': { tier: 'ask', gate: 'app', title: 'a note inside a row at the name\'s weight (no icon, no surface of its own, lightness within 0.15 and size 0.9 or more of the name\'s), or a note of three lines or more at 1200px and wider', fix: 'mute the note (a lighter ink and a smaller size than the name), or give it an icon or a callout of its own', render: true, app: true }, + 'small-hit-area': { tier: 'ask', gate: 'app', title: 'a control inside a list row under 32px in either dimension at a phone width (480px or less)', fix: 'make the target 32px or more (padding with a negative margin, or an inset ::before) and keep the visible glyph its size', render: true, app: true }, + 'section-overflow': { tier: 'ask', gate: 'app', title: 'an element inside the measured section runs past the viewport, or a non-scrolling element overflows its own box', fix: 'let it wrap or shrink (min-width: 0, overflow-wrap: anywhere), or give it a scrolling container', render: true, app: true }, // ── The lint's own bookkeeping ── 'waiver': { tier: 'ask', gate: 'waiver', title: 'a waiver the lint refused', fix: 'fix the finding, or waive it with the rule id and a reason about this page' }, 'house-style': { tier: 'polish', gate: 'waiver', title: 'more than three rules waived on one page', fix: 'lock the choices into DESIGN.md so later pages inherit them instead of re-arguing them' }, diff --git a/test/design-lint-app-contract.test.ts b/test/design-lint-app-contract.test.ts new file mode 100644 index 000000000..64e060a44 --- /dev/null +++ b/test/design-lint-app-contract.test.ts @@ -0,0 +1,386 @@ +/** + * The app profile's contract without a browser (GST-1741 PR 1): usage errors + * before any render, state-file and waiver validation, the verdict and exit + * precedence, URL redaction, private trace files, RENDER_UNAVAILABLE for a + * --url nobody measured, and the scope guard that keeps app rules out of the + * page and component profiles. A fake runner stands in for the browser; the + * measured behavior is test/design-lint-app-profile.test.ts. + */ +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 { parseStateFile, parseViewport, parseWaiverFile, redactText, redactUrl, selectorError, stateNameOf } from '../lib/design-lint/app-input'; +import { appMain, renderUnavailable, type AppCliArgs, type AppDeps } from '../lib/design-lint/app-cli'; +import { lint } from '../lib/design-lint/index'; +import { lintApp, APP_CODES } from '../lib/design-lint/render-app'; +import { appInstallScript, composedWaitExpression } from '../lib/design-lint/render-probe-app'; +import { installScript } from '../lib/design-lint/render-probe'; +import { finalize, Reporter, type Finding } from '../lib/design-lint/report'; +import { RULES } from '../lib/design-lint/rules/ids'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin', 'gstack-design-lint'); +const tmp: string[] = []; +afterAll(() => { for (const d of tmp) fs.rmSync(d, { recursive: true, force: true }); }); +const tmpDir = (): string => { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-app-')); tmp.push(d); return d; }; + +function runBin(args: string[]) { + const r = Bun.spawnSync([BIN, ...args], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 60_000 }); + return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() }; +} + +const APP_IDS = Object.entries(RULES).filter(([, m]) => m.app).map(([id]) => id); +const URL1 = 'http://127.0.0.1:5300/settings?as=owner'; + +interface Fake { setup?: unknown; measure?: (width: number, call: string) => unknown; fail?: string } +const ok = (findings: unknown[] = [], extra: Record = {}) => ({ status: 'measured', matched: 1, roots: 1, lists: 1, findings, ...extra }); +const f = (rule: string, locator: string, measured = 4, threshold = 8) => ({ rule, sentence: 'a 2-line row leaves ' + measured + 'px between its text and its top edge (under ' + threshold + 'px)', locator, measured, threshold }); + +/** A runner that answers every step the scheduler plans, and records each spec it was handed. */ +function fakeRun(fake: Fake, specs: RenderSpec[] = []) { + return async (spec: RenderSpec): Promise => { + specs.push(spec); + if (fake.fail) return { ok: false, outputs: [], evals: {}, stdout: '', error: fake.fail }; + const evals: Record = {}; + spec.steps.forEach((s, i) => { + if (s.kind !== 'eval') return; + if (i === 0) evals[i] = '1'; + else if (s.expression.includes('P.setup(P)')) evals[i] = JSON.stringify(fake.setup ?? { lostSheets: [], lostFonts: [] }); + else evals[i] = JSON.stringify(fake.measure ? fake.measure(s.width ?? 0, s.expression) : ok()); + }); + return { ok: true, outputs: [], evals, stdout: '' }; + }; +} + +const args = (over: Partial = {}): AppCliArgs => ({ paths: [], urls: [URL1], within: '.sharing', viewports: [], verbose: false, json: false, ...over }); + +async function app(over: Partial, deps: AppDeps) { + const out: string[] = []; + const err: string[] = []; + const code = await appMain(args(over), { out: (s) => out.push(s), err: (s) => err.push(s) }, { reach: async () => null, traceDir: tmpDir(), ...deps }); + return { code, out: out.join('\n'), err: err.join('\n') }; +} + +const neverRun = async (): Promise => { throw new Error('the runner must not be called for a usage error'); }; + +describe('app profile contract: usage errors exit 3 before any render (T24, T30)', () => { + test('the bin refuses paths, a missing --within, a bad selector, a bad viewport and both or neither target', () => { + for (const [argv, code] of [ + [['--scope', 'app', 'x.html', '--within', 'main'], 'APP_PATHS'], + [['--scope', 'app', '--url', URL1], 'NO_WITHIN'], + [['--scope', 'app', '--url', URL1, '--within', '.rows['], 'BAD_SELECTOR'], + [['--scope', 'app', '--url', URL1, '--within', 'main', '--wait-for', 'a >'], 'BAD_SELECTOR'], + [['--scope', 'app', '--url', URL1, '--within', 'main', '--viewport', '100x900'], 'BAD_VIEWPORT'], + [['--scope', 'app', '--url', URL1, '--within', 'main', '--viewport', '1440x900x'], 'BAD_VIEWPORT'], + [['--scope', 'app', '--url', URL1, '--within', 'main', '--viewport', '4000x900'], 'BAD_VIEWPORT'], + [['--scope', 'app', '--within', 'main'], 'NO_TARGET'], + [['--scope', 'app', '--url', 'ftp://x.test/p', '--within', 'main'], 'BAD_URL'], + [['--scope', 'app', '--url', URL1, '--state-file', 'x.json', '--within', 'main'], 'BOTH_TARGETS'], + [['--scope', 'app', '--state-file', '/nowhere/states.json', '--within', 'main'], 'BAD_STATE_FILE'], + ] as Array<[string[], string]>) { + const r = runBin(argv); + expect([argv.join(' '), r.code, r.out]).toEqual([argv.join(' '), 3, '']); + expect(r.err).toMatch(new RegExp('^gstack-design-lint: ' + code + ': .+ \\| fix: .+$', 'm')); + } + }); + + test('every app-only flag without --scope app exits 3 naming the flag; --verbose works anywhere', () => { + for (const flag of ['--within', '--viewport', '--wait-for', '--waivers', '--state-file']) { + const r = runBin(['test/fixtures/design-lint/clean', flag, 'x']); + expect([r.code, r.err.trim()]).toEqual([3, 'gstack-design-lint: APP_ONLY: ' + flag + ' only with --scope app | fix: add --scope app, or drop ' + flag]); + } + const plain = runBin(['test/fixtures/design-lint/clean']); + const verbose = runBin(['test/fixtures/design-lint/clean', '--verbose']); + expect([verbose.code, verbose.out]).toEqual([plain.code, plain.out]); + const usage = runBin(['--help']); + expect(usage.out).toContain('gstack-design-lint --scope app (--url ... | --state-file ) --within '); + }); + + test('an invalid selector never reaches the runner', async () => { + const r = await app({ within: 'div > > p' }, { run: neverRun }); + expect(r.code).toBe(3); + expect(r.err).toContain('BAD_SELECTOR'); + }); + + test('selector and viewport grammar', () => { + for (const ok of ['.sharing', 'qm-sharing', 'main .rows > .row:nth-of-type(2)', '[data-x="a]b"]', 'a, b', ':is(.a, .b) li', 'x-y\\:z', '#main']) expect([ok, selectorError(ok)]).toEqual([ok, null]); + for (const bad of ['', '.rows[', 'div >', '> div', 'a,,b', 'a {}', 'p:', '.', '#', '(a', 'a)', 'a !b', '"q"']) expect([bad, selectorError(bad)]).not.toEqual([bad, null]); + expect(parseViewport('375x812')).toEqual({ width: 375, height: 812 }); + expect(parseViewport('3840x2400')).toEqual({ width: 3840, height: 2400 }); + for (const bad of ['319x900', '1440x479', '1440x2401', '14400x900', '1440X900', ' 1440x900', '1440x900.5']) expect(() => parseViewport(bad)).toThrow(/--viewport/); + }); +}); + +describe('app profile contract: state files and waivers (T12, T23, T30, T36)', () => { + test('a state file is validated whole: unknown keys, no url, a bad version or selector, a duplicate name; a non-loopback URL is noted', () => { + const good = parseStateFile(JSON.stringify({ version: 1, states: [{ name: 'owner', url: URL1, expect: '.owner-note' }, { url: 'https://staging.example.test/p?as=reader&token=abc' }] })); + expect(good.states.map((s) => s.name)).toEqual(['owner', '/p?as=reader&token=REDACTED']); + expect(good.notes).toEqual(['state /p?as=reader&token=REDACTED is not on a loopback address (https://staging.example.test/p?as=reader&token=REDACTED)']); + for (const [doc, why] of [ + ['{', 'not JSON'], + [{ version: 1, states: [], extra: 1 }, 'unknown key extra'], + [{ version: 2, states: [{ url: URL1 }] }, 'version must be 1'], + [{ version: 1, states: [] }, 'states is empty'], + [{ version: 1, states: [{ name: 'a' }] }, 'states[0] has no url'], + [{ version: 1, states: [{ url: 'file:///x.html' }] }, 'not an http or https address'], + [{ version: 1, states: [{ url: URL1, wait: 1 }] }, 'states[0] has unknown key wait'], + [{ version: 1, states: [{ url: URL1, expect: 'a >' }] }, 'not a valid selector'], + [{ version: 1, states: [{ url: URL1, name: 'a' }, { url: URL1, name: 'a' }] }, 'two states are named a'], + ] as Array<[unknown, string]>) { + expect(() => parseStateFile(typeof doc === 'string' ? doc : JSON.stringify(doc))).toThrow(why); + } + }); + + test('a bad state file through the bin exits 3 with its reason', () => { + const dir = tmpDir(); + fs.writeFileSync(path.join(dir, 's.json'), JSON.stringify({ version: 1, states: [{ url: URL1, color: 'red' }] })); + const r = runBin(['--scope', 'app', '--state-file', path.join(dir, 's.json'), '--within', 'main']); + expect([r.code, r.err.trim()]).toEqual([3, expect.stringMatching(/^gstack-design-lint: BAD_STATE_FILE: --state-file: states\[0\] has unknown key color \| fix: /)]); + }); + + test('a waiver file names an app rule, a locator and a reason of 8 characters or more; anything else exits 3', async () => { + const isApp = (id: string) => !!RULES[id]?.app; + expect(parseWaiverFile(JSON.stringify({ version: 1, waivers: [{ rule: 'small-hit-area', locator: 'button.x', reason: 'the glyph sits in a 44px label target' }] }), isApp)).toHaveLength(1); + for (const [w, why] of [ + [{ rule: 'low-contrast', locator: 'a', reason: 'long enough reason' }, 'is not an app rule'], + [{ rule: 'nope', locator: 'a', reason: 'long enough reason' }, 'is not an app rule'], + [{ rule: 'small-hit-area', locator: 'a', reason: 'short' }, '8 characters or more'], + [{ rule: 'small-hit-area', locator: ' ', reason: 'long enough reason' }, 'locator must be'], + [{ rule: 'small-hit-area', locator: 'a', reason: 'long enough reason', page: 'x' }, 'unknown key page'], + ] as Array<[unknown, string]>) expect(() => parseWaiverFile(JSON.stringify({ version: 1, waivers: [w] }), isApp)).toThrow(why); + const dir = tmpDir(); + fs.writeFileSync(path.join(dir, 'w.json'), JSON.stringify({ version: 1, waivers: [{ rule: 'row-crowding', locator: 'a', reason: 'tiny' }] })); + const r = await app({ waivers: path.join(dir, 'w.json') }, { run: neverRun }); + expect([r.code, r.err]).toEqual([3, expect.stringContaining('BAD_WAIVERS')]); + }); + + test('a matched waiver prints at waived with its reason and leaves the verdict clean; an unused one is a waiver finding (T6, T23, T28, T35)', async () => { + const dir = tmpDir(); + const file = path.join(dir, 'w.json'); + fs.writeFileSync(file, JSON.stringify({ version: 1, waivers: [ + { rule: 'small-hit-area', locator: 'button.remove', reason: 'the remove glyph sits inside a 44px label target' }, + { rule: 'row-crowding', locator: 'div.never', reason: 'kept to show an unused waiver' }, + ] })); + const run = fakeRun({ measure: (w) => ok(w === 375 ? [f('small-hit-area', 'qm-x >>> div.row > button.remove "Remove Dana"', 18, 32)] : []) }); + const r = await app({ waivers: file }, { run }); + expect(r.code).toBe(0); + expect(r.out).toMatch(/^LINT: waived small-hit-area .+ \| fix: waived \(the remove glyph sits inside a 44px label target\)$/m); + expect(r.out).toMatch(/^LINT: ask waiver --waivers:2 waiver on row-crowding at "div\.never" matched no finding \(unused\) \| fix: /m); + expect(r.out).toMatch(/^LINT_APP: verdict=clean code=OK states=1\/1 viewports=1440x900,375x812 within=\.sharing matched=1 open=0 waived=1$/m); + }); +}); + +describe('app profile contract: verdict, codes and exit precedence (T8, T15, T16, T19, T27)', () => { + test('clean is 0 only on complete coverage with nothing open; any unwaived app finding is open (1) whatever its tier', async () => { + const clean = await app({}, { run: fakeRun({}) }); + expect([clean.code, clean.out.split('\n').slice(-1)[0]]).toEqual([0, 'LINT_APP: verdict=clean code=OK states=1/1 viewports=1440x900,375x812 within=.sharing matched=1 open=0 waived=0']); + const open = await app({ json: true }, { run: fakeRun({ measure: () => ok([f('row-crowding', 'div.rows > div.row:nth-of-type(1) "Deploy key"')]) }) }); + expect(open.code).toBe(1); + const json = JSON.parse(open.out); + expect(json.app).toMatchObject({ verdict: 'open', code: 'OPEN', open: 2, waived: 0, codes: [] }); + expect(json.app.states.map((s: { viewport: string; status: string; code: string }) => [s.viewport, s.status, s.code])).toEqual([['1440x900', 'measured', 'OK'], ['375x812', 'measured', 'OK']]); + expect(json.summary.ask).toBe(2); + }); + + test('incomplete wins over open and is never printed clean: one viewport not measured exits 4', async () => { + const run = fakeRun({ measure: (w) => (w === 375 ? { status: 'incomplete', code: 'NOT_READY', detail: 'no list', matched: 1, roots: 1, lists: 0, findings: [] } : ok([f('row-crowding', 'div.row "A"')])) }); + const r = await app({}, { run }); + expect(r.code).toBe(4); + expect(r.out).toMatch(/^LINT_APP_STATE: state=\/settings\?as=owner viewport=375x812 status=incomplete code=NOT_READY matched=1 no list \| fix: pass --wait-for .+ \(docs\/design-lint\.md#app-code-not_ready\)$/m); + expect(r.out).toMatch(/^LINT_APP: verdict=incomplete code=NOT_READY states=0\/1 .* open=1 waived=0$/m); + }); + + test('several codes report the first in the documented order and list them all', async () => { + const result = await lintApp({ + states: [{ name: 'a', url: 'http://127.0.0.1:1/a' }, { name: 'b', url: URL1 }, { name: 'c', url: 'http://127.0.0.1:5300/c' }], + within: 'main', traceDir: tmpDir(), + reach: async (u) => (u.endsWith('/a') ? 'connection refused' : null), + run: fakeRun({ measure: (w, call) => (call.includes('"expect":null') && w === 1440 ? { status: 'incomplete', code: 'STATE_MISMATCH', matched: 1, roots: 1, lists: 1, findings: [] } : { error: 'TypeError: x is not a function', stack: 'TypeError: x\n at http://127.0.0.1:5300/app.js?token=SECRET:1:1' }) }), + }); + expect(result.exitCode).toBe(4); + expect(result.app.code).toBe('UNREACHABLE'); + expect(result.app.codes).toEqual(['UNREACHABLE', 'STATE_MISMATCH', 'PROBE_ERROR']); + expect(APP_CODES.indexOf('UNREACHABLE')).toBeLessThan(APP_CODES.indexOf('PROBE_ERROR')); + }); + + test('a failed render maps to its code: no browser, timeout, the --wait-for never matching, anything else unreachable', async () => { + const codeOf = async (fail: string, waitFor?: string) => (await lintApp({ states: [{ name: 's', url: URL1 }], within: 'main', waitFor, reach: async () => null, run: fakeRun({ fail }) })).app.code; + expect(await codeOf('no browser available: run ./setup')).toBe('NO_BROWSER'); + expect(await codeOf('aside repl did not run: timed out after 130000ms')).toBe('TIMEOUT'); + expect(await codeOf('waitFor expression never became truthy: (() => …)() (waited 15000ms)', '.ready')).toBe('NOT_READY'); + expect(await codeOf('browse goto failed: net::ERR_CONNECTION_REFUSED')).toBe('UNREACHABLE'); + }); + + test('a stylesheet that did not load is ASSET_MISSING; a font that did not load is a note and the state stays measured', async () => { + const sheet = await lintApp({ states: [{ name: 's', url: URL1 }], within: 'main', reach: async () => null, run: fakeRun({ setup: { lostSheets: ['app.css'], lostFonts: [] } }) }); + expect([sheet.app.code, sheet.app.states[0].detail]).toEqual(['ASSET_MISSING', 'stylesheets did not load: app.css']); + const font = await lintApp({ states: [{ name: 's', url: URL1 }], within: 'main', reach: async () => null, run: fakeRun({ setup: { lostSheets: [], lostFonts: ['Inter'] } }) }); + expect([font.app.verdict, font.app.notes]).toEqual(['clean', ['s: fonts did not load (Inter); measured with a fallback face']]); + }); + + test('a probe answer the lint does not know is PROBE_ERROR, never a finding: a page rule, an unknown code, a malformed list', async () => { + for (const answer of [ok([f('low-contrast', 'p')]), ok([{ rule: 'row-crowding' }]), { status: 'incomplete', code: 'FINE', matched: 1, findings: [] }, [1, 2], 'x']) { + const r = await lintApp({ states: [{ name: 's', url: URL1 }], within: 'main', reach: async () => null, run: fakeRun({ measure: () => answer }) }); + expect([JSON.stringify(answer), r.app.code, r.findings]).toEqual([JSON.stringify(answer), 'PROBE_ERROR', []]); + } + }); +}); + +describe('app profile contract: the plan handed to the browser (T2, T25, T32)', () => { + test('one render per state: the app install, setup, one measure per viewport at its size; readiness waits on the section through shadow roots', async () => { + const specs: RenderSpec[] = []; + await lintApp({ states: [{ name: 's', url: URL1, expect: '.owner' }], within: 'qm-sharing', viewports: [{ width: 1280, height: 800 }, { width: 390, height: 844 }], reach: async () => null, run: fakeRun({}, specs) }); + expect(specs).toHaveLength(1); + const [spec] = specs; + expect(spec.file).toBe(URL1); + expect(spec.steps.map((s) => (s.kind === 'eval' ? [s.width, s.height, s.expression.includes('P.setup(P)') ? 'setup' : s.expression.includes('P.measure(P') ? 'measure' : 'install'] : s.kind))).toEqual([[1280, 800, 'install'], [1280, 800, 'setup'], [1280, 800, 'measure'], [390, 844, 'measure']]); + const first = spec.steps[0]; + expect(first.kind === 'eval' && first.expression).toBe(appInstallScript()); + const measure = spec.steps[2]; + expect(measure.kind === 'eval' && measure.expression).toContain('"within":"qm-sharing","expect":".owner","explicitWait":false,"readyMs":5000'); + expect(spec.waitFor?.selector).toBeUndefined(); + expect(spec.waitFor?.timeoutMs).toBe(16_000); + expect(spec.waitFor?.expression).toContain('"qm-sharing"'); + expect(spec.waitFor?.expression).toContain('shadowRoot'); + expect(spec.waitFor?.expression).toContain('> 15000'); + const explicit: RenderSpec[] = []; + await lintApp({ states: [{ name: 's', url: URL1 }], within: 'main', waitFor: '.loaded', reach: async () => null, run: fakeRun({}, explicit) }); + expect(explicit[0].waitFor?.timeoutMs).toBe(15_000); + expect(explicit[0].waitFor?.expression).not.toContain('> 15000'); + expect(explicit[0].steps[2].kind === 'eval' && explicit[0].steps[2].expression).toContain('"explicitWait":true'); + }); + + test('the scripted variants build the same Sharing section the static replica holds', () => { + const sandbox: { SHARING_HTML?: string } = {}; + new Function('window', fs.readFileSync(path.join(ROOT, 'test/fixtures/ui-spacing/sharing.js'), 'utf8'))(sandbox); + expect(sandbox.SHARING_HTML!.length).toBeGreaterThan(1000); + expect(fs.readFileSync(path.join(ROOT, 'test/fixtures/ui-spacing/replica.html'), 'utf8')).toContain(sandbox.SHARING_HTML!); + }); + + test('the page probes are untouched: the page install script carries no app probe, and the app install no page probe', () => { + expect(installScript()).not.toContain('__gstackLintApp'); + expect(appInstallScript()).not.toContain('__gstackLint ='); + expect(appInstallScript()).toContain('window.__gstackLintApp = { H: ('); + }); +}); + +describe('app profile contract: findings, redaction and traces (T22, T29, T34, T19)', () => { + test('findings keep state, viewport, locator, measured px and threshold whole; distinct rows and viewports are never merged', async () => { + const long = 'qm-panel >>> ' + Array.from({ length: 12 }, (_, i) => 'div.level-' + i).join(' > ') + ' > div.row:nth-of-type(2) "Deploy key Read-only access to the repo"'; + const run = fakeRun({ measure: () => ok([f('row-crowding', long), f('row-crowding', long.replace('nth-of-type(2)', 'nth-of-type(3)'))]) }); + const r = await lintApp({ states: [{ name: 'owner', url: URL1 }], within: 'main', reach: async () => null, run }); + const rows = r.findings.filter((x) => x.rule === 'row-crowding'); + expect(rows).toHaveLength(4); + expect(rows[0].app).toEqual({ state: 'owner', viewport: '1440x900', locator: long, measured: 4, threshold: 8 }); + expect(rows[0].evidence).toBe('[owner 1440px] a 2-line row leaves 4px between its text and its top edge (under 8px) at ' + long); + expect(new Set(rows.map((x) => x.app!.viewport + x.app!.locator)).size).toBe(4); + const dup: Finding = { ...rows[0] }; + expect(finalize([rows[0], dup])).toHaveLength(1); + }); + + test('credentials in a URL never reach text, JSON, stderr or a trace; navigation and the reachability check use the real URL', async () => { + const userinfo = ['dana', 'PWSENTINEL'].join(':') + '@'; + const secret = 'http://' + userinfo + '127.0.0.1:5300/settings?as=owner&token=TOKSENTINEL&sig=SIGSENTINEL&session_id=SESSENTINEL'; + const reached: string[] = []; + const specs: RenderSpec[] = []; + const run = fakeRun({ measure: (w) => (w === 375 ? { error: 'Error: boom at ' + secret, stack: 'Error: boom\n at ' + secret + ':1:1' } : ok([f('row-crowding', 'div.row "x"')])) }, specs); + const traceDir = tmpDir(); + for (const json of [false, true]) { + const out: string[] = []; + const err: string[] = []; + await appMain(args({ urls: [secret], json, verbose: true }), { out: (s) => out.push(s), err: (s) => err.push(s) }, { run, traceDir, reach: async (u) => { reached.push(u); return null; } }); + const all = out.join('\n') + err.join('\n') + fs.readdirSync(traceDir).map((t) => fs.readFileSync(path.join(traceDir, t), 'utf8')).join(''); + for (const s of ['PWSENTINEL', 'TOKSENTINEL', 'SIGSENTINEL', 'SESSENTINEL', 'dana:']) expect([json, s, all.includes(s)]).toEqual([json, s, false]); + expect(all).toContain('token=REDACTED'); + } + expect(specs.map((s) => s.file)).toEqual([secret, secret]); + expect(reached).toEqual([secret, secret]); + expect(redactUrl('https://' + ['u', 'p'].join(':') + '@h.test/a?code=1&next=/b')).toBe('https://h.test/a?code=REDACTED&next=%2Fb'); + expect(redactText('see http://h.test/?jwt=abc and https://k.test/x?api_key=1')).toBe('see http://h.test/?jwt=REDACTED and https://k.test/x?api_key=REDACTED'); + expect(stateNameOf('http://h.test/p?as=owner&password=x')).toBe('/p?as=owner&password=REDACTED'); + expect(redactUrl('http://127.0.0.1:5300/cb#access_token=FRAGSENTINEL&state=x')).toBe('http://127.0.0.1:5300/cb#access_token=REDACTED&state=x'); + expect(redactText('at http://h.test/#id_token=FRAGSENTINEL')).toBe('at http://h.test/#id_token=REDACTED'); + expect(redactUrl('http://h.test/p#rows')).toBe('http://h.test/p#rows'); + const hashed = await app({ urls: ['http://127.0.0.1:5300/cb#access_token=FRAGSENTINEL'], json: true }, { run: fakeRun({ measure: () => ok([f('row-crowding', 'div.row "x"')]) }) }); + expect(hashed.out + hashed.err).not.toContain('FRAGSENTINEL'); + }); + + test('two --url states that share a redacted name stay two states; the same URL twice is DUPLICATE_STATE', async () => { + const seen: string[] = []; + const two = await app({ urls: ['http://127.0.0.1:5300/p?session=owner', 'http://127.0.0.1:5300/p?session=viewer'] }, { run: async (spec) => { seen.push(spec.file); return fakeRun({})(spec); } }); + expect(two.code).toBe(0); + expect(seen).toEqual(['http://127.0.0.1:5300/p?session=owner', 'http://127.0.0.1:5300/p?session=viewer']); + expect(two.out).toMatch(/^LINT_APP: verdict=clean code=OK states=2\/2 /m); + expect(two.out).toContain('state=/p?session=REDACTED~2 viewport=1440x900'); + const literal = await app({ urls: ['http://127.0.0.1:5300/p', 'http://127.0.0.1:5300/p#x', 'http://127.0.0.1:5300/p~2'] }, { run: fakeRun({}) }); + expect(literal.out).toMatch(/^LINT_APP: verdict=clean code=OK states=3\/3 /m); + expect([...new Set(literal.out.split('\n').filter((l) => l.startsWith('LINT_APP_STATE:')).map((l) => l.split(' ')[1]))]).toEqual(['state=/p', 'state=/p~2', 'state=/p~2~2']); + const dup = await app({ urls: [URL1, URL1] }, { run: neverRun }); + expect([dup.code, dup.err]).toEqual([3, 'gstack-design-lint: DUPLICATE_STATE: --url ' + URL1 + ' is given twice | fix: measure each URL once']); + }); + + test('a probe that throws writes a 0600 trace under a private directory, names it, and prints its stack only with --verbose', async () => { + const traceDir = path.join(tmpDir(), 'traces'); + const run = fakeRun({ measure: (w) => (w === 375 ? { error: 'TypeError: rows is undefined', stack: 'TypeError: rows is undefined\n at glAppRows (eval:1:2)' } : ok()) }); + const quiet = await app({}, { run, traceDir }); + expect(quiet.code).toBe(4); + const [trace] = fs.readdirSync(traceDir).map((t) => path.join(traceDir, t)); + if (process.platform !== 'win32') { + expect(fs.statSync(trace).mode & 0o777).toBe(0o600); + expect(fs.statSync(traceDir).mode & 0o777).toBe(0o700); + } + expect(JSON.parse(fs.readFileSync(trace, 'utf8'))).toEqual({ probe: 'measure', state: '/settings?as=owner', viewport: '375x812', stack: 'TypeError: rows is undefined\n at glAppRows (eval:1:2)', url: URL1 }); + expect(quiet.out).toContain('code=PROBE_ERROR matched=0 the measure probe failed: TypeError: rows is undefined (trace: ' + trace + ')'); + expect(quiet.err).toBe('gstack-design-lint: PROBE_ERROR: the measure probe threw at /settings?as=owner 375x812; trace ' + trace); + const loud = await app({ verbose: true }, { run, traceDir: path.join(tmpDir(), 'traces') }); + expect(loud.err).toContain('at glAppRows (eval:1:2)'); + const blocker = path.join(tmpDir(), 'not-a-dir'); + fs.writeFileSync(blocker, ''); + const unwritable = await app({ verbose: true }, { run, traceDir: path.join(blocker, 'traces') }); + expect(unwritable.code).toBe(4); + expect(unwritable.out).toMatch(/^LINT_APP: verdict=incomplete code=PROBE_ERROR /m); + expect(unwritable.err).toContain('gstack-design-lint: PROBE_ERROR: the measure probe threw at /settings?as=owner 375x812; no trace written ('); + }); + + test('a section selector the browser refuses never holds readiness: the soft wait goes on to the probe, an explicit --wait-for stays not ready', async () => { + const refuses = { querySelector: () => { throw new SyntaxError("'main:foo' is not a valid selector"); }, querySelectorAll: () => [] }; + const evaluate = (expression: string) => new Function('document', 'window', 'return ' + expression)(refuses, {}); + expect(evaluate(composedWaitExpression('main:foo', 15_000))).toBe(true); + expect(evaluate(composedWaitExpression('main:foo', null))).toBe(false); + const late = await lintApp({ states: [{ name: 's', url: URL1 }], within: 'main', reach: async () => null, run: fakeRun({ fail: 'waitFor expression never became truthy: x (waited 16000ms)' }) }); + expect([late.app.code, late.app.states[0].detail]).toEqual(['NOT_READY', 'the section main did not match within 15s']); + }); +}); + +describe('app profile contract: no browser and the scope guard (T15, T20, T33)', () => { + const noBrowser = () => ({ engine: null, probe: { ok: false as const, reason: 'NEEDS_ASIDE' as const, detail: 'test' }, error: 'no browser available: run ./setup (NEEDS_ASIDE: test)' }); + + test('--url with no browser under page scope: RENDER_UNAVAILABLE names the redacted URL; --render over files keeps today\'s skip', async () => { + const url = 'http://127.0.0.1:5300/p?token=SECRET'; + const r = await lint([], { urls: [url], engine: noBrowser, warn: () => {} }); + expect(r.summary.render).toBe('skipped: no browser available: run ./setup (NEEDS_ASIDE: test)'); + expect(renderUnavailable([url], r.summary.render)).toBe('gstack-design-lint: RENDER_UNAVAILABLE: nothing was measured at http://127.0.0.1:5300/p?token=REDACTED (no browser available: run ./setup (NEEDS_ASIDE: test)) | fix: gstack-browser-ensure, then rerun'); + const files = await lint([path.join(ROOT, 'test/fixtures/design-lint/clean')], { cwd: ROOT, render: true, engine: noBrowser, warn: () => {} }); + expect(renderUnavailable([], files.summary.render)).toBeNull(); + expect(files.exitCode).toBe(0); + expect(renderUnavailable([url], 'ran')).toBeNull(); + }); + + test('--scope app with no browser: every state x viewport NO_BROWSER, exit 4 and RENDER_UNAVAILABLE on stderr', async () => { + const r = await app({}, { engine: noBrowser }); + expect(r.code).toBe(4); + expect(r.err).toBe('gstack-design-lint: RENDER_UNAVAILABLE: nothing was measured at ' + URL1 + ' (no browser available: run ./setup (NEEDS_ASIDE: test)) | fix: gstack-browser-ensure, then rerun'); + expect(r.out).toMatch(/LINT_SUMMARY: blocks=0 ask=0 waived=0 polish=0 render=skipped: no browser available/); + expect(r.out).toMatch(/^LINT_APP: verdict=incomplete code=NO_BROWSER states=0\/1 /m); + }); + + test('app rules are app-only: tier ask, render, never reported by a page or component reporter; nothing else carries the marker', () => { + expect(APP_IDS.sort()).toEqual(['copy-flush-under-list', 'note-in-row-weight', 'row-crowding', 'section-overflow', 'small-hit-area']); + for (const id of APP_IDS) expect([id, RULES[id].tier, RULES[id].render]).toEqual([id, 'ask', true]); + for (const scope of ['page', 'component'] as const) expect(new Reporter(scope).report('row-crowding', 'x.html', 1, 'e')).toBeNull(); + expect(new Reporter('app').report('row-crowding', 'x.html', 1, 'e')?.rule).toBe('row-crowding'); + }); + +}); diff --git a/test/design-lint-app-profile.test.ts b/test/design-lint-app-profile.test.ts new file mode 100644 index 000000000..020202468 --- /dev/null +++ b/test/design-lint-app-profile.test.ts @@ -0,0 +1,247 @@ +/** + * The app profile measured (gstack-design-lint --scope app, GST-1741 PR 1): + * the qm#3186 replica and its variants (other markup, open, slotted, nested and + * closed shadow roots, rows that load late), the fixed copy, the negative + * controls, the threshold boundaries at +-0.5px, and the false-positive + * corpus. Fixtures are served from a loopback server, so every state goes + * through the URL path the CLI takes. Skipped with a named reason when no + * browser is available, except in CI, where a missing browser fails. + */ +import { afterAll, beforeAll, 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 { pickEngine, resolveBrowseBin, type EngineChoice } from '../lib/aside-render'; +import { lintApp, type AppOptions, type AppResult } from '../lib/design-lint/render-app'; +import { asideAvailable } from './helpers/aside-available'; +import { browserAvailable, NO_BROWSER_REASON } from './helpers/browser-available'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const FIX = 'test/fixtures/ui-spacing'; +const hasBrowser = browserAvailable(); +if (!hasBrowser && process.env.CI) throw new Error('design-lint-app-profile: CI needs a browser for the app profile tier: ' + NO_BROWSER_REASON); +if (!hasBrowser) process.stderr.write('design-lint-app-profile: SKIPPED — ' + NO_BROWSER_REASON + '\n'); + +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 }; +}; + +const FOUR = ['copy-flush-under-list', 'note-in-row-weight', 'row-crowding', 'small-hit-area']; +const gen = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-app-gen-')); +let base = ''; +let server: ReturnType | null = null; + +beforeAll(() => { + if (!hasBrowser) return; + server = Bun.serve({ + hostname: '127.0.0.1', port: 0, + fetch(req) { + const rel = decodeURIComponent(new URL(req.url).pathname); + const file = rel.startsWith('/gen/') ? path.join(gen, rel.slice(5)) : path.join(ROOT, rel); + const inside = file.startsWith(gen + path.sep) || file.startsWith(ROOT + path.sep); + if (!inside || !fs.existsSync(file) || !fs.statSync(file).isFile()) return new Response('not found', { status: 404 }); + return new Response(Bun.file(file)); + }, + }); + base = 'http://127.0.0.1:' + server.port; +}); +afterAll(() => { server?.stop(true); fs.rmSync(gen, { recursive: true, force: true }); }); + +async function measure(page: string, within: string, more: Partial = {}): Promise { + const url = page.startsWith('/') ? base + page : base + '/' + FIX + '/' + page; + const r = await lintApp({ states: [{ name: page, url }], within, engine, traceDir: gen, ...more }); + return { ...r, ids: [...new Set(r.findings.filter((f) => f.grade !== 'waived').map((f) => f.rule))].sort() }; +} + +const genPage = (name: string, css: string, body: string): string => { + fs.writeFileSync(path.join(gen, name), '' + name + '
' + body + '
'); + return '/gen/' + name; +}; + +describe.skipIf(!hasBrowser)('app profile: the replica and its variants', () => { + test('the replica reports exactly row-crowding, copy-flush-under-list, note-in-row-weight and small-hit-area; open, exit 1', async () => { + const r = await measure('replica.html', '.sharing'); + expect(r.ids).toEqual(FOUR); + expect([r.app.verdict, r.exitCode, r.app.matched, r.app.states.map((s) => s.status)]).toEqual(['open', 1, 1, ['measured', 'measured']]); + const crowd = r.findings.find((f) => f.rule === 'row-crowding')!; + expect(crowd.evidence).toMatch(/^\[replica\.html 1440px\] a 2-line row leaves 4px between its text and its top edge \(under 8px\) at .*div\.rows:nth-of-type\(1\) > div\.row:nth-of-type\(1\) "Deploy key Read-only access to the repos"$/); + expect(crowd.app).toMatchObject({ state: 'replica.html', viewport: '1440x900', measured: 4, threshold: 8 }); + const hit = r.findings.find((f) => f.rule === 'small-hit-area')!; + expect([hit.app!.viewport, hit.app!.measured, hit.app!.threshold, hit.app!.locator]).toEqual(['375x812', 18, 32, expect.stringContaining('button.x "Remove')]); + expect(r.findings.filter((f) => f.rule === 'small-hit-area').every((f) => f.app!.viewport === '375x812')).toBe(true); + expect(r.findings.filter((f) => f.rule === 'copy-flush-under-list').map((f) => f.app!.measured)).toEqual([4, 4, 4, 4, 4, 4]); + expect(new Set(r.findings.map((f) => f.app!.viewport + f.app!.locator + f.rule)).size).toBe(r.findings.length); + }, 60_000); + + test('the fixed copy is clean, exit 0', async () => { + const r = await measure('fixed.html', '.sharing'); + expect([r.findings, r.app.verdict, r.exitCode]).toEqual([[], 'clean', 0]); + }, 60_000); + + test("the replica's defects in other markup (ul/li, a grid per row, renamed classes, no test hooks) give the same four ids", async () => { + expect(fs.readFileSync(path.join(ROOT, FIX, 'structural.html'), 'utf8')).not.toContain('data-testid'); + expect((await measure('structural.html', '.settings-sharing')).ids).toEqual(FOUR); + }, 60_000); + + test('open, slotted and nested shadow roots match the light DOM; a closed one is incomplete CLOSED_SHADOW, exit 4', async () => { + for (const [page, within] of [['shadow-open.html', 'qm-sharing'], ['shadow-slotted.html', 'qm-card'], ['nested-shadow.html', '.sharing']]) { + const r = await measure(page, within); + expect([page, r.ids, r.app.verdict]).toEqual([page, FOUR, 'open']); + } + const open = await measure('shadow-open.html', 'qm-sharing'); + expect(open.findings[0].app!.locator).toStartWith('qm-sharing >>> '); + const closed = await measure('shadow-closed.html', 'qm-sharing'); + expect([closed.app.verdict, closed.app.code, closed.exitCode, closed.findings]).toEqual(['incomplete', 'CLOSED_SHADOW', 4, []]); + }, 120_000); + + test('rows that load 800ms late are measured once ready and match the replica; rows that never load are NOT_READY', async () => { + const late = await measure('async.html', '.sharing'); + expect([late.ids, late.app.verdict]).toEqual([FOUR, 'open']); + const replica = await measure('replica.html', '.sharing'); + expect(late.findings.map((f) => f.rule + f.app!.viewport + f.app!.measured)).toEqual(replica.findings.map((f) => f.rule + f.app!.viewport + f.app!.measured)); + const never = await measure('never-ready.html', '.sharing', { readyMs: 1_500 }); + expect([never.app.code, never.exitCode, never.app.states[0].detail]).toEqual(['NOT_READY', 4, 'no list of two or more rows appeared inside .sharing within 2s']); + }, 90_000); + + test('a section nothing matches is WITHIN_NO_MATCH naming the roots searched; a state whose expect selector is absent is STATE_MISMATCH', async () => { + const none = await measure('shadow-open.html', '.no-such-section', { sectionWaitMs: 1_000, readyMs: 500 }); + expect([none.app.code, none.app.matched, none.app.states[0].detail]).toEqual(['WITHIN_NO_MATCH', 0, 'nothing matched .no-such-section in the document or its 1 open shadow roots']); + const url = base + '/' + FIX + '/replica.html?as=lastowner'; + const wrong = await lintApp({ states: [{ name: 'lastowner', url, expect: '.only-owner-note' }], within: '.sharing', engine, readyMs: 1_000 }); + expect([wrong.app.code, wrong.exitCode]).toEqual(['STATE_MISMATCH', 4]); + const right = await lintApp({ states: [{ name: 'owner', url, expect: '.sharing .row' }], within: '.sharing', engine }); + expect(right.app.verdict).toBe('open'); + }, 90_000); +}); + +describe.skipIf(!hasBrowser)('app profile: what must not fire', () => { + test('plain role labels beside names are never notes; a note louder than its name is', async () => { + const reader = await measure('reader.html', '.members'); + expect(reader.findings.filter((f) => f.rule === 'note-in-row-weight')).toEqual([]); + expect(reader.app.verdict).toBe('clean'); + expect((await measure('larger-note.html', '.integrations')).ids).toEqual(['note-in-row-weight']); + }, 60_000); + + test('a list of one row is not a list; a three-line note on a phone only is not note-in-row-weight', async () => { + const one = await measure('one-row.html', '.credentials', { waitFor: '.credentials' }); + expect([one.findings, one.app.verdict]).toEqual([[], 'clean']); + const phone = await measure('mobile-note.html', '.channels'); + expect([phone.findings, phone.app.verdict]).toEqual([[], 'clean']); + }, 60_000); + + test('a defect outside --within is not reported; the same section measured directly is', async () => { + const page = genPage('outside.html', '.list { border: 1px solid #dfe3ea } .row { padding: 3px 12px } .row + .row { border-top: 1px solid #dfe3ea } .row p { margin: 0 } .row p + p { color: #6b7385; font-size: 13px } .ok .row { padding: 10px 12px } .ok + .bad { margin-top: 24px }', + '

Deploy key

Read-only access

Registry token

Pushes images

' + + '

Deploy key

Read-only access

Registry token

Pushes images

'); + const inside = await measure(page, '.ok'); + expect([inside.findings, inside.app.verdict]).toEqual([[], 'clean']); + expect((await measure(page, '.bad')).ids).toEqual(['row-crowding']); + }, 60_000); + + test('small-hit-area counts an input\'s label toward its box and skips a link inside a run of text; a bare 16px checkbox fires', async () => { + const page = genPage('targets.html', '.list { border: 1px solid #dfe3ea } .row { display: flex; gap: 8px; align-items: center; padding: 10px 12px } .row + .row { border-top: 1px solid #dfe3ea } .row p { margin: 0 } label.big { display: flex; align-items: center; gap: 8px; min-height: 40px } input { width: 16px; height: 16px; margin: 0 }', + '
' + + '

Read the sharing policy before adding people.

' + + '

Mute

'); + const r = await measure(page, '.probe'); + expect(r.findings.map((f) => [f.rule, f.app!.viewport, f.app!.locator])).toEqual([['small-hit-area', '375x812', expect.stringMatching(/input "Mute"$/)]]); + }, 60_000); + + test('negative controls with trigger bait: a nav list, a table, a card grid and a list spaced right give zero app findings', async () => { + const r = await measure('negatives.html', '.negatives'); + expect([r.findings, r.app.verdict, r.exitCode]).toEqual([[], 'clean', 0]); + }, 60_000); +}); + +describe.skipIf(!hasBrowser)('app profile: thresholds at +-0.5px', () => { + const rows = (pad: string, css = '') => genPage('rows-' + pad.replace(/\W/g, '_') + css.length + '.html', + '.list { border: 1px solid #dfe3ea } .row + .row { border-top: 1px solid #dfe3ea } .row { padding: ' + pad + ' } .row p { margin: 0 } .help { margin: 0 } button { width: 40px; height: 40px } ' + css, + '

Deploy key

Read-only access to the repository

Registry token

Pushes release images

Credentials are encrypted at rest.

'); + const at = async (page: string, rule: string) => (await measure(page, '.probe')).findings.filter((f) => f.rule === rule).map((f) => f.app!.measured); + + test('row-crowding: 7.5px to a drawn edge fires, 8.5px does not', async () => { + expect([...new Set(await at(rows('7.5px 12px'), 'row-crowding'))]).toEqual([7.5]); + expect(await at(rows('8.5px 12px'), 'row-crowding')).toEqual([]); + }, 60_000); + + test('row-crowding: layout subpixel noise never fires (7.97px does not), 7.9px does', async () => { + expect(await at(rows('7.97px 12px'), 'row-crowding')).toEqual([]); + expect((await at(rows('7.9px 12px'), 'row-crowding')).length).toBeGreaterThan(0); + }, 60_000); + + test('row-crowding reads an unboxed list of laid-out rows (avatar, text and control side by side); an unboxed list of prose items stays prose', async () => { + const people = genPage('people-unboxed.html', + '.row { display: flex; align-items: center; gap: 10px; padding: 4px 0 } .av { width: 28px; height: 28px; border-radius: 14px; background: #dbe7f0; flex: none } .text { flex: 1 } .text div { margin: 0 } button { width: 80px; height: 32px; flex: none } .help { margin: 24px 0 0 }', + '
Dana Whitfield
Can\'t use personas until an admin turns them on
Design team
Personas is off for the organization

Owners change who has access.

'); + const crowded = await at(people, 'row-crowding'); + expect(crowded.length).toBeGreaterThan(0); + expect(crowded.every((m) => m < 16)).toBe(true); + const prose = genPage('prose-unboxed.html', 'ul { margin: 0; padding-left: 20px } li { margin: 0 0 4px } .help { margin: 24px 0 0 }', + '
  • Read the docs before you start, then run the setup script once on each machine you use
  • Keep the token out of the repository and rotate it when someone leaves the team

Questions go to the platform channel.

'); + expect(await at(prose, 'row-crowding')).toEqual([]); + }, 60_000); + + test('row-crowding: 15.5px from a two-line row\'s text to the next row\'s fires, 16.5px does not', async () => { + const pair = (next: number) => genPage('next-' + next + '.html', + '.list { border: 1px solid #dfe3ea } .row { padding: 8px 12px } .row p { margin: 0 } .row + .row { padding-top: ' + next + 'px }', + '

Deploy key

Read-only access to the repository

Registry token

'); + expect(await at(pair(7.5), 'row-crowding')).toEqual([15.5, 15.5]); + expect(await at(pair(8.5), 'row-crowding')).toEqual([]); + }, 60_000); + + test('copy-flush-under-list: 11.5px fires, 12.5px does not', async () => { + const help = (gap: number) => genPage('help-' + gap + '.html', + '.list { border: 1px solid #dfe3ea } .row { padding: 10px 12px } .row + .row { border-top: 1px solid #dfe3ea } .row p { margin: 0 } .help { margin: ' + gap + 'px 0 0 }', + '

Deploy key

Registry token

Credentials are encrypted at rest.

'); + expect(await at(help(11.5), 'copy-flush-under-list')).toEqual([11.5, 11.5]); + expect(await at(help(12.5), 'copy-flush-under-list')).toEqual([]); + }, 60_000); + + test('small-hit-area at 375 only: 31.5px fires, 32.5px does not', async () => { + const hit = (side: number) => genPage('hit-' + side + '.html', + '.list { border: 1px solid #dfe3ea } .row { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px } .row + .row { border-top: 1px solid #dfe3ea } .row p { margin: 0 } button { width: ' + side + 'px; height: 40px; padding: 0 }', + '

Deploy key

Registry token

Credentials are encrypted at rest.

'); + const small = (await measure(hit(31.5), '.probe')).findings; + expect(small.map((f) => [f.rule, f.app!.viewport, f.app!.measured])).toEqual([['small-hit-area', '375x812', 31.5], ['small-hit-area', '375x812', 31.5]]); + expect(await at(hit(32.5), 'small-hit-area')).toEqual([]); + }, 60_000); +}); + +describe.skipIf(!hasBrowser)('app profile: the false-positive corpus (decision 6, T10)', () => { + test('zero app findings on all pages but one, and at most one on that page', async () => { + const corpus = JSON.parse(fs.readFileSync(path.join(ROOT, FIX, 'corpus.json'), 'utf8')) as { pages: Array<{ path: string; within: string; waitFor: string }> }; + expect(corpus.pages.length).toBeGreaterThanOrEqual(10); + const counts: Array<[string, number, string]> = []; + for (const p of corpus.pages) { + const r = await measure('/' + p.path, p.within, { waitFor: p.waitFor }); + counts.push([p.path, r.findings.length, r.app.verdict === 'incomplete' ? r.app.code : 'measured']); + } + expect(counts.filter(([, , status]) => status !== 'measured')).toEqual([]); + const withAny = counts.filter(([, n]) => n > 0); + expect(withAny.length).toBeLessThanOrEqual(1); + expect(withAny.every(([, n]) => n <= 1)).toBe(true); + }, 300_000); +}); + +describe.skipIf(!hasBrowser)('app profile: the CLI end to end', () => { + /** Asynchronous on purpose: the fixture server runs in this process, and a synchronous spawn would block it from answering. */ + async function runBin(args: string[]): Promise<{ code: number; out: string }> { + const child = Bun.spawn([path.join(ROOT, 'bin', 'gstack-design-lint'), ...args], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 120_000 }); + const out = await new Response(child.stdout).text(); + return { code: await child.exited, out }; + } + + test('the documented invocation prints the four ids, the LINT_APP line and exits 1; --json carries the app block', async () => { + const url = base + '/' + FIX + '/replica.html'; + const text = await runBin(['--scope', 'app', '--url', url, '--within', '.sharing', '--viewport', '1440x900', '--viewport', '375x812']); + const out = text.out; + expect(text.code).toBe(1); + expect([...new Set(out.split('\n').filter((l) => l.startsWith('LINT: ')).map((l) => l.split(' ')[2]))].sort()).toEqual(FOUR); + expect(out.trim().split('\n').slice(-2)).toEqual([expect.stringMatching(/^LINT_SUMMARY: blocks=0 ask=25 waived=0 polish=0 render=ran$/), 'LINT_APP: verdict=open code=OPEN states=1/1 viewports=1440x900,375x812 within=.sharing matched=1 open=25 waived=0']); + const json = await runBin(['--scope', 'app', '--url', url, '--within', '.sharing', '--json']); + const doc = JSON.parse(json.out); + expect([json.code, doc.app.verdict, doc.app.states.length, doc.findings[0].app.threshold]).toEqual([1, 'open', 2, expect.any(Number)]); + }, 120_000); +}); diff --git a/test/design-lint-scope-guard.test.ts b/test/design-lint-scope-guard.test.ts new file mode 100644 index 000000000..fe8a4e349 --- /dev/null +++ b/test/design-lint-scope-guard.test.ts @@ -0,0 +1,40 @@ +/** + * The scope guard for GST-1741's app profile: --scope page and --scope + * component print the same bytes and exit with the same codes on the existing + * design-lint fixtures as bin/gstack-design-lint did on origin/main before the + * app profile (e58be61). The golden holds each case's exit code, line count + * and sha256; test/fixtures/ui-spacing/scope-guard-golden.json says how it was + * produced. Static tier only: the render tier's numbers depend on the browser. + */ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin', 'gstack-design-lint'); + +function runBin(args: string[]) { + const r = Bun.spawnSync([BIN, ...args], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 60_000 }); + return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() }; +} + +describe.skipIf(process.platform === 'win32')('design-lint scope guard: page and component profiles unchanged by --scope app', () => { + test('page and component output and exit codes on the existing fixtures match origin/main byte for byte', () => { + const golden = JSON.parse(fs.readFileSync(path.join(ROOT, 'test/fixtures/ui-spacing/scope-guard-golden.json'), 'utf8')) as { cases: Array<{ args: string[]; code: number; sha256: string; lines: number }> }; + expect(golden.cases.length).toBeGreaterThanOrEqual(10); + for (const c of golden.cases) { + const r = runBin(c.args); + expect([c.args.join(' '), r.code, r.out.split('\n').length - 1, new Bun.CryptoHasher('sha256').update(r.out).digest('hex')]).toEqual([c.args.join(' '), c.code, c.lines, c.sha256]); + } + }); + + test('the app rules never print under page or component scope, and --rules lists them as app rules at ask', () => { + const page = runBin(['test/fixtures/design-lint']); + const component = runBin(['--scope', 'component', 'test/fixtures/design-lint/tailwind-fail']); + for (const id of ['row-crowding', 'copy-flush-under-list', 'note-in-row-weight', 'small-hit-area', 'section-overflow']) { + expect(page.out).not.toContain(' ' + id + ' '); + expect(component.out).not.toContain(' ' + id + ' '); + expect(runBin(['--rules']).out).toMatch(new RegExp('^ask\\s+' + id + '\\s+app\\s', 'm')); + } + }); +}); diff --git a/test/design-lint.test.ts b/test/design-lint.test.ts index 7bd2a0a63..dae2d773d 100644 --- a/test/design-lint.test.ts +++ b/test/design-lint.test.ts @@ -216,8 +216,9 @@ describe('design-lint: the output contract', () => { expect(r.out).toMatch(/^LINT: blocks render http:\/\/127\.0\.0\.1:9\/:1 could not reach http:\/\/127\.0\.0\.1:9\/: .+ \| fix: .+$/m); expect(r.out).toMatch(/^LINT_SUMMARY: blocks=1 ask=0 waived=0 polish=0 render=ran$/m); } else { - expect(r.code).toBe(0); + expect(r.code).toBe(4); expect(r.out).toMatch(/^LINT_SUMMARY: blocks=0 ask=0 waived=0 polish=0 render=skipped: /m); + expect(r.err).toMatch(/^gstack-design-lint: RENDER_UNAVAILABLE: nothing was measured at http:\/\/127\.0\.0\.1:9\/ \(no browser available.+\) \| fix: gstack-browser-ensure, then rerun$/m); } }); }); diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json index 17d18b599..ccb015672 100644 --- a/test/fixtures/module-size-ratchet.json +++ b/test/fixtures/module-size-ratchet.json @@ -13,6 +13,10 @@ { "file": "lib/design-lint/render-probe.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint-render.mjs)" }, { "file": "lib/design-lint/render-probe-space.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint-render.mjs)" }, { "file": "lib/design-lint/render-probe-quality.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint-render.mjs)" }, + { "file": "lib/design-lint/render-app.ts", "movedFrom": "(new: the design lint's app profile scheduler, GST-1741)" }, + { "file": "lib/design-lint/render-probe-app.ts", "movedFrom": "(new: the design lint's app profile probes, GST-1741)" }, + { "file": "lib/design-lint/app-input.ts", "movedFrom": "(new: the design lint's app profile inputs and URL redaction, GST-1741)" }, + { "file": "lib/design-lint/app-cli.ts", "movedFrom": "(new: the design lint's --scope app CLI, GST-1741)" }, { "file": "lib/design-lint/render-png.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint-render.mjs)" }, { "file": "lib/design-lint/rules/assets.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" }, { "file": "lib/design-lint/rules/chrome-copy.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" }, diff --git a/test/fixtures/ui-spacing/async.html b/test/fixtures/ui-spacing/async.html new file mode 100644 index 000000000..4ccedc6bf --- /dev/null +++ b/test/fixtures/ui-spacing/async.html @@ -0,0 +1,16 @@ + + + + + +Project settings: rows load late + + + + +
+
+ +
+ + diff --git a/test/fixtures/ui-spacing/corpus.json b/test/fixtures/ui-spacing/corpus.json new file mode 100644 index 000000000..64ac539c4 --- /dev/null +++ b/test/fixtures/ui-spacing/corpus.json @@ -0,0 +1,95 @@ +{ + "about": "The app profile's false-positive corpus (GST-1741 decision 6): gstack-owned pages unrelated to the qm#3186 shape, each measured under --scope app at 1440x900 and 375x812 with the --within and --wait-for given. Bar (test/design-lint-app-profile.test.ts): zero app findings on all but one page, and at most one on that page. The pages carry no lists of the replica's kind, so --wait-for stands in for the list readiness wait.", + "pages": [ + { + "path": "browse/src/welcome.html", + "within": "body", + "waitFor": "body", + "what": "the browse welcome page" + }, + { + "path": "extension/popup.html", + "within": "body", + "waitFor": "body", + "what": "the extension popup" + }, + { + "path": "browse/test/fixtures/forms.html", + "within": "body", + "waitFor": "body", + "what": "a login and profile form" + }, + { + "path": "browse/test/fixtures/qa-eval-checkout.html", + "within": "body", + "waitFor": "body", + "what": "a checkout form" + }, + { + "path": "browse/test/fixtures/qa-eval-spa.html", + "within": "body", + "waitFor": "body", + "what": "a single-page app shell" + }, + { + "path": "browse/test/fixtures/states.html", + "within": "body", + "waitFor": "body", + "what": "controls in their states" + }, + { + "path": "browse/test/fixtures/snapshot.html", + "within": "body", + "waitFor": "body", + "what": "a mixed app page" + }, + { + "path": "browse/test/fixtures/responsive.html", + "within": "body", + "waitFor": "body", + "what": "a responsive layout" + }, + { + "path": "browse/test/fixtures/basic.html", + "within": "body", + "waitFor": "body", + "what": "a basic page" + }, + { + "path": "test/fixtures/design-lint/clean/index.html", + "within": "body", + "waitFor": "body", + "what": "the design lint's clean render fixture" + }, + { + "path": "test/fixtures/design-lint/tailwind-pass/index.html", + "within": "body", + "waitFor": "body", + "what": "a Tailwind page with a bordered list" + }, + { + "path": "test/fixtures/design-lint/eyebrow-pass/index.html", + "within": "body", + "waitFor": "body", + "what": "a docs page with a step list" + } + ], + "excluded": [ + { + "path": "browse/test/fixtures/qa-eval.html", + "why": "a QA fixture with planted defects; its stats line really overflows its box (section-overflow is a true positive)" + }, + { + "path": "browse/test/fixtures/dropdown.html", + "why": "its search input is 320px inside a 300px container: a real overflow" + }, + { + "path": "test/fixtures/design-motion-finalized.html", + "why": "absolutely positioned cards run 233px past a 375px screen: a real overflow" + }, + { + "path": "extension/sidepanel.html", + "why": "links xterm.css, which ./setup builds and the repository does not hold, so it is incomplete (ASSET_MISSING), not measurable" + } + ] +} diff --git a/test/fixtures/ui-spacing/fixed.html b/test/fixtures/ui-spacing/fixed.html new file mode 100644 index 000000000..bdc613722 --- /dev/null +++ b/test/fixtures/ui-spacing/fixed.html @@ -0,0 +1,54 @@ + + + + + +Project settings: the replica with its spacing fixed + + + +
+ +
+ + diff --git a/test/fixtures/ui-spacing/larger-note.html b/test/fixtures/ui-spacing/larger-note.html new file mode 100644 index 000000000..7f4e65973 --- /dev/null +++ b/test/fixtures/ui-spacing/larger-note.html @@ -0,0 +1,37 @@ + + + + + +Integrations: a note louder than its name + + + +
+
+

Integrations

+
+
GitHub
Reconnect: the token expired yesterday.
+
PagerDuty
Paging is off until a schedule is chosen.
+
+

Integrations act with the permissions of the person who connected them.

+
+
+ + diff --git a/test/fixtures/ui-spacing/mobile-note.html b/test/fixtures/ui-spacing/mobile-note.html new file mode 100644 index 000000000..c5384f57c --- /dev/null +++ b/test/fixtures/ui-spacing/mobile-note.html @@ -0,0 +1,36 @@ + + + + + +Slack channels: a note that wraps on a phone + + + +
+
+

Slack channels

+
+
#deploys
The bot is not in this channel yet, so the deploy summary and the rollback alerts will not post until someone invites it.
+
#release-notes
Posting resumes after a workspace admin approves the app again for this channel and its threads.
+
+

Channels get a message when a deploy starts and when it finishes.

+
+
+ + diff --git a/test/fixtures/ui-spacing/negatives.html b/test/fixtures/ui-spacing/negatives.html new file mode 100644 index 000000000..99d84a32b --- /dev/null +++ b/test/fixtures/ui-spacing/negatives.html @@ -0,0 +1,70 @@ + + + + + +Negative controls: a nav list, a table, a card grid and a list spaced right + + + +
+
+

Navigation

+ +

Settings are saved as you go.

+

Deploys

+ + + + +
ServiceStatus
apius-eastLive2 min ago
webus-westBuildingjust now
+

Deploys older than 30 days are archived.

+

Environments

+
+
ProductionThree regions
+
StagingOne region
+
PreviewPer branch
+
+

Each environment has its own secrets.

+

Credentials

+
+
Deploy key
Read-only access to the repository, added by Dana on March 3
+
Registry token
Pushes release images, added by Omar on April 18
+
+

Credentials are encrypted at rest and never shown again after you add them.

+
+
+ + diff --git a/test/fixtures/ui-spacing/nested-shadow.html b/test/fixtures/ui-spacing/nested-shadow.html new file mode 100644 index 000000000..e5cbdff13 --- /dev/null +++ b/test/fixtures/ui-spacing/nested-shadow.html @@ -0,0 +1,22 @@ + + + + + +Project settings: nested open shadow roots + + + +
+ + +
+ + diff --git a/test/fixtures/ui-spacing/never-ready.html b/test/fixtures/ui-spacing/never-ready.html new file mode 100644 index 000000000..e69d14bee --- /dev/null +++ b/test/fixtures/ui-spacing/never-ready.html @@ -0,0 +1,14 @@ + + + + + +Project settings: rows never load + + + +
+ +
+ + diff --git a/test/fixtures/ui-spacing/one-row.html b/test/fixtures/ui-spacing/one-row.html new file mode 100644 index 000000000..b3893418f --- /dev/null +++ b/test/fixtures/ui-spacing/one-row.html @@ -0,0 +1,36 @@ + + + + + +Credentials: a list of one + + + +
+
+

Credentials

+
+
Deploy key
Read-only access to the repository, added by Dana on March 3
+
+

Credentials are encrypted at rest and never shown again after you add them.

+
+
+ + diff --git a/test/fixtures/ui-spacing/reader.html b/test/fixtures/ui-spacing/reader.html new file mode 100644 index 000000000..564c5f1b8 --- /dev/null +++ b/test/fixtures/ui-spacing/reader.html @@ -0,0 +1,39 @@ + + + + + +Project members: role labels beside names + + + +
+
+

Members

+
+
Dana WhitfieldOwner
dana@example.test
+
Omar HaddadReader
omar@example.test
+
Lena ParkReader
lena@example.test
+
+

Readers see deploys and logs.

+
+
+ + diff --git a/test/fixtures/ui-spacing/replica.css b/test/fixtures/ui-spacing/replica.css new file mode 100644 index 000000000..db7ba104b --- /dev/null +++ b/test/fixtures/ui-spacing/replica.css @@ -0,0 +1,19 @@ +/* The qm#3186 shape (GST-1741): a Sharing panel whose rows crowd, whose help copy sits flush under its lists, whose status notes read at the name's weight, and whose remove controls are 18px. */ +:root, :host { --ink: #1d2433; --muted: #6b7385; --line: #dfe3ea; --paper: #ffffff; } +:host { display: block; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); } +* { box-sizing: border-box; } +body { margin: 0; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: #f5f6f8; } +main { max-width: 720px; margin: 0 auto; padding: 24px 16px; } +.panel { background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 16px 20px; } +.panel h2 { font-size: 18px; line-height: 24px; margin: 0 0 12px; } +.panel h3 { font-size: 15px; line-height: 20px; margin: 20px 0 8px; } +.rows { border: 1px solid var(--line); border-radius: 6px; } +.row { display: flex; align-items: center; gap: 12px; padding: 4px 12px; border-top: 1px solid var(--line); } +.row:first-child { border-top: 0; } +.row .text { flex: 1; min-width: 0; } +.row .name { font-weight: 600; } +.row .desc { color: var(--muted); font-size: 13px; } +.row .flag { color: var(--ink); font-size: 14px; } +.row .role { color: var(--muted); font-size: 13px; margin-left: 8px; } +.x { flex: none; width: 18px; height: 18px; border: 0; padding: 0; background: transparent; color: var(--muted); font-size: 14px; line-height: 18px; cursor: pointer; } +.help { color: var(--muted); font-size: 13px; margin: 4px 0 0; } diff --git a/test/fixtures/ui-spacing/replica.html b/test/fixtures/ui-spacing/replica.html new file mode 100644 index 000000000..bd7df9220 --- /dev/null +++ b/test/fixtures/ui-spacing/replica.html @@ -0,0 +1,35 @@ + + + + + +Project settings: replica of qm#3186 + + + +
+ +
+ + diff --git a/test/fixtures/ui-spacing/scope-guard-golden.json b/test/fixtures/ui-spacing/scope-guard-golden.json new file mode 100644 index 000000000..e5cdc1dec --- /dev/null +++ b/test/fixtures/ui-spacing/scope-guard-golden.json @@ -0,0 +1,131 @@ +{ + "generatedFrom": "bin/gstack-design-lint at e58be61 (origin/main before GST-1741 PR 1), run from the repository root", + "cases": [ + { + "args": [ + "test/fixtures/design-lint/slopped.html" + ], + "code": 2, + "lines": 46, + "sha256": "295bbc0c354f8064459d30e389aa230eb53f257ad781ba844159230ddcf91515" + }, + { + "args": [ + "test/fixtures/design-lint/clean" + ], + "code": 0, + "lines": 1, + "sha256": "90680fde67b703ba3f12bbae8638f8ff7053561d76b9722cf7cb0dda37cc6ea1" + }, + { + "args": [ + "test/fixtures/design-lint/eyebrow-fail" + ], + "code": 2, + "lines": 18, + "sha256": "61350c90e70845368095ef0b61b650fbbef9f9206a70c8e70ff3cbda966c183c" + }, + { + "args": [ + "test/fixtures/design-lint/eyebrow-pass" + ], + "code": 0, + "lines": 3, + "sha256": "9da5caa6d3bea8f7624f2938e22466e60791ca61afbddfeba560f69c4d2bdf28" + }, + { + "args": [ + "test/fixtures/design-lint/tailwind-fail" + ], + "code": 2, + "lines": 41, + "sha256": "e6901b66a090638abca7e22ec89843de64e719900618b7c37e01c3cad39e0f90" + }, + { + "args": [ + "test/fixtures/design-lint/tailwind-pass" + ], + "code": 0, + "lines": 2, + "sha256": "727a01dee14590f39de8607303db1fbe20a1236bc06bf753395b169a6fbbb326" + }, + { + "args": [ + "test/fixtures/design-lint/waiver" + ], + "code": 1, + "lines": 12, + "sha256": "e6c850be2214066f74469e3483b4a3ee73bc0da4ff6da1e5319225df5a516d15" + }, + { + "args": [ + "test/fixtures/design-lint" + ], + "code": 2, + "lines": 110, + "sha256": "71e69ecf0e2b93437ff29bc5f2e94bfb85089fc64f8e318191f93331cdf1626f" + }, + { + "args": [ + "--scope", + "component", + "test/fixtures/design-lint/tailwind-fail" + ], + "code": 2, + "lines": 33, + "sha256": "2c18f9e7e5ca30bae2543d5b7aaf54c958feadecada1be9aa19e47e598231279" + }, + { + "args": [ + "--scope", + "component", + "test/fixtures/design-lint/tailwind-pass" + ], + "code": 0, + "lines": 1, + "sha256": "90680fde67b703ba3f12bbae8638f8ff7053561d76b9722cf7cb0dda37cc6ea1" + }, + { + "args": [ + "--scope", + "component", + "test/fixtures/design-lint/slopped.html" + ], + "code": 2, + "lines": 37, + "sha256": "e6305053b6de5a3ce42eb165875b1a135636fe6daf903b18a05078bc469aca20" + }, + { + "args": [ + "--scope", + "page", + "--job", + "book", + "test/fixtures/design-lint/slopped.html" + ], + "code": 2, + "lines": 46, + "sha256": "295bbc0c354f8064459d30e389aa230eb53f257ad781ba844159230ddcf91515" + }, + { + "args": [ + "--json", + "test/fixtures/design-lint/slopped.html" + ], + "code": 2, + "lines": 462, + "sha256": "61a824bec090e297fdbadd2a753892c2fa1e0ae232d1dee0ff05922fa167a18f" + }, + { + "args": [ + "--json", + "--scope", + "component", + "test/fixtures/design-lint/tailwind-fail" + ], + "code": 2, + "lines": 332, + "sha256": "2c8c88c80449a9cefbadacfbc4886b7404378087a525fd627cc9cd0607e836a8" + } + ] +} diff --git a/test/fixtures/ui-spacing/shadow-closed.html b/test/fixtures/ui-spacing/shadow-closed.html new file mode 100644 index 000000000..0a0107790 --- /dev/null +++ b/test/fixtures/ui-spacing/shadow-closed.html @@ -0,0 +1,19 @@ + + + + + +Project settings: a closed shadow root + + + +
+ + +
+ + diff --git a/test/fixtures/ui-spacing/shadow-open.html b/test/fixtures/ui-spacing/shadow-open.html new file mode 100644 index 000000000..02041375f --- /dev/null +++ b/test/fixtures/ui-spacing/shadow-open.html @@ -0,0 +1,19 @@ + + + + + +Project settings: an open shadow root + + + +
+ + +
+ + diff --git a/test/fixtures/ui-spacing/shadow-slotted.html b/test/fixtures/ui-spacing/shadow-slotted.html new file mode 100644 index 000000000..4ae9bbe10 --- /dev/null +++ b/test/fixtures/ui-spacing/shadow-slotted.html @@ -0,0 +1,21 @@ + + + + + +Project settings: rows slotted into a shadow root + + + + +
+ + +
+ + diff --git a/test/fixtures/ui-spacing/sharing.js b/test/fixtures/ui-spacing/sharing.js new file mode 100644 index 000000000..0a1173b98 --- /dev/null +++ b/test/fixtures/ui-spacing/sharing.js @@ -0,0 +1,2 @@ +// The replica's Sharing section as one string, for the variants that build it with script (async, shadow). test/design-lint-app-profile.test.ts holds it equal to replica.html's markup. +window.SHARING_HTML = "
\n

Sharing

\n

Credentials

\n
\n
Deploy key
Read-only access to the repository, added by Dana on March 3
\n
Registry token
Pushes release images, added by Omar on April 18
\n
\n

Credentials are encrypted at rest and never shown again after you add them.

\n

Slack channels

\n
\n
#deploys
The bot is not in this channel yet, so posts will fail.
\n
#release-notes
Posting paused by a workspace admin.
\n
\n

Channels get a message when a deploy starts and when it finishes.

\n

Who has access

\n
\n
Dana WhitfieldOwner
\n
Omar HaddadEditor
\n
Lena ParkReader
\n
\n

Readers see deploys and logs; editors can change settings.

\n
"; diff --git a/test/fixtures/ui-spacing/structural.html b/test/fixtures/ui-spacing/structural.html new file mode 100644 index 000000000..1f854ea79 --- /dev/null +++ b/test/fixtures/ui-spacing/structural.html @@ -0,0 +1,51 @@ + + + + + +Project settings: the replica's defects in other markup + + + +
+
+

Sharing

+

Credentials

+
    +
  • Deploy key

    Read-only access to the repository, added by Dana on March 3

  • +
  • Registry token

    Pushes release images, added by Omar on April 18

  • +
+
Credentials are encrypted at rest and never shown again after you add them.
+

Slack channels

+
    +
  • #deploys

    The bot is not in this channel yet, so posts will fail.

  • +
  • #release-notes

    Posting paused by a workspace admin.

  • +
+
Channels get a message when a deploy starts and when it finishes.
+

Who has access

+
    +
  • Dana WhitfieldOwner
  • +
  • Omar HaddadEditor
  • +
+
Readers see deploys and logs; editors can change settings.
+
+
+ + diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index 0d407c995..4a6db25ff 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -78,7 +78,7 @@ export const FREE_FIXTURES: Readonly> = { '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/design-lint-fourth-cycle.test.ts', 'test/design-lint-fifth-cycle.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/design-lint-app-profile.test.ts', 'test/design-lint-scope-guard.test.ts'], 'test/fixtures/design-floor-focus-36597762183.json': ['test/plan-design-floor-fixture.test.ts'], 'test/fixtures/design-floor-focus-cover-all.json': ['test/plan-design-floor-fixture.test.ts'], 'test/fixtures/design-floor-focus-review-focus.json': ['test/plan-design-floor-fixture.test.ts'], @@ -259,6 +259,7 @@ export const FREE_FIXTURES: Readonly> = { 'test/fixtures/trial-cause/replays.json': ['test/trial-cause.test.ts'], 'test/fixtures/trial-cause/thinking-captures-37198445662.json': ['test/trial-cause.test.ts'], 'test/fixtures/trial-record-v1-reader.ts': ['test/helpers/eval-store.test.ts'], + 'test/fixtures/ui-spacing/**': ['test/design-lint-app-contract.test.ts', 'test/design-lint-app-profile.test.ts', 'test/design-lint-scope-guard.test.ts'], 'test/fixtures/windows-bash-resolution.json': ['test/script-invocation.test.ts'], 'test/fixtures/workflow-required-steps.json': ['test/workflow-required-steps.test.ts'], };