@awarebydefault/display-case 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +309 -0
- package/display-case.prompt.md +64 -0
- package/docs/ai-agents.md +126 -0
- package/docs/cli.md +99 -0
- package/docs/configuration.md +410 -0
- package/docs/documentation-panel.md +50 -0
- package/docs/examples/README.md +14 -0
- package/docs/examples/multi-variant.case.tsx +30 -0
- package/docs/examples/plain.case.tsx +22 -0
- package/docs/examples/tweak-control.placard.md +80 -0
- package/docs/examples/tweaks.case.tsx +39 -0
- package/docs/hierarchy.md +59 -0
- package/docs/quick-start.md +78 -0
- package/docs/style-engines.md +180 -0
- package/docs/testing.md +245 -0
- package/docs/theming.md +97 -0
- package/docs/tweaks.md +75 -0
- package/docs/writing-cases.md +144 -0
- package/docs/writing-placard-docs.md +194 -0
- package/package.json +113 -0
- package/skills/display-case-author-case/README.md +20 -0
- package/skills/display-case-author-case/SKILL.md +40 -0
- package/skills/display-case-author-placard-doc/README.md +24 -0
- package/skills/display-case-author-placard-doc/SKILL.md +65 -0
- package/skills/display-case-review/README.md +19 -0
- package/skills/display-case-review/SKILL.md +30 -0
- package/skills/display-case-snapshot/README.md +20 -0
- package/skills/display-case-snapshot/SKILL.md +29 -0
- package/src/checks/a11y-scanner.test.ts +240 -0
- package/src/checks/a11y-scanner.ts +410 -0
- package/src/checks/check-text.test.ts +53 -0
- package/src/checks/check-text.ts +78 -0
- package/src/checks/check.test.ts +194 -0
- package/src/checks/check.ts +473 -0
- package/src/checks/providers/pixelmatch-diff.test.ts +79 -0
- package/src/checks/providers/pixelmatch-diff.ts +30 -0
- package/src/checks/providers/playwright-driver.ts +104 -0
- package/src/checks/ssr-check.test.ts +73 -0
- package/src/checks/ssr-check.ts +96 -0
- package/src/checks/structure-check.cross-package.test.ts +165 -0
- package/src/checks/structure-check.test.ts +651 -0
- package/src/checks/structure-check.ts +988 -0
- package/src/checks/tokens-check.test.ts +159 -0
- package/src/checks/tokens-check.ts +162 -0
- package/src/cli.ts +218 -0
- package/src/commands/agents.test.ts +24 -0
- package/src/commands/agents.ts +28 -0
- package/src/commands/init-run.test.ts +123 -0
- package/src/commands/init.test.ts +63 -0
- package/src/commands/init.ts +412 -0
- package/src/commands/publish.test.ts +210 -0
- package/src/commands/publish.ts +292 -0
- package/src/core/affected.test.ts +99 -0
- package/src/core/affected.ts +144 -0
- package/src/core/catalog.test.ts +152 -0
- package/src/core/catalog.ts +92 -0
- package/src/core/discovery.test.ts +184 -0
- package/src/core/discovery.ts +250 -0
- package/src/core/manifest.ts +41 -0
- package/src/core/mdx-lite/__fixtures__/box-stub.tsx +7 -0
- package/src/core/mdx-lite/index.ts +393 -0
- package/src/core/mdx-lite/mdx-lite.test.ts +345 -0
- package/src/core/mdx-plugin.test.ts +60 -0
- package/src/core/mdx-plugin.ts +30 -0
- package/src/flow-step.test-d.ts +39 -0
- package/src/index.test.ts +100 -0
- package/src/index.ts +564 -0
- package/src/render/collect-styles.emotion.test.tsx +114 -0
- package/src/render/collect-styles.test.tsx +72 -0
- package/src/render/collect-styles.ts +33 -0
- package/src/render/documents.test.ts +184 -0
- package/src/render/documents.ts +88 -0
- package/src/render/render-node.test.tsx +160 -0
- package/src/render/render-node.tsx +133 -0
- package/src/render/ssr-primer.test.tsx +25 -0
- package/src/render/ssr-primer.tsx +54 -0
- package/src/render/ssr-render.test.tsx +142 -0
- package/src/render/ssr-render.tsx +63 -0
- package/src/render/ssr-shell.test.tsx +57 -0
- package/src/render/ssr-shell.tsx +54 -0
- package/src/server/prod-server.ts +237 -0
- package/src/server/server.test.ts +117 -0
- package/src/server/server.ts +1039 -0
- package/src/style-engine.test-d.ts +37 -0
- package/src/testing/test-helpers.ts +27 -0
- package/src/types/pixelmatch.d.ts +12 -0
- package/src/ui/browser-entry.tsx +51 -0
- package/src/ui/chrome.css +485 -0
- package/src/ui/design-system/README.md +88 -0
- package/src/ui/design-system/components/controls/Button.case.tsx +52 -0
- package/src/ui/design-system/components/controls/Button.css +89 -0
- package/src/ui/design-system/components/controls/Button.placard.md +14 -0
- package/src/ui/design-system/components/controls/Button.test.tsx +45 -0
- package/src/ui/design-system/components/controls/Button.tsx +41 -0
- package/src/ui/design-system/components/controls/IconButton.case.tsx +52 -0
- package/src/ui/design-system/components/controls/IconButton.css +67 -0
- package/src/ui/design-system/components/controls/IconButton.placard.md +13 -0
- package/src/ui/design-system/components/controls/IconButton.test.tsx +39 -0
- package/src/ui/design-system/components/controls/IconButton.tsx +47 -0
- package/src/ui/design-system/components/controls/Input.case.tsx +50 -0
- package/src/ui/design-system/components/controls/Input.css +52 -0
- package/src/ui/design-system/components/controls/Input.placard.md +12 -0
- package/src/ui/design-system/components/controls/Input.test.tsx +43 -0
- package/src/ui/design-system/components/controls/Input.tsx +45 -0
- package/src/ui/design-system/components/controls/Select.case.tsx +48 -0
- package/src/ui/design-system/components/controls/Select.css +44 -0
- package/src/ui/design-system/components/controls/Select.placard.md +15 -0
- package/src/ui/design-system/components/controls/Select.test.tsx +57 -0
- package/src/ui/design-system/components/controls/Select.tsx +58 -0
- package/src/ui/design-system/components/controls/SelectMenu.case.tsx +100 -0
- package/src/ui/design-system/components/controls/SelectMenu.css +72 -0
- package/src/ui/design-system/components/controls/SelectMenu.placard.md +18 -0
- package/src/ui/design-system/components/controls/SelectMenu.test.tsx +66 -0
- package/src/ui/design-system/components/controls/SelectMenu.tsx +377 -0
- package/src/ui/design-system/components/index.ts +66 -0
- package/src/ui/design-system/components/primer-specimen/ColorRamp.case.tsx +44 -0
- package/src/ui/design-system/components/primer-specimen/ColorRamp.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/ColorRamp.tsx +51 -0
- package/src/ui/design-system/components/primer-specimen/DefinitionList.case.tsx +38 -0
- package/src/ui/design-system/components/primer-specimen/DefinitionList.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/DefinitionList.tsx +41 -0
- package/src/ui/design-system/components/primer-specimen/FontFamilies.case.tsx +24 -0
- package/src/ui/design-system/components/primer-specimen/FontFamilies.placard.md +12 -0
- package/src/ui/design-system/components/primer-specimen/FontFamilies.tsx +41 -0
- package/src/ui/design-system/components/primer-specimen/GlyphGrid.case.tsx +27 -0
- package/src/ui/design-system/components/primer-specimen/GlyphGrid.placard.md +13 -0
- package/src/ui/design-system/components/primer-specimen/GlyphGrid.tsx +34 -0
- package/src/ui/design-system/components/primer-specimen/LayoutMock.case.tsx +36 -0
- package/src/ui/design-system/components/primer-specimen/LayoutMock.placard.md +7 -0
- package/src/ui/design-system/components/primer-specimen/LayoutMock.tsx +36 -0
- package/src/ui/design-system/components/primer-specimen/SpacingScale.case.tsx +20 -0
- package/src/ui/design-system/components/primer-specimen/SpacingScale.placard.md +12 -0
- package/src/ui/design-system/components/primer-specimen/SpacingScale.tsx +33 -0
- package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.case.tsx +56 -0
- package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.placard.md +17 -0
- package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.tsx +45 -0
- package/src/ui/design-system/components/primer-specimen/StatusList.case.tsx +17 -0
- package/src/ui/design-system/components/primer-specimen/StatusList.placard.md +16 -0
- package/src/ui/design-system/components/primer-specimen/StatusList.tsx +39 -0
- package/src/ui/design-system/components/primer-specimen/SwatchGrid.case.tsx +26 -0
- package/src/ui/design-system/components/primer-specimen/SwatchGrid.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/SwatchGrid.tsx +42 -0
- package/src/ui/design-system/components/primer-specimen/TypeScale.case.tsx +23 -0
- package/src/ui/design-system/components/primer-specimen/TypeScale.placard.md +14 -0
- package/src/ui/design-system/components/primer-specimen/TypeScale.tsx +34 -0
- package/src/ui/design-system/components/primer-specimen/WeightSpecimen.case.tsx +28 -0
- package/src/ui/design-system/components/primer-specimen/WeightSpecimen.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/WeightSpecimen.tsx +46 -0
- package/src/ui/design-system/components/primer-specimen/index.ts +31 -0
- package/src/ui/design-system/components/primer-specimen/styles.css +476 -0
- package/src/ui/design-system/components/shell/A11yPage.case.tsx +237 -0
- package/src/ui/design-system/components/shell/A11yPage.placard.md +15 -0
- package/src/ui/design-system/components/shell/CaseTemplate.case.tsx +32 -0
- package/src/ui/design-system/components/shell/CaseTemplate.placard.md +5 -0
- package/src/ui/design-system/components/shell/CasesPage.case.tsx +141 -0
- package/src/ui/design-system/components/shell/CasesPage.placard.md +12 -0
- package/src/ui/design-system/components/shell/PrimerPage.case.tsx +22 -0
- package/src/ui/design-system/components/shell/PrimerPage.placard.md +3 -0
- package/src/ui/design-system/components/shell/PrimerTemplate.case.tsx +22 -0
- package/src/ui/design-system/components/shell/PrimerTemplate.placard.md +5 -0
- package/src/ui/design-system/components/shell/ShellView.case.tsx +57 -0
- package/src/ui/design-system/components/shell/ShellView.placard.md +5 -0
- package/src/ui/design-system/components/shell/ShellView.tsx +678 -0
- package/src/ui/design-system/components/shell/shell-fixtures.tsx +727 -0
- package/src/ui/design-system/components/showcase/A11yBadge.case.tsx +46 -0
- package/src/ui/design-system/components/showcase/A11yBadge.css +27 -0
- package/src/ui/design-system/components/showcase/A11yBadge.placard.md +11 -0
- package/src/ui/design-system/components/showcase/A11yBadge.test.tsx +31 -0
- package/src/ui/design-system/components/showcase/A11yBadge.tsx +41 -0
- package/src/ui/design-system/components/showcase/A11yPanel.case.tsx +121 -0
- package/src/ui/design-system/components/showcase/A11yPanel.css +198 -0
- package/src/ui/design-system/components/showcase/A11yPanel.placard.md +19 -0
- package/src/ui/design-system/components/showcase/A11yPanel.test.tsx +81 -0
- package/src/ui/design-system/components/showcase/A11yPanel.tsx +144 -0
- package/src/ui/design-system/components/showcase/Chip.case.tsx +48 -0
- package/src/ui/design-system/components/showcase/Chip.css +51 -0
- package/src/ui/design-system/components/showcase/Chip.placard.md +13 -0
- package/src/ui/design-system/components/showcase/Chip.test.tsx +46 -0
- package/src/ui/design-system/components/showcase/Chip.tsx +54 -0
- package/src/ui/design-system/components/showcase/Eyebrow.case.tsx +30 -0
- package/src/ui/design-system/components/showcase/Eyebrow.css +16 -0
- package/src/ui/design-system/components/showcase/Eyebrow.placard.md +10 -0
- package/src/ui/design-system/components/showcase/Eyebrow.test.tsx +38 -0
- package/src/ui/design-system/components/showcase/Eyebrow.tsx +29 -0
- package/src/ui/design-system/components/showcase/FlowNav.case.tsx +35 -0
- package/src/ui/design-system/components/showcase/FlowNav.css +29 -0
- package/src/ui/design-system/components/showcase/FlowNav.placard.md +13 -0
- package/src/ui/design-system/components/showcase/FlowNav.test.tsx +48 -0
- package/src/ui/design-system/components/showcase/FlowNav.tsx +58 -0
- package/src/ui/design-system/components/showcase/ImpactTag.case.tsx +19 -0
- package/src/ui/design-system/components/showcase/ImpactTag.css +36 -0
- package/src/ui/design-system/components/showcase/ImpactTag.placard.md +14 -0
- package/src/ui/design-system/components/showcase/ImpactTag.test.tsx +40 -0
- package/src/ui/design-system/components/showcase/ImpactTag.tsx +35 -0
- package/src/ui/design-system/components/showcase/NavItem.case.tsx +86 -0
- package/src/ui/design-system/components/showcase/NavItem.css +111 -0
- package/src/ui/design-system/components/showcase/NavItem.placard.md +13 -0
- package/src/ui/design-system/components/showcase/NavItem.test.tsx +65 -0
- package/src/ui/design-system/components/showcase/NavItem.tsx +95 -0
- package/src/ui/design-system/components/showcase/RenderAddress.case.tsx +21 -0
- package/src/ui/design-system/components/showcase/RenderAddress.css +35 -0
- package/src/ui/design-system/components/showcase/RenderAddress.placard.md +7 -0
- package/src/ui/design-system/components/showcase/RenderAddress.test.tsx +26 -0
- package/src/ui/design-system/components/showcase/RenderAddress.tsx +43 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.case.tsx +84 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.css +61 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.placard.md +21 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.test.tsx +81 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.tsx +75 -0
- package/src/ui/design-system/components/showcase/Sidebar.case.tsx +67 -0
- package/src/ui/design-system/components/showcase/Sidebar.css +6 -0
- package/src/ui/design-system/components/showcase/Sidebar.placard.md +14 -0
- package/src/ui/design-system/components/showcase/Sidebar.test.tsx +32 -0
- package/src/ui/design-system/components/showcase/Sidebar.tsx +30 -0
- package/src/ui/design-system/components/showcase/Stage.case.tsx +51 -0
- package/src/ui/design-system/components/showcase/Stage.css +91 -0
- package/src/ui/design-system/components/showcase/Stage.placard.md +15 -0
- package/src/ui/design-system/components/showcase/Stage.test.tsx +84 -0
- package/src/ui/design-system/components/showcase/Stage.tsx +97 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.case.tsx +81 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.css +169 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.placard.md +20 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.tsx +230 -0
- package/src/ui/design-system/components/showcase/Wordmark.case.tsx +42 -0
- package/src/ui/design-system/components/showcase/Wordmark.css +31 -0
- package/src/ui/design-system/components/showcase/Wordmark.placard.md +10 -0
- package/src/ui/design-system/components/showcase/Wordmark.test.tsx +22 -0
- package/src/ui/design-system/components/showcase/Wordmark.tsx +22 -0
- package/src/ui/design-system/primer-specimens/brand.tsx +26 -0
- package/src/ui/design-system/primer-specimens/colors.tsx +83 -0
- package/src/ui/design-system/primer-specimens/components.tsx +308 -0
- package/src/ui/design-system/primer-specimens/foundations.tsx +71 -0
- package/src/ui/design-system/primer-specimens/index.ts +25 -0
- package/src/ui/design-system/primer-specimens/showcase.tsx +68 -0
- package/src/ui/design-system/primer-specimens/spacing.tsx +101 -0
- package/src/ui/design-system/primer-specimens/type.tsx +75 -0
- package/src/ui/design-system/primer.mdx +236 -0
- package/src/ui/design-system/styles.css +14 -0
- package/src/ui/design-system/tokens/colors.css +172 -0
- package/src/ui/design-system/tokens/fonts.css +18 -0
- package/src/ui/design-system/tokens/spacing.css +48 -0
- package/src/ui/design-system/tokens/typography.css +49 -0
- package/src/ui/markdown.test.tsx +54 -0
- package/src/ui/markdown.tsx +19 -0
- package/src/ui/primer-mount.tsx +76 -0
- package/src/ui/primer.css +175 -0
- package/src/ui/primer.tsx +277 -0
- package/src/ui/render-mount.tsx +284 -0
- package/src/ui/shell-core.test.ts +340 -0
- package/src/ui/shell-core.ts +295 -0
- package/src/ui/shell.tsx +60 -0
- package/src/ui/test-ids.ts +53 -0
- package/src/ui/use-shell.ts +1230 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { rm } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import type { A11yViolation } from '../index'
|
|
5
|
+
import { makeTempDir, writeFiles } from '../testing/test-helpers'
|
|
6
|
+
import { a11yDetailLines, runChecks } from './check'
|
|
7
|
+
|
|
8
|
+
const CLI = join(import.meta.dir, '..', 'cli.ts')
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `a11yDetailLines` turns a violation's per-node detail into the indented lines
|
|
12
|
+
* the CLI prints — the colour pair / element that makes a finding fixable. It's
|
|
13
|
+
* pure, so it's the one piece of the a11y output testable without a browser.
|
|
14
|
+
*/
|
|
15
|
+
describe('check: a11y detail formatting', () => {
|
|
16
|
+
test('color-contrast node renders the measured pair and threshold', () => {
|
|
17
|
+
const v: A11yViolation = {
|
|
18
|
+
id: 'color-contrast',
|
|
19
|
+
help: 'Elements must meet minimum color contrast ratio thresholds',
|
|
20
|
+
nodes: 1,
|
|
21
|
+
impact: 'serious',
|
|
22
|
+
details: [
|
|
23
|
+
{
|
|
24
|
+
target: 'span.dcui-eyebrow',
|
|
25
|
+
html: '<span class="dcui-eyebrow">Tweaks</span>',
|
|
26
|
+
contrast: {
|
|
27
|
+
foreground: '#8a8073',
|
|
28
|
+
background: '#ffffff',
|
|
29
|
+
ratio: 3.71,
|
|
30
|
+
required: 4.5,
|
|
31
|
+
fontSize: '12.0pt',
|
|
32
|
+
fontWeight: '400',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
}
|
|
37
|
+
expect(a11yDetailLines(v)).toEqual([
|
|
38
|
+
' ↳ span.dcui-eyebrow #8a8073 on #ffffff = 3.71:1 (need 4.5:1) [12.0pt 400]',
|
|
39
|
+
])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('non-contrast node renders the element and first summary line', () => {
|
|
43
|
+
const v: A11yViolation = {
|
|
44
|
+
id: 'select-name',
|
|
45
|
+
help: 'Select element must have an accessible name',
|
|
46
|
+
nodes: 1,
|
|
47
|
+
impact: 'critical',
|
|
48
|
+
details: [
|
|
49
|
+
{
|
|
50
|
+
target: 'select.dcui-select-el',
|
|
51
|
+
html: '<select class="dcui-select-el">',
|
|
52
|
+
failureSummary: 'Fix any of the following:\n Element has no name',
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
expect(a11yDetailLines(v)).toEqual([
|
|
57
|
+
' ↳ select.dcui-select-el Fix any of the following:',
|
|
58
|
+
])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('caps the inline list and notes the remainder', () => {
|
|
62
|
+
const details = Array.from({ length: 10 }, (_, i) => ({
|
|
63
|
+
target: `x${i}`,
|
|
64
|
+
html: '',
|
|
65
|
+
contrast: {
|
|
66
|
+
foreground: '#000',
|
|
67
|
+
background: '#fff',
|
|
68
|
+
ratio: 1,
|
|
69
|
+
required: 4.5,
|
|
70
|
+
},
|
|
71
|
+
}))
|
|
72
|
+
const lines = a11yDetailLines({
|
|
73
|
+
id: 'color-contrast',
|
|
74
|
+
help: 'h',
|
|
75
|
+
nodes: 10,
|
|
76
|
+
impact: 'serious',
|
|
77
|
+
details,
|
|
78
|
+
})
|
|
79
|
+
expect(lines).toHaveLength(9)
|
|
80
|
+
expect(lines.at(-1)).toBe(' ↳ … +2 more node(s)')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('a violation with no detail yields no lines', () => {
|
|
84
|
+
expect(
|
|
85
|
+
a11yDetailLines({
|
|
86
|
+
id: 'x',
|
|
87
|
+
help: 'h',
|
|
88
|
+
nodes: 1,
|
|
89
|
+
impact: null,
|
|
90
|
+
}),
|
|
91
|
+
).toEqual([])
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The structure (and token) phases are static: `runChecks` must not start the
|
|
97
|
+
* dev server for them, and the CLI must honor `check.defaultPhases`. These are
|
|
98
|
+
* the only render-free assertions we can make without booting a browser, so the
|
|
99
|
+
* a11y/visual phases are exercised by the e2e suite, not here.
|
|
100
|
+
*/
|
|
101
|
+
describe('check: structure phase wiring', () => {
|
|
102
|
+
const dirs: string[] = []
|
|
103
|
+
const setup = async (files: Record<string, string>) => {
|
|
104
|
+
const dir = await makeTempDir()
|
|
105
|
+
dirs.push(dir)
|
|
106
|
+
await writeFiles(dir, files)
|
|
107
|
+
return dir
|
|
108
|
+
}
|
|
109
|
+
afterEach(async () => {
|
|
110
|
+
while (dirs.length)
|
|
111
|
+
await rm(dirs.pop() as string, { recursive: true, force: true })
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const AllRules = [
|
|
115
|
+
'case-placard-coverage',
|
|
116
|
+
'no-orphaned-placard-doc',
|
|
117
|
+
'primer-present-and-used',
|
|
118
|
+
'setup-present',
|
|
119
|
+
'config-paths-exist',
|
|
120
|
+
'levels-classified',
|
|
121
|
+
'cases-load',
|
|
122
|
+
'flow-transitions-resolve',
|
|
123
|
+
'flow-multi-step',
|
|
124
|
+
'unique-slugs',
|
|
125
|
+
'tweak-defaults-valid',
|
|
126
|
+
'atom-purity',
|
|
127
|
+
'no-downward-dependency',
|
|
128
|
+
'composes-lower-level',
|
|
129
|
+
'level-fit',
|
|
130
|
+
]
|
|
131
|
+
// A config that enables only the named rules (others disabled), so the static
|
|
132
|
+
// run is deterministic and free of the noisy default-on rules.
|
|
133
|
+
const cfg = (enabled: string[]) => {
|
|
134
|
+
const rules = AllRules.map(
|
|
135
|
+
(id) => `'${id}': ${enabled.includes(id) ? '{}' : 'false'}`,
|
|
136
|
+
).join(', ')
|
|
137
|
+
return `export default { title:'F', roots:['**/*.case.tsx'], check:{ structure:{ rules:{ ${rules} } } } }\n`
|
|
138
|
+
}
|
|
139
|
+
const runStructure = (dir: string) =>
|
|
140
|
+
runChecks(dir, {
|
|
141
|
+
structure: true,
|
|
142
|
+
tokens: false,
|
|
143
|
+
a11y: false,
|
|
144
|
+
visual: false,
|
|
145
|
+
ssr: false,
|
|
146
|
+
update: false,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('structure-only run resolves without starting a server', async () => {
|
|
150
|
+
const dir = await setup({
|
|
151
|
+
'display-case.config.ts': cfg(['levels-classified']),
|
|
152
|
+
'X.case.tsx': `export default { component:'X', cases:{}, isFlow:false, level:'atom' }\n`,
|
|
153
|
+
})
|
|
154
|
+
// No port is passed; a structure-only run must not need or start the server.
|
|
155
|
+
expect(await runStructure(dir)).toBe(true)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('structure-only run returns false on an error finding', async () => {
|
|
159
|
+
const dir = await setup({
|
|
160
|
+
'display-case.config.ts': cfg(['levels-classified']),
|
|
161
|
+
// Unclassified (no level) ⇒ an error-severity finding.
|
|
162
|
+
'X.case.tsx': `export default { component:'X', cases:{}, isFlow:false }\n`,
|
|
163
|
+
})
|
|
164
|
+
expect(await runStructure(dir)).toBe(false)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const cli = async (dir: string, args: string[]) => {
|
|
168
|
+
const proc = Bun.spawn(['bun', CLI, 'check', dir, ...args], {
|
|
169
|
+
stdout: 'pipe',
|
|
170
|
+
stderr: 'pipe',
|
|
171
|
+
})
|
|
172
|
+
const [out, err] = await Promise.all([
|
|
173
|
+
new Response(proc.stdout).text(),
|
|
174
|
+
new Response(proc.stderr).text(),
|
|
175
|
+
])
|
|
176
|
+
await proc.exited
|
|
177
|
+
return out + err
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
test('defaultPhases opts a phase out of the bare run but not the explicit one', async () => {
|
|
181
|
+
// Every phase opted out of the default run, so the bare `check` runs nothing
|
|
182
|
+
// (and never boots a browser). X is unclassified ⇒ structure errors when run.
|
|
183
|
+
const dir = await setup({
|
|
184
|
+
'display-case.config.ts':
|
|
185
|
+
`export default { title:'F', roots:['**/*.case.tsx'], ` +
|
|
186
|
+
`check:{ defaultPhases:{ tokens:false, a11y:false, visual:false, structure:false } } }\n`,
|
|
187
|
+
'X.case.tsx': `export default { component:'X', cases:{}, isFlow:false }\n`,
|
|
188
|
+
})
|
|
189
|
+
const bare = await cli(dir, [])
|
|
190
|
+
expect(bare).not.toContain('structure ✗')
|
|
191
|
+
const explicit = await cli(dir, ['--structure'])
|
|
192
|
+
expect(explicit).toContain('structure ✗')
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises'
|
|
2
|
+
import { dirname, extname, join, relative, resolve, sep } from 'node:path'
|
|
3
|
+
import { Glob } from 'bun'
|
|
4
|
+
import { componentClosures } from '../core/affected'
|
|
5
|
+
import { baselineDir, cacheDir, resolveConfig } from '../core/discovery'
|
|
6
|
+
import type { ManifestComponent } from '../core/manifest'
|
|
7
|
+
import type {
|
|
8
|
+
A11yViolation,
|
|
9
|
+
CaseContext,
|
|
10
|
+
DiffFn,
|
|
11
|
+
DisplayCaseConfig,
|
|
12
|
+
RenderDriver,
|
|
13
|
+
} from '../index'
|
|
14
|
+
import { startDisplayCase } from '../server/server'
|
|
15
|
+
import { checkSsr } from './ssr-check'
|
|
16
|
+
import { checkStructure } from './structure-check'
|
|
17
|
+
import { checkTokens } from './tokens-check'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Headless a11y + visual-regression runner. The capture/audit driver and the
|
|
21
|
+
* image diff are pluggable (config `providers`); when unset, the built-in
|
|
22
|
+
* Playwright/axe + pixelmatch/pngjs defaults are imported lazily — so those
|
|
23
|
+
* packages are needed only when a default-backed check actually runs.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const THEMES = ['light', 'dark'] as const
|
|
27
|
+
const VIEWPORT_WIDTH = 1024
|
|
28
|
+
|
|
29
|
+
const INSTALL_HINT =
|
|
30
|
+
'Visual/a11y checks need the default toolchain. Install it with ' +
|
|
31
|
+
'`bun add -d playwright @axe-core/playwright pixelmatch pngjs && bunx playwright install chromium` ' +
|
|
32
|
+
'(or run `display-case init --with-visual`), or set `providers.driver`/`providers.diff` in display-case.config.ts.'
|
|
33
|
+
|
|
34
|
+
export interface CheckOptions {
|
|
35
|
+
a11y: boolean
|
|
36
|
+
visual: boolean
|
|
37
|
+
tokens: boolean
|
|
38
|
+
structure: boolean
|
|
39
|
+
/** Server-render every case and fail on any that can't pre-render. */
|
|
40
|
+
ssr: boolean
|
|
41
|
+
update: boolean
|
|
42
|
+
/** Treat structure warnings as errors (CLI `--strict`). */
|
|
43
|
+
strict?: boolean
|
|
44
|
+
/** Restrict the render phases (a11y/visual) to these component ids or globs. */
|
|
45
|
+
only?: string[]
|
|
46
|
+
/** Restrict the render phases to components whose import closure touches a
|
|
47
|
+
* file changed since this git ref (CLI `--changed[=ref]`). */
|
|
48
|
+
changedRef?: string
|
|
49
|
+
port?: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Target {
|
|
53
|
+
componentId: string
|
|
54
|
+
caseId: string
|
|
55
|
+
theme: (typeof THEMES)[number]
|
|
56
|
+
renderUrl: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One scanned variant in the written a11y report (only failing variants). */
|
|
60
|
+
interface A11yReportEntry {
|
|
61
|
+
component: string
|
|
62
|
+
case: string
|
|
63
|
+
theme: string
|
|
64
|
+
violations: A11yViolation[]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Cap on per-violation detail lines printed inline; the full set is always in
|
|
68
|
+
* the written report. Keeps a noisy run's console readable. */
|
|
69
|
+
const A11Y_DETAIL_CAP = 8
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Indented, human-readable detail lines for one violation: the failing element
|
|
73
|
+
* and, for colour-contrast, the exact measured pair and threshold — the data
|
|
74
|
+
* that makes a finding fixable without re-running a browser. Pure (no I/O) so
|
|
75
|
+
* the formatting is unit-tested.
|
|
76
|
+
*/
|
|
77
|
+
export function a11yDetailLines(v: A11yViolation): string[] {
|
|
78
|
+
const details = v.details ?? []
|
|
79
|
+
const lines = details.slice(0, A11Y_DETAIL_CAP).map((d) => {
|
|
80
|
+
const where = d.target || '(element)'
|
|
81
|
+
if (d.contrast) {
|
|
82
|
+
const c = d.contrast
|
|
83
|
+
const font = c.fontSize
|
|
84
|
+
? ` [${c.fontSize}${c.fontWeight ? ` ${c.fontWeight}` : ''}]`
|
|
85
|
+
: ''
|
|
86
|
+
return ` ↳ ${where} ${c.foreground} on ${c.background} = ${c.ratio}:1 (need ${c.required}:1)${font}`
|
|
87
|
+
}
|
|
88
|
+
const why = (d.failureSummary ?? '').split('\n')[0].trim()
|
|
89
|
+
return ` ↳ ${where}${why ? ` ${why}` : ''}`
|
|
90
|
+
})
|
|
91
|
+
if (details.length > A11Y_DETAIL_CAP) {
|
|
92
|
+
lines.push(` ↳ … +${details.length - A11Y_DETAIL_CAP} more node(s)`)
|
|
93
|
+
}
|
|
94
|
+
return lines
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function resolveDriver(config: DisplayCaseConfig): Promise<RenderDriver> {
|
|
98
|
+
if (config.providers?.driver) return await config.providers.driver()
|
|
99
|
+
try {
|
|
100
|
+
const { createPlaywrightDriver } = await import(
|
|
101
|
+
'./providers/playwright-driver'
|
|
102
|
+
)
|
|
103
|
+
return await createPlaywrightDriver()
|
|
104
|
+
} catch (err) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`${INSTALL_HINT}\n (${err instanceof Error ? err.message : String(err)})`,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function resolveDiff(config: DisplayCaseConfig): Promise<DiffFn> {
|
|
112
|
+
if (config.providers?.diff) return config.providers.diff
|
|
113
|
+
try {
|
|
114
|
+
const { pixelmatchDiff } = await import('./providers/pixelmatch-diff')
|
|
115
|
+
return pixelmatchDiff
|
|
116
|
+
} catch (err) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`${INSTALL_HINT}\n (${err instanceof Error ? err.message : String(err)})`,
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Absolute paths of files changed since `ref`, for `--changed` scoping. Unions
|
|
125
|
+
* the committed diff since the merge-base (`ref...HEAD`) with the working tree
|
|
126
|
+
* (`HEAD`), so a local run also sees staged/unstaged edits and CI sees the PR's
|
|
127
|
+
* commits. Returns an empty list when git is unavailable or `ref` can't be
|
|
128
|
+
* resolved (e.g. an over-shallow clone) — the caller treats "no changes" as
|
|
129
|
+
* "nothing affected".
|
|
130
|
+
*/
|
|
131
|
+
async function changedSince(pkgDir: string, ref: string): Promise<string[]> {
|
|
132
|
+
const top = await Bun.$`git -C ${pkgDir} rev-parse --show-toplevel`
|
|
133
|
+
.quiet()
|
|
134
|
+
.nothrow()
|
|
135
|
+
if (top.exitCode !== 0) return []
|
|
136
|
+
const root = top.stdout.toString().trim()
|
|
137
|
+
const names = new Set<string>()
|
|
138
|
+
for (const range of [`${ref}...HEAD`, 'HEAD']) {
|
|
139
|
+
const out = await Bun.$`git -C ${root} diff --name-only ${range}`
|
|
140
|
+
.quiet()
|
|
141
|
+
.nothrow()
|
|
142
|
+
if (out.exitCode !== 0) continue
|
|
143
|
+
for (const line of out.stdout.toString().split('\n')) {
|
|
144
|
+
if (line.trim()) names.add(resolve(root, line.trim()))
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return [...names]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Extensions whose change can alter a rendered case (markup, behaviour, style).
|
|
151
|
+
const RENDER_EXTS = new Set(['.tsx', '.ts', '.jsx', '.js', '.css', '.mdx'])
|
|
152
|
+
// Trees under the package that never feed a render (so a change there scopes to
|
|
153
|
+
// nothing): docs, specs, the e2e suite, agent skills, build/CI tooling.
|
|
154
|
+
const NON_RENDER_DIRS =
|
|
155
|
+
/^(\.github|\.claude|contributing|docs|e2e|skills|scripts|tools|node_modules)(\/|$)/
|
|
156
|
+
|
|
157
|
+
/** Whether a changed file (absolute) can affect a rendered case. */
|
|
158
|
+
function isRenderRelevant(file: string, pkgRoot: string): boolean {
|
|
159
|
+
if (file !== pkgRoot && !file.startsWith(pkgRoot + sep)) return false
|
|
160
|
+
const rel = relative(pkgRoot, file)
|
|
161
|
+
if (NON_RENDER_DIRS.test(rel)) return false
|
|
162
|
+
if (/\.(test|spec)\.[tj]sx?$/.test(rel) || /\.test-d\.ts$/.test(rel))
|
|
163
|
+
return false
|
|
164
|
+
if (rel.endsWith('.d.ts')) return false
|
|
165
|
+
return RENDER_EXTS.has(extname(file))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The components affected by the changes since `ref`. Render-irrelevant changes
|
|
170
|
+
* (docs, specs, tests, tooling) scope to nothing. A render-relevant change is
|
|
171
|
+
* attributed to a component when it lies in that component's import closure; a
|
|
172
|
+
* render-relevant change that *no* closure claims — globally-inlined component
|
|
173
|
+
* CSS, the render pipeline, shared source — conservatively affects every
|
|
174
|
+
* component, so a regression is never silently skipped.
|
|
175
|
+
*/
|
|
176
|
+
async function changedScope(
|
|
177
|
+
pkgDir: string,
|
|
178
|
+
ref: string,
|
|
179
|
+
comps: { id: string; caseFile: string }[],
|
|
180
|
+
): Promise<Set<string>> {
|
|
181
|
+
const pkgRoot = resolve(pkgDir)
|
|
182
|
+
const changed = (await changedSince(pkgDir, ref)).filter((f) =>
|
|
183
|
+
isRenderRelevant(f, pkgRoot),
|
|
184
|
+
)
|
|
185
|
+
if (changed.length === 0) return new Set()
|
|
186
|
+
const closures = await componentClosures(comps)
|
|
187
|
+
const claimed = new Set<string>()
|
|
188
|
+
for (const files of closures.values()) for (const f of files) claimed.add(f)
|
|
189
|
+
// Any render-relevant change outside every closure ⇒ a global input changed.
|
|
190
|
+
if (changed.some((f) => !claimed.has(f))) {
|
|
191
|
+
return new Set(comps.map((c) => c.id))
|
|
192
|
+
}
|
|
193
|
+
const changedSet = new Set(changed)
|
|
194
|
+
const affected = new Set<string>()
|
|
195
|
+
for (const [id, files] of closures) {
|
|
196
|
+
for (const f of files) {
|
|
197
|
+
if (changedSet.has(f)) {
|
|
198
|
+
affected.add(id)
|
|
199
|
+
break
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return affected
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function runChecks(
|
|
207
|
+
pkgDir: string,
|
|
208
|
+
opts: CheckOptions,
|
|
209
|
+
): Promise<boolean> {
|
|
210
|
+
const { config } = await resolveConfig(pkgDir)
|
|
211
|
+
const baselines = baselineDir(pkgDir, config)
|
|
212
|
+
|
|
213
|
+
// Token conformance is a static parse — run it first, with no browser/server.
|
|
214
|
+
let tokenViolations = 0
|
|
215
|
+
if (opts.tokens) {
|
|
216
|
+
const { violations } = await checkTokens(pkgDir)
|
|
217
|
+
tokenViolations = violations.length
|
|
218
|
+
for (const v of violations) {
|
|
219
|
+
const rel = v.file.startsWith(`${pkgDir}/`)
|
|
220
|
+
? v.file.slice(pkgDir.length + 1)
|
|
221
|
+
: v.file
|
|
222
|
+
console.error(
|
|
223
|
+
` tokens ✗ ${rel}:${v.line}:${v.column} unknown token ${v.token}${v.hadFallback ? ' (fallback does not excuse it)' : ''}`,
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Structure best-practice checks are also static — no browser/server.
|
|
229
|
+
let structureErrors = 0
|
|
230
|
+
let structureWarnings = 0
|
|
231
|
+
if (opts.structure) {
|
|
232
|
+
const { findings } = await checkStructure(pkgDir, { strict: opts.strict })
|
|
233
|
+
for (const f of findings) {
|
|
234
|
+
const rel = f.file.startsWith(`${pkgDir}/`)
|
|
235
|
+
? f.file.slice(pkgDir.length + 1)
|
|
236
|
+
: f.file
|
|
237
|
+
const line = ` structure ${f.severity === 'error' ? '✗' : '⚠'} ${rel}: ${f.message} (${f.rule})`
|
|
238
|
+
if (f.severity === 'error') {
|
|
239
|
+
structureErrors++
|
|
240
|
+
console.error(line)
|
|
241
|
+
} else {
|
|
242
|
+
structureWarnings++
|
|
243
|
+
console.warn(line)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// SSR-safety is a static check too: render every case with `renderToString`
|
|
249
|
+
// (no browser, no server) and flag any that can't pre-render — a case that
|
|
250
|
+
// touches a browser-only API during render. Declared-`browserOnly` components
|
|
251
|
+
// are expected and skipped.
|
|
252
|
+
let ssrErrors = 0
|
|
253
|
+
if (opts.ssr) {
|
|
254
|
+
const { findings, declared } = await checkSsr(pkgDir)
|
|
255
|
+
for (const f of findings) {
|
|
256
|
+
ssrErrors++
|
|
257
|
+
const rel = f.file.startsWith(`${pkgDir}/`)
|
|
258
|
+
? f.file.slice(pkgDir.length + 1)
|
|
259
|
+
: f.file
|
|
260
|
+
console.error(
|
|
261
|
+
` ssr ✗ ${rel}: ${f.component}/${f.case} can't render before scripts (${f.error}). ` +
|
|
262
|
+
'Move browser APIs into effects/handlers, or declare the component browserOnly.',
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
if (declared)
|
|
266
|
+
console.log(` ssr: ${declared} case(s) declared browser-only`)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const staticErrors = tokenViolations + structureErrors + ssrErrors
|
|
270
|
+
|
|
271
|
+
// The browser phases (a11y + visual) are the only ones needing a live render.
|
|
272
|
+
if (!opts.a11y && !opts.visual) {
|
|
273
|
+
const ok = staticErrors === 0
|
|
274
|
+
const warn = structureWarnings ? `, ${structureWarnings} warning(s)` : ''
|
|
275
|
+
console.log(
|
|
276
|
+
ok
|
|
277
|
+
? `\n ✓ checks passed${warn}`
|
|
278
|
+
: `\n ✗ ${tokenViolations} token violation(s), ${structureErrors} structure error(s), ${ssrErrors} ssr error(s)${warn}`,
|
|
279
|
+
)
|
|
280
|
+
return ok
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const server = await startDisplayCase(pkgDir, { port: opts.port ?? 0 })
|
|
284
|
+
const base = String(server.url).replace(/\/$/, '')
|
|
285
|
+
const manifest = await fetch(`${base}/manifest.json`).then((r) => r.json())
|
|
286
|
+
|
|
287
|
+
// Resolve the change-scope for the render phases. `null` means no scoping —
|
|
288
|
+
// every component is checked (the default). Otherwise it is the set of
|
|
289
|
+
// component ids to check; an empty set short-circuits before any browser work.
|
|
290
|
+
let scope: Set<string> | null = null
|
|
291
|
+
if (opts.only || opts.changedRef) {
|
|
292
|
+
const comps = (manifest.components as ManifestComponent[]).map((c) => ({
|
|
293
|
+
id: c.id,
|
|
294
|
+
caseFile: resolve(pkgDir, c.caseFile),
|
|
295
|
+
}))
|
|
296
|
+
const sets: Set<string>[] = []
|
|
297
|
+
if (opts.only) {
|
|
298
|
+
const globs = opts.only.map((g) => new Glob(g))
|
|
299
|
+
sets.push(
|
|
300
|
+
new Set(
|
|
301
|
+
comps
|
|
302
|
+
.filter((c) => globs.some((g) => g.match(c.id)))
|
|
303
|
+
.map((c) => c.id),
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
if (opts.changedRef) {
|
|
308
|
+
sets.push(await changedScope(pkgDir, opts.changedRef, comps))
|
|
309
|
+
}
|
|
310
|
+
// Both flags present ⇒ a component must satisfy both (intersection).
|
|
311
|
+
scope = sets[0]
|
|
312
|
+
for (const s of sets.slice(1)) {
|
|
313
|
+
const next = new Set<string>()
|
|
314
|
+
for (const id of scope) if (s.has(id)) next.add(id)
|
|
315
|
+
scope = next
|
|
316
|
+
}
|
|
317
|
+
const basis = [
|
|
318
|
+
opts.only ? '--only' : null,
|
|
319
|
+
opts.changedRef ? `--changed=${opts.changedRef}` : null,
|
|
320
|
+
]
|
|
321
|
+
.filter(Boolean)
|
|
322
|
+
.join(' + ')
|
|
323
|
+
console.log(
|
|
324
|
+
` scope: ${scope.size} of ${comps.length} component(s) (${basis})`,
|
|
325
|
+
)
|
|
326
|
+
if (scope.size === 0) {
|
|
327
|
+
server.stop(true)
|
|
328
|
+
const ok = staticErrors === 0
|
|
329
|
+
const warn = structureWarnings ? `, ${structureWarnings} warning(s)` : ''
|
|
330
|
+
console.log(
|
|
331
|
+
ok
|
|
332
|
+
? `\n ✓ checks passed — no affected components${warn}`
|
|
333
|
+
: `\n ✗ ${tokenViolations} token violation(s), ${structureErrors} structure error(s), ${ssrErrors} ssr error(s)${warn}`,
|
|
334
|
+
)
|
|
335
|
+
return ok
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const targets: Target[] = []
|
|
340
|
+
for (const c of manifest.components) {
|
|
341
|
+
if (scope && !scope.has(c.id)) continue
|
|
342
|
+
for (const cs of c.cases) {
|
|
343
|
+
for (const theme of THEMES) {
|
|
344
|
+
targets.push({
|
|
345
|
+
componentId: c.id,
|
|
346
|
+
caseId: cs.id,
|
|
347
|
+
theme,
|
|
348
|
+
renderUrl: `${base}${cs.renderUrl}?theme=${theme}`,
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Shared scan parameters (also honored by the live in-app surface) so the
|
|
355
|
+
// panel and this gate agree on what counts as a violation. `enabled` is NOT
|
|
356
|
+
// consulted here — the gate runs whenever invoked.
|
|
357
|
+
const a11yThemes = config.a11y?.themes ?? THEMES
|
|
358
|
+
const a11yExclude = config.a11y?.exclude
|
|
359
|
+
|
|
360
|
+
const driver = await resolveDriver(config)
|
|
361
|
+
const diff = opts.visual ? await resolveDiff(config) : null
|
|
362
|
+
|
|
363
|
+
let a11yViolations = 0
|
|
364
|
+
let visualChanges = 0
|
|
365
|
+
let recorded = 0
|
|
366
|
+
const a11yReport: A11yReportEntry[] = []
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
for (const t of targets) {
|
|
370
|
+
const ctx: CaseContext = {
|
|
371
|
+
componentId: t.componentId,
|
|
372
|
+
caseId: t.caseId,
|
|
373
|
+
theme: t.theme,
|
|
374
|
+
width: VIEWPORT_WIDTH,
|
|
375
|
+
}
|
|
376
|
+
const page = await driver.open(t.renderUrl, ctx)
|
|
377
|
+
|
|
378
|
+
if (opts.a11y && a11yThemes.includes(t.theme)) {
|
|
379
|
+
const violations = await page.audit({ exclude: a11yExclude })
|
|
380
|
+
if (violations.length) {
|
|
381
|
+
a11yReport.push({
|
|
382
|
+
component: t.componentId,
|
|
383
|
+
case: t.caseId,
|
|
384
|
+
theme: t.theme,
|
|
385
|
+
violations,
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
for (const v of violations) {
|
|
389
|
+
a11yViolations++
|
|
390
|
+
const sev = v.impact ? `${v.impact} ` : ''
|
|
391
|
+
console.error(
|
|
392
|
+
` a11y ✗ ${t.componentId}/${t.caseId} [${t.theme}] ${sev}${v.id}: ${v.help} (${v.nodes} node(s))`,
|
|
393
|
+
)
|
|
394
|
+
for (const line of a11yDetailLines(v)) console.error(line)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (opts.visual && diff) {
|
|
399
|
+
const shot = await page.screenshot()
|
|
400
|
+
const file = join(
|
|
401
|
+
baselines,
|
|
402
|
+
t.componentId,
|
|
403
|
+
`${t.caseId}.${t.theme}.png`,
|
|
404
|
+
)
|
|
405
|
+
if (opts.update || !(await Bun.file(file).exists())) {
|
|
406
|
+
await mkdir(dirname(file), { recursive: true })
|
|
407
|
+
await Bun.write(file, shot)
|
|
408
|
+
recorded++
|
|
409
|
+
} else {
|
|
410
|
+
const baseline = new Uint8Array(await Bun.file(file).arrayBuffer())
|
|
411
|
+
const res = await diff(
|
|
412
|
+
{ baseline, actual: shot },
|
|
413
|
+
{ ...ctx, baselinePath: file },
|
|
414
|
+
)
|
|
415
|
+
if (res.changed) {
|
|
416
|
+
visualChanges++
|
|
417
|
+
if (res.diffImage) {
|
|
418
|
+
await Bun.write(
|
|
419
|
+
file.replace(/\.png$/, '.diff.png'),
|
|
420
|
+
res.diffImage,
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
console.error(
|
|
424
|
+
` visual ✗ ${t.componentId}/${t.caseId} [${t.theme}] differs from baseline`,
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
await page.dispose()
|
|
431
|
+
}
|
|
432
|
+
} finally {
|
|
433
|
+
await driver.close()
|
|
434
|
+
server.stop(true)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (opts.visual && recorded) console.log(` recorded ${recorded} baseline(s)`)
|
|
438
|
+
|
|
439
|
+
// Persist the full run (every failing variant, with per-node detail) so an
|
|
440
|
+
// agent or human can read the exact failing colours/elements later without
|
|
441
|
+
// re-running the browser. Written under the gitignored cache dir, overwriting
|
|
442
|
+
// the prior run; a clean run leaves an empty `results` so the file is current.
|
|
443
|
+
if (opts.a11y) {
|
|
444
|
+
const reportPath = join(cacheDir(pkgDir), 'a11y', 'last-check.json')
|
|
445
|
+
await mkdir(dirname(reportPath), { recursive: true })
|
|
446
|
+
await Bun.write(
|
|
447
|
+
reportPath,
|
|
448
|
+
`${JSON.stringify(
|
|
449
|
+
{ scannedAt: Date.now(), total: a11yViolations, results: a11yReport },
|
|
450
|
+
null,
|
|
451
|
+
2,
|
|
452
|
+
)}\n`,
|
|
453
|
+
)
|
|
454
|
+
const rel = reportPath.startsWith(`${pkgDir}/`)
|
|
455
|
+
? reportPath.slice(pkgDir.length + 1)
|
|
456
|
+
: reportPath
|
|
457
|
+
console.log(` a11y detail → ${rel}${a11yViolations ? '' : ' (clean run)'}`)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const ok =
|
|
461
|
+
a11yViolations === 0 &&
|
|
462
|
+
visualChanges === 0 &&
|
|
463
|
+
tokenViolations === 0 &&
|
|
464
|
+
structureErrors === 0 &&
|
|
465
|
+
ssrErrors === 0
|
|
466
|
+
const warn = structureWarnings ? `, ${structureWarnings} warning(s)` : ''
|
|
467
|
+
console.log(
|
|
468
|
+
ok
|
|
469
|
+
? `\n ✓ checks passed${warn}`
|
|
470
|
+
: `\n ✗ ${a11yViolations} a11y violation(s), ${visualChanges} visual change(s), ${tokenViolations} token violation(s), ${structureErrors} structure error(s), ${ssrErrors} ssr error(s)${warn}`,
|
|
471
|
+
)
|
|
472
|
+
return ok
|
|
473
|
+
}
|