similarbuild 0.1.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +301 -0
  4. package/bin/install.js +256 -0
  5. package/lib/copy-templates.mjs +52 -0
  6. package/lib/install-deps.mjs +62 -0
  7. package/lib/prompt-config.mjs +83 -0
  8. package/lib/verify-env.mjs +19 -0
  9. package/package.json +63 -0
  10. package/scripts/sync-templates.mjs +71 -0
  11. package/templates/commands/build-page.md +490 -0
  12. package/templates/commands/build-site.md +548 -0
  13. package/templates/commands/clip-section.md +519 -0
  14. package/templates/memory/anti-patterns.md +212 -0
  15. package/templates/memory/design-knowledge.md +225 -0
  16. package/templates/memory/fixes.md +163 -0
  17. package/templates/memory/patterns.md +681 -0
  18. package/templates/presets/shopify-section.yaml +51 -0
  19. package/templates/presets/wp-elementor.yaml +49 -0
  20. package/templates/reports/fixtures/mock-run-1.json +115 -0
  21. package/templates/reports/fixtures/mock-run-2.json +72 -0
  22. package/templates/reports/report-renderer.mjs +218 -0
  23. package/templates/reports/report-template.html +571 -0
  24. package/templates/skills/sb-build-shopify/SKILL.md +104 -0
  25. package/templates/skills/sb-build-shopify/references/shopify-build-rules.md +563 -0
  26. package/templates/skills/sb-build-shopify/scripts/build-shopify.mjs +637 -0
  27. package/templates/skills/sb-build-shopify/scripts/tests/test-build-shopify.mjs +424 -0
  28. package/templates/skills/sb-build-wp/SKILL.md +83 -0
  29. package/templates/skills/sb-build-wp/references/wp-build-rules.md +376 -0
  30. package/templates/skills/sb-build-wp/scripts/build-wp.mjs +327 -0
  31. package/templates/skills/sb-build-wp/scripts/tests/test-build-wp.mjs +224 -0
  32. package/templates/skills/sb-compare-visual/SKILL.md +121 -0
  33. package/templates/skills/sb-compare-visual/scripts/compare-visual.mjs +387 -0
  34. package/templates/skills/sb-compare-visual/scripts/lib/compare-tokens.mjs +273 -0
  35. package/templates/skills/sb-compare-visual/scripts/tests/test-compare-tokens.mjs +350 -0
  36. package/templates/skills/sb-compare-visual/scripts/tests/test-compare-visual.mjs +626 -0
  37. package/templates/skills/sb-crawl-and-list/SKILL.md +99 -0
  38. package/templates/skills/sb-crawl-and-list/scripts/crawl-and-list.mjs +437 -0
  39. package/templates/skills/sb-crawl-and-list/scripts/lib/blocklist-filter.mjs +176 -0
  40. package/templates/skills/sb-crawl-and-list/scripts/lib/fallback-crawler.mjs +107 -0
  41. package/templates/skills/sb-crawl-and-list/scripts/lib/page-classifier.mjs +89 -0
  42. package/templates/skills/sb-crawl-and-list/scripts/lib/sitemap-parser.mjs +118 -0
  43. package/templates/skills/sb-crawl-and-list/scripts/tests/test-blocklist-filter.mjs +204 -0
  44. package/templates/skills/sb-crawl-and-list/scripts/tests/test-crawl-and-list.mjs +276 -0
  45. package/templates/skills/sb-crawl-and-list/scripts/tests/test-fallback-crawler.mjs +243 -0
  46. package/templates/skills/sb-crawl-and-list/scripts/tests/test-page-classifier.mjs +120 -0
  47. package/templates/skills/sb-crawl-and-list/scripts/tests/test-sitemap-parser.mjs +157 -0
  48. package/templates/skills/sb-extract-assets/SKILL.md +112 -0
  49. package/templates/skills/sb-extract-assets/scripts/extract-assets.mjs +484 -0
  50. package/templates/skills/sb-extract-assets/scripts/tests/test-extract-assets.mjs +112 -0
  51. package/templates/skills/sb-inspect-live/SKILL.md +105 -0
  52. package/templates/skills/sb-inspect-live/scripts/inspect-live.mjs +693 -0
  53. package/templates/skills/sb-inspect-live/scripts/tests/test-inspect-live.mjs +181 -0
  54. package/templates/skills/sb-review-checks/SKILL.md +113 -0
  55. package/templates/skills/sb-review-checks/references/review-rules.md +195 -0
  56. package/templates/skills/sb-review-checks/scripts/lib/anti-patterns.mjs +379 -0
  57. package/templates/skills/sb-review-checks/scripts/lib/cross-reference.mjs +115 -0
  58. package/templates/skills/sb-review-checks/scripts/lib/design-quality.mjs +541 -0
  59. package/templates/skills/sb-review-checks/scripts/review-checks.mjs +250 -0
  60. package/templates/skills/sb-review-checks/scripts/tests/test-anti-patterns.mjs +343 -0
  61. package/templates/skills/sb-review-checks/scripts/tests/test-cross-reference.mjs +170 -0
  62. package/templates/skills/sb-review-checks/scripts/tests/test-design-quality.mjs +493 -0
  63. package/templates/skills/sb-review-checks/scripts/tests/test-review-checks.mjs +267 -0
  64. package/templates/skills/sb-tweak/SKILL.md +130 -0
  65. package/templates/skills/sb-tweak/references/tweak-patterns.md +157 -0
  66. package/templates/skills/sb-tweak/scripts/lib/diff-summarizer.mjs +140 -0
  67. package/templates/skills/sb-tweak/scripts/lib/element-locator.mjs +507 -0
  68. package/templates/skills/sb-tweak/scripts/lib/intent-parser.mjs +324 -0
  69. package/templates/skills/sb-tweak/scripts/tests/test-diff-summarizer.mjs +248 -0
  70. package/templates/skills/sb-tweak/scripts/tests/test-element-locator.mjs +418 -0
  71. package/templates/skills/sb-tweak/scripts/tests/test-intent-parser.mjs +496 -0
  72. package/templates/skills/sb-tweak/scripts/tests/test-tweak.mjs +407 -0
  73. package/templates/skills/sb-tweak/scripts/tweak.mjs +656 -0
  74. package/templates/skills/sb-validate-render/SKILL.md +120 -0
  75. package/templates/skills/sb-validate-render/scripts/tests/test-validate-render.mjs +304 -0
  76. package/templates/skills/sb-validate-render/scripts/validate-render.mjs +645 -0
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ // test-cross-reference.mjs — Pure tests for the cross-reference layer. No deps.
3
+
4
+ import { fileURLToPath } from 'node:url'
5
+ import { dirname, resolve } from 'node:path'
6
+ import { strict as assert } from 'node:assert'
7
+
8
+ const here = dirname(fileURLToPath(import.meta.url))
9
+ const LIB = resolve(here, '..', 'lib', 'cross-reference.mjs')
10
+ const { applyCrossReference, sortByPriority } = await import(LIB)
11
+
12
+ let passed = 0
13
+ let failed = 0
14
+
15
+ function test(name, fn) {
16
+ try {
17
+ fn()
18
+ process.stdout.write(`ok - ${name}\n`)
19
+ passed++
20
+ } catch (err) {
21
+ process.stdout.write(`not ok - ${name}\n ${err.message}\n`)
22
+ failed++
23
+ }
24
+ }
25
+
26
+ // ─── applyCrossReference ────────────────────────────────────────────────────
27
+
28
+ test('returns input unchanged when compareDiffs is null/undefined/[]', () => {
29
+ const input = [{ severity: 'medium', location: 'x', issue: 'y', candidateFix: 'z', group: 'a11y', id: 'foo' }]
30
+ assert.deepEqual(applyCrossReference(input, null), input)
31
+ assert.deepEqual(applyCrossReference(input, undefined), input)
32
+ assert.deepEqual(applyCrossReference(input, []), input)
33
+ })
34
+
35
+ test('returns input unchanged when no diff is severity high', () => {
36
+ const violations = [{ severity: 'medium', location: '<button>', issue: 'x', candidateFix: 'y', group: 'a11y', id: 'a' }]
37
+ const diffs = [{ area: 'button', severity: 'low', issue: 'x', live: 1, build: 2 }]
38
+ assert.deepEqual(applyCrossReference(violations, diffs), violations)
39
+ })
40
+
41
+ test('escalates a violation that overlaps a high-severity diff', () => {
42
+ const violations = [{ severity: 'medium', location: '<button class="cta">', issue: 'x', candidateFix: 'y', group: 'a11y', id: 'a11y-button-type' }]
43
+ const diffs = [{ area: 'button', severity: 'high', issue: 'background-color drift', deltaPx: null }]
44
+ const out = applyCrossReference(violations, diffs)
45
+ assert.equal(out.length, 1)
46
+ assert.equal(out[0].severity, 'high')
47
+ assert.ok(out[0].correlatedDiff)
48
+ assert.equal(out[0].correlatedDiff.area, 'button')
49
+ })
50
+
51
+ test('does NOT escalate a non-overlapping violation', () => {
52
+ const violations = [{ severity: 'medium', location: '<img alt="">', issue: 'x', candidateFix: 'y', group: 'a11y', id: 'a11y-img-alt' }]
53
+ const diffs = [{ area: 'button', severity: 'high', issue: 'color drift' }]
54
+ const out = applyCrossReference(violations, diffs)
55
+ assert.equal(out.length, 2) // one synthetic added for uncorrelated diff
56
+ // Original violation unchanged
57
+ const orig = out.find((v) => v.id === 'a11y-img-alt')
58
+ assert.equal(orig.severity, 'medium')
59
+ assert.ok(!orig.correlatedDiff)
60
+ })
61
+
62
+ test('synthesizes a violation for uncorrelated high-severity diffs', () => {
63
+ const violations = []
64
+ const diffs = [{ area: 'h1', severity: 'high', issue: 'font-size drift', live: '27px', build: '24px', deltaPx: -3 }]
65
+ const out = applyCrossReference(violations, diffs)
66
+ assert.equal(out.length, 1)
67
+ assert.equal(out[0].group, 'visual-diff')
68
+ assert.equal(out[0].id, 'diff-h1')
69
+ assert.equal(out[0].severity, 'high')
70
+ assert.match(out[0].candidateFix, /27px/)
71
+ assert.match(out[0].candidateFix, /24px/)
72
+ })
73
+
74
+ test('synthetic fix mentions live and build values', () => {
75
+ const out = applyCrossReference([], [
76
+ { area: 'button', severity: 'high', issue: 'bg drift', live: '#d8112a', build: '#d82a11' },
77
+ ])
78
+ assert.match(out[0].candidateFix, /#d8112a/)
79
+ assert.match(out[0].candidateFix, /#d82a11/)
80
+ })
81
+
82
+ test('one violation can correlate to a high diff and skip the synthetic for that area', () => {
83
+ const violations = [{ severity: 'medium', location: '<button class="cta">', issue: 'x', candidateFix: 'y', group: 'a11y', id: 'a11y-button-type' }]
84
+ const diffs = [
85
+ { area: 'button', severity: 'high', issue: 'bg color drift' },
86
+ { area: 'h1', severity: 'high', issue: 'size drift', live: '27px', build: '24px', deltaPx: -3 },
87
+ ]
88
+ const out = applyCrossReference(violations, diffs)
89
+ // 1 correlated + 1 synthetic for h1
90
+ assert.equal(out.length, 2)
91
+ const correlated = out.find((v) => v.correlatedDiff?.area === 'button')
92
+ assert.ok(correlated)
93
+ const synth = out.find((v) => v.group === 'visual-diff' && v.id === 'diff-h1')
94
+ assert.ok(synth)
95
+ })
96
+
97
+ test('correlated violations preserve their group and candidateFix', () => {
98
+ const violations = [{ severity: 'medium', location: '<button class="cta">', issue: 'x', candidateFix: 'add type="button"', group: 'a11y', id: 'a11y-button-type' }]
99
+ const diffs = [{ area: 'button', severity: 'high', issue: 'bg drift' }]
100
+ const out = applyCrossReference(violations, diffs)
101
+ assert.equal(out[0].group, 'a11y')
102
+ assert.equal(out[0].candidateFix, 'add type="button"')
103
+ })
104
+
105
+ // ─── sortByPriority ─────────────────────────────────────────────────────────
106
+
107
+ test('sortByPriority: high before medium before low', () => {
108
+ const out = sortByPriority([
109
+ { severity: 'low' }, { severity: 'high' }, { severity: 'medium' },
110
+ ])
111
+ assert.deepEqual(out.map((v) => v.severity), ['high', 'medium', 'low'])
112
+ })
113
+
114
+ test('sortByPriority: correlated comes first within same severity', () => {
115
+ const out = sortByPriority([
116
+ { severity: 'high', id: 'a' },
117
+ { severity: 'high', id: 'b', correlatedDiff: { area: 'button' } },
118
+ { severity: 'high', id: 'c' },
119
+ ])
120
+ assert.equal(out[0].id, 'b')
121
+ })
122
+
123
+ test('sortByPriority: stable for equal entries', () => {
124
+ const out = sortByPriority([
125
+ { severity: 'medium', id: '1' },
126
+ { severity: 'medium', id: '2' },
127
+ { severity: 'medium', id: '3' },
128
+ ])
129
+ assert.deepEqual(out.map((v) => v.id), ['1', '2', '3'])
130
+ })
131
+
132
+ test('sortByPriority: returns a new array (no mutation)', () => {
133
+ const input = [{ severity: 'low' }, { severity: 'high' }]
134
+ const out = sortByPriority(input)
135
+ assert.notEqual(out, input)
136
+ assert.equal(input[0].severity, 'low') // unchanged
137
+ })
138
+
139
+ // ─── Area-hint coverage ─────────────────────────────────────────────────────
140
+
141
+ test('locationMatchesArea via h1 hint', () => {
142
+ const out = applyCrossReference(
143
+ [{ severity: 'low', location: '<h1> heading', issue: 'x', candidateFix: 'y', group: 'a11y', id: 'foo' }],
144
+ [{ area: 'h1', severity: 'high', issue: 'size' }],
145
+ )
146
+ assert.equal(out[0].severity, 'high')
147
+ })
148
+
149
+ test('locationMatchesArea via images hint (svg)', () => {
150
+ const out = applyCrossReference(
151
+ [{ severity: 'medium', location: '<svg viewBox="0 0 600 600">', issue: 'x', candidateFix: 'y', group: 'anti-pattern', id: '#8' }],
152
+ [{ area: 'images', severity: 'high', issue: 'count differs' }],
153
+ )
154
+ assert.equal(out[0].severity, 'high')
155
+ })
156
+
157
+ test('locationMatchesArea via geometry hint (hero)', () => {
158
+ const out = applyCrossReference(
159
+ [{ severity: 'medium', location: 'hero <img src="x">', issue: 'x', candidateFix: 'y', group: 'performance', id: 'perf-hero-preload' }],
160
+ [{ area: 'geometry', severity: 'high', issue: 'overflow' }],
161
+ )
162
+ assert.equal(out[0].severity, 'high')
163
+ })
164
+
165
+ if (failed > 0) {
166
+ process.stdout.write(`\n${failed} failed, ${passed} passed\n`)
167
+ process.exit(1)
168
+ }
169
+ process.stdout.write(`\n${passed} passed\n`)
170
+ process.exit(0)
@@ -0,0 +1,493 @@
1
+ #!/usr/bin/env node
2
+ // test-design-quality.mjs — Pure-function tests for the 12 a11y/perf/web checks.
3
+ // All these checks rely on cheerio for HTML parsing — gracefully skipped when
4
+ // cheerio isn't installed.
5
+
6
+ import { fileURLToPath } from 'node:url'
7
+ import { dirname, resolve } from 'node:path'
8
+ import { strict as assert } from 'node:assert'
9
+
10
+ const here = dirname(fileURLToPath(import.meta.url))
11
+ const LIB = resolve(here, '..', 'lib', 'design-quality.mjs')
12
+ const {
13
+ checkButtonAria,
14
+ checkImgAlt,
15
+ checkHeadingHierarchy,
16
+ checkButtonType,
17
+ checkNavSemantic,
18
+ checkImgLazy,
19
+ checkHeroPreload,
20
+ checkFontDisplay,
21
+ checkFetchpriorityHero,
22
+ checkSrcsetResponsive,
23
+ checkModalDialog,
24
+ checkPreconnectFonts,
25
+ runDesignQuality,
26
+ findHeroCssBackgroundImage,
27
+ findHeroImgWithConfidence,
28
+ } = await import(LIB)
29
+
30
+ let cheerio = null
31
+ try { cheerio = await import('cheerio') } catch {}
32
+
33
+ let passed = 0
34
+ let failed = 0
35
+
36
+ function test(name, fn) {
37
+ try {
38
+ fn()
39
+ process.stdout.write(`ok - ${name}\n`)
40
+ passed++
41
+ } catch (err) {
42
+ process.stdout.write(`not ok - ${name}\n ${err.message}\n`)
43
+ failed++
44
+ }
45
+ }
46
+
47
+ function skip(name, reason) {
48
+ process.stdout.write(`ok - ${name} # SKIP ${reason}\n`)
49
+ passed++
50
+ }
51
+
52
+ function ctxFromHtml(html, preset = 'wp-elementor') {
53
+ if (!cheerio) return null
54
+ return { html, css: '', $: cheerio.load(html), preset, styleBlocks: [] }
55
+ }
56
+
57
+ function ctxFromHtmlAndCss(html, css, preset = 'wp-elementor') {
58
+ if (!cheerio) return null
59
+ return { html, css, $: cheerio.load(html), preset, styleBlocks: [{ css, htmlOffset: 0 }] }
60
+ }
61
+
62
+ if (!cheerio) {
63
+ skip('all design-quality tests', 'cheerio not installed')
64
+ process.stdout.write(`\n${passed} passed (skipped)\n`)
65
+ process.exit(0)
66
+ }
67
+
68
+ // ─── a11y-button-aria ───────────────────────────────────────────────────────
69
+
70
+ test('a11y-button-aria: flags icon-only button without aria-label', () => {
71
+ const ctx = ctxFromHtml('<button class="menu"><svg></svg></button>')
72
+ const v = checkButtonAria(ctx)
73
+ assert.equal(v.length, 1)
74
+ assert.equal(v[0].id, 'a11y-button-aria')
75
+ assert.equal(v[0].severity, 'high')
76
+ assert.match(v[0].candidateFix, /aria-label="Open menu"/)
77
+ })
78
+
79
+ test('a11y-button-aria: flags emoji-glyph button (☰)', () => {
80
+ const ctx = ctxFromHtml('<button>☰</button>')
81
+ const v = checkButtonAria(ctx)
82
+ assert.equal(v.length, 1)
83
+ })
84
+
85
+ test('a11y-button-aria: passes when aria-label present', () => {
86
+ const ctx = ctxFromHtml('<button aria-label="Open menu"><svg></svg></button>')
87
+ assert.equal(checkButtonAria(ctx).length, 0)
88
+ })
89
+
90
+ test('a11y-button-aria: passes for buttons with text', () => {
91
+ const ctx = ctxFromHtml('<button>Submit</button>')
92
+ assert.equal(checkButtonAria(ctx).length, 0)
93
+ })
94
+
95
+ test('a11y-button-aria: passes when inner img has alt', () => {
96
+ const ctx = ctxFromHtml('<button><img src="x" alt="Cart"></button>')
97
+ assert.equal(checkButtonAria(ctx).length, 0)
98
+ })
99
+
100
+ // ─── a11y-img-alt ───────────────────────────────────────────────────────────
101
+
102
+ test('a11y-img-alt: flags img without alt', () => {
103
+ const ctx = ctxFromHtml('<img src="hero.jpg">')
104
+ const v = checkImgAlt(ctx)
105
+ assert.equal(v.length, 1)
106
+ assert.equal(v[0].id, 'a11y-img-alt')
107
+ assert.equal(v[0].severity, 'high')
108
+ })
109
+
110
+ test('a11y-img-alt: passes with empty alt', () => {
111
+ const ctx = ctxFromHtml('<img src="x.jpg" alt="">')
112
+ assert.equal(checkImgAlt(ctx).length, 0)
113
+ })
114
+
115
+ test('a11y-img-alt: passes with descriptive alt', () => {
116
+ const ctx = ctxFromHtml('<img src="x.jpg" alt="Hero product">')
117
+ assert.equal(checkImgAlt(ctx).length, 0)
118
+ })
119
+
120
+ // ─── a11y-heading-hierarchy ─────────────────────────────────────────────────
121
+
122
+ test('a11y-heading-hierarchy: flags multiple h1', () => {
123
+ const ctx = ctxFromHtml('<h1>A</h1><p>x</p><h1>B</h1>')
124
+ const v = checkHeadingHierarchy(ctx)
125
+ assert.ok(v.some((d) => /multiple <h1>/.test(d.issue)))
126
+ })
127
+
128
+ test('a11y-heading-hierarchy: flags level skip h2 → h4', () => {
129
+ const ctx = ctxFromHtml('<h2>A</h2><h4>B</h4>')
130
+ const v = checkHeadingHierarchy(ctx)
131
+ assert.ok(v.some((d) => /skip/.test(d.issue)))
132
+ })
133
+
134
+ test('a11y-heading-hierarchy: flags first heading h3 (no h1/h2 above)', () => {
135
+ const ctx = ctxFromHtml('<h3>Section</h3>')
136
+ const v = checkHeadingHierarchy(ctx)
137
+ assert.ok(v.some((d) => /starts at <h3>/.test(d.issue)))
138
+ })
139
+
140
+ test('a11y-heading-hierarchy: passes for clean h1 → h2 → h3', () => {
141
+ const ctx = ctxFromHtml('<h1>A</h1><h2>B</h2><h3>C</h3>')
142
+ assert.equal(checkHeadingHierarchy(ctx).length, 0)
143
+ })
144
+
145
+ test('a11y-heading-hierarchy: empty doc emits no violation', () => {
146
+ const ctx = ctxFromHtml('<p>no headings</p>')
147
+ assert.equal(checkHeadingHierarchy(ctx).length, 0)
148
+ })
149
+
150
+ // ─── a11y-button-type ───────────────────────────────────────────────────────
151
+
152
+ test('a11y-button-type: flags button without type', () => {
153
+ const ctx = ctxFromHtml('<button>Click me</button>')
154
+ const v = checkButtonType(ctx)
155
+ assert.equal(v.length, 1)
156
+ assert.equal(v[0].id, 'a11y-button-type')
157
+ assert.match(v[0].candidateFix, /type="button"/)
158
+ })
159
+
160
+ test('a11y-button-type: passes type=button', () => {
161
+ const ctx = ctxFromHtml('<button type="button">Click</button>')
162
+ assert.equal(checkButtonType(ctx).length, 0)
163
+ })
164
+
165
+ test('a11y-button-type: passes type=submit (intentional)', () => {
166
+ const ctx = ctxFromHtml('<button type="submit">Submit</button>')
167
+ assert.equal(checkButtonType(ctx).length, 0)
168
+ })
169
+
170
+ // ─── a11y-nav-semantic ──────────────────────────────────────────────────────
171
+
172
+ test('a11y-nav-semantic: flags <div class="nav"> with multiple links', () => {
173
+ const ctx = ctxFromHtml('<div class="nav"><a href="/">Home</a><a href="/x">X</a></div>')
174
+ const v = checkNavSemantic(ctx)
175
+ assert.equal(v.length, 1)
176
+ assert.equal(v[0].id, 'a11y-nav-semantic')
177
+ assert.match(v[0].candidateFix, /<nav class="nav" aria-label="Main">/)
178
+ })
179
+
180
+ test('a11y-nav-semantic: passes when wrapped in <nav>', () => {
181
+ const ctx = ctxFromHtml('<nav><div class="nav-inner"><a href="/">A</a><a href="/b">B</a></div></nav>')
182
+ assert.equal(checkNavSemantic(ctx).length, 0)
183
+ })
184
+
185
+ test('a11y-nav-semantic: ignores footer nav', () => {
186
+ const ctx = ctxFromHtml('<div class="footer-nav"><a href="/">A</a><a href="/b">B</a></div>')
187
+ assert.equal(checkNavSemantic(ctx).length, 0)
188
+ })
189
+
190
+ // ─── perf-img-lazy ──────────────────────────────────────────────────────────
191
+
192
+ test('perf-img-lazy: flags non-hero img without loading', () => {
193
+ // First img is the hero; second should be flagged
194
+ const ctx = ctxFromHtml('<img src="hero.jpg" alt="hero"><img src="other.jpg" alt="other">')
195
+ const v = checkImgLazy(ctx)
196
+ assert.equal(v.length, 1)
197
+ assert.equal(v[0].id, 'perf-img-lazy')
198
+ assert.match(v[0].location, /other\.jpg/)
199
+ })
200
+
201
+ test('perf-img-lazy: passes when loading=lazy', () => {
202
+ const ctx = ctxFromHtml('<img src="hero.jpg"><img src="other.jpg" loading="lazy">')
203
+ assert.equal(checkImgLazy(ctx).length, 0)
204
+ })
205
+
206
+ test('perf-img-lazy: respects fetchpriority=high as hero indicator', () => {
207
+ const ctx = ctxFromHtml('<img src="other.jpg" loading="lazy"><img src="hero.jpg" fetchpriority="high">')
208
+ // Hero is the second one (fetchpriority=high). First is non-hero with lazy → ok.
209
+ assert.equal(checkImgLazy(ctx).length, 0)
210
+ })
211
+
212
+ // ─── perf-hero-preload ──────────────────────────────────────────────────────
213
+
214
+ test('perf-hero-preload: flags missing preload', () => {
215
+ const ctx = ctxFromHtml('<img src="hero.jpg" alt="hero">')
216
+ const v = checkHeroPreload(ctx)
217
+ assert.equal(v.length, 1)
218
+ assert.equal(v[0].id, 'perf-hero-preload')
219
+ assert.match(v[0].candidateFix, /<link rel="preload"/)
220
+ })
221
+
222
+ test('perf-hero-preload: passes when matching preload exists', () => {
223
+ const ctx = ctxFromHtml('<link rel="preload" as="image" href="hero.jpg"><img src="hero.jpg">')
224
+ assert.equal(checkHeroPreload(ctx).length, 0)
225
+ })
226
+
227
+ // Pattern #25 — CSS background-image hero detection.
228
+
229
+ test('perf-hero-preload: flags hero CSS background-image without preload', () => {
230
+ const html = '<div class="ai-hero"><img src="logo.png" alt="brand logo"></div>'
231
+ const css = '.ai-hero { background-image: url("photo.jpg"); height: 480px; }'
232
+ const ctx = ctxFromHtmlAndCss(html, css)
233
+ const v = checkHeroPreload(ctx)
234
+ assert.equal(v.length, 1)
235
+ assert.equal(v[0].id, 'perf-hero-preload')
236
+ assert.match(v[0].location, /background-image/)
237
+ assert.match(v[0].candidateFix, /preload" as="image" href="photo\.jpg"/)
238
+ })
239
+
240
+ test('perf-hero-preload: passes when hero CSS bg has matching preload', () => {
241
+ const html = '<link rel="preload" as="image" href="photo.jpg"><div class="ai-hero"><img src="logo.png" alt="logo"></div>'
242
+ const css = '.ai-hero { background-image: url("photo.jpg"); }'
243
+ const ctx = ctxFromHtmlAndCss(html, css)
244
+ assert.equal(checkHeroPreload(ctx).length, 0)
245
+ })
246
+
247
+ test('perf-hero-preload: hero CSS-bg + LOGO <img> wide → flag bg only, NOT logo', () => {
248
+ const html = '<div class="ai-hero"><img src="logo.png" alt="brand" width="1200"></div>'
249
+ const css = '.ai-hero { background-image: url("hero-photo.jpg"); }'
250
+ const ctx = ctxFromHtmlAndCss(html, css)
251
+ const v = checkHeroPreload(ctx)
252
+ assert.equal(v.length, 1)
253
+ assert.match(v[0].location, /background-image/)
254
+ // Make sure the candidateFix names the bg URL, not the logo
255
+ assert.match(v[0].candidateFix, /hero-photo\.jpg/)
256
+ assert.doesNotMatch(v[0].candidateFix, /logo\.png/)
257
+ })
258
+
259
+ test('perf-hero-preload: skips bare first <img> when no signal (logo case)', () => {
260
+ // No CSS bg, no fetchpriority, no "hero"-named class/src/alt, no width
261
+ // → treated as fallback tier and skipped (Pattern #25 prevents flagging logos)
262
+ const ctx = ctxFromHtml('<img src="brand.png" alt="brand">')
263
+ assert.equal(checkHeroPreload(ctx).length, 0)
264
+ })
265
+
266
+ test('perf-hero-preload: <img> ≥800px width without preload → flag (original case)', () => {
267
+ const ctx = ctxFromHtml('<img src="banner.jpg" alt="brand" width="1200">')
268
+ const v = checkHeroPreload(ctx)
269
+ assert.equal(v.length, 1)
270
+ assert.match(v[0].candidateFix, /banner\.jpg/)
271
+ })
272
+
273
+ test('perf-hero-preload: BEM modifier rule (.foo__bar) is NOT mistaken for hero scope', () => {
274
+ // BEM-modifier element with bg-image is NOT the root scope. Fall through
275
+ // to <img>; the lone logo with no signal lands in fallback tier → skip.
276
+ const html = '<img src="logo.png" alt="logo">'
277
+ const css = '.ai-hero__overlay { background-image: url("texture.png"); }'
278
+ const ctx = ctxFromHtmlAndCss(html, css)
279
+ assert.equal(checkHeroPreload(ctx).length, 0)
280
+ })
281
+
282
+ test('perf-hero-preload: descendant selector (.foo .bar) is NOT mistaken for hero scope', () => {
283
+ const html = '<img src="logo.png" alt="logo">'
284
+ const css = '.ai-hero .inner { background-image: url("inner.jpg"); }'
285
+ const ctx = ctxFromHtmlAndCss(html, css)
286
+ assert.equal(checkHeroPreload(ctx).length, 0)
287
+ })
288
+
289
+ // findHeroCssBackgroundImage helper — direct unit tests.
290
+
291
+ test('findHeroCssBackgroundImage: detects single root-class rule with bg URL', () => {
292
+ const r = findHeroCssBackgroundImage('.hero { background-image: url("a.jpg"); }')
293
+ assert.deepEqual(r, { selector: '.hero', url: 'a.jpg' })
294
+ })
295
+
296
+ test('findHeroCssBackgroundImage: handles double-quoted, single-quoted, unquoted URLs', () => {
297
+ assert.equal(findHeroCssBackgroundImage(`.h { background-image: url('x.jpg'); }`).url, 'x.jpg')
298
+ assert.equal(findHeroCssBackgroundImage(`.h { background-image: url(x.jpg); }`).url, 'x.jpg')
299
+ assert.equal(findHeroCssBackgroundImage(`.h { background: url("x.jpg") center/cover; }`).url, 'x.jpg')
300
+ })
301
+
302
+ test('findHeroCssBackgroundImage: rejects descendant combinator', () => {
303
+ assert.equal(findHeroCssBackgroundImage('.hero .child { background-image: url("a.jpg"); }'), null)
304
+ })
305
+
306
+ test('findHeroCssBackgroundImage: rejects __ BEM modifier', () => {
307
+ assert.equal(findHeroCssBackgroundImage('.hero__overlay { background-image: url("a.jpg"); }'), null)
308
+ })
309
+
310
+ test('findHeroCssBackgroundImage: rejects @media wrapper rules', () => {
311
+ // The simple regex doesn't open into @media — still returns null gracefully
312
+ const css = '@media (min-width: 600px) { .hero { background-image: url("a.jpg"); } }'
313
+ // The naive matcher might match, but the inner selector is fine — at-rule
314
+ // wrapper has braces around it. The first matched block has selector
315
+ // "@media (min-width: 600px)" and body containing ".hero { ... }". Skipped
316
+ // by the @-rule guard. Then the body is left as-is and re-scanned — but
317
+ // the regex stops after first non-overlapping matches. Behavior: skip.
318
+ // Confirm we don't blow up; inside-bracket bg may or may not be picked up
319
+ // depending on regex quirks. Acceptable as long as we don't crash.
320
+ const r = findHeroCssBackgroundImage(css)
321
+ // It MAY pick up the inner .hero rule; either way, output must be null or
322
+ // an object with `.hero` selector — never crash.
323
+ assert.ok(r === null || (r && typeof r.selector === 'string'))
324
+ })
325
+
326
+ test('findHeroCssBackgroundImage: returns null when no bg URL present', () => {
327
+ assert.equal(findHeroCssBackgroundImage('.hero { color: red; }'), null)
328
+ })
329
+
330
+ test('findHeroCssBackgroundImage: comma-separated selectors — uses first selector', () => {
331
+ const r = findHeroCssBackgroundImage('.hero, .banner { background-image: url("a.jpg"); }')
332
+ assert.deepEqual(r, { selector: '.hero', url: 'a.jpg' })
333
+ })
334
+
335
+ test('findHeroCssBackgroundImage: pseudo-class on root scope is allowed', () => {
336
+ const r = findHeroCssBackgroundImage('.hero:hover { background-image: url("a.jpg"); }')
337
+ assert.deepEqual(r, { selector: '.hero:hover', url: 'a.jpg' })
338
+ })
339
+
340
+ // findHeroImgWithConfidence — confidence tiering.
341
+
342
+ test('findHeroImgWithConfidence: explicit fetchpriority="high"', () => {
343
+ const $ = cheerio.load('<img src="a.jpg"><img src="b.jpg" fetchpriority="high">')
344
+ const r = findHeroImgWithConfidence($)
345
+ assert.equal(r.confidence, 'explicit')
346
+ assert.equal(r.el.attr('src'), 'b.jpg')
347
+ })
348
+
349
+ test('findHeroImgWithConfidence: hero-ancestor class', () => {
350
+ const $ = cheerio.load('<img src="a.png"><div class="hero"><img src="b.jpg"></div>')
351
+ const r = findHeroImgWithConfidence($)
352
+ assert.equal(r.confidence, 'hero-ancestor')
353
+ assert.equal(r.el.attr('src'), 'b.jpg')
354
+ })
355
+
356
+ test('findHeroImgWithConfidence: src/alt name-match (hero|banner|cover)', () => {
357
+ const $ = cheerio.load('<img src="logo.png"><img src="banner.jpg" alt="x">')
358
+ const r = findHeroImgWithConfidence($)
359
+ assert.equal(r.confidence, 'name-match')
360
+ assert.equal(r.el.attr('src'), 'banner.jpg')
361
+ })
362
+
363
+ test('findHeroImgWithConfidence: declared width >= 800', () => {
364
+ const $ = cheerio.load('<img src="a.png"><img src="b.jpg" width="1200">')
365
+ const r = findHeroImgWithConfidence($)
366
+ assert.equal(r.confidence, 'wide')
367
+ assert.equal(r.el.attr('src'), 'b.jpg')
368
+ })
369
+
370
+ test('findHeroImgWithConfidence: bare first <img> falls back', () => {
371
+ const $ = cheerio.load('<img src="a.png">')
372
+ const r = findHeroImgWithConfidence($)
373
+ assert.equal(r.confidence, 'fallback')
374
+ })
375
+
376
+ // ─── perf-font-display ──────────────────────────────────────────────────────
377
+
378
+ test('perf-font-display: flags Google Fonts without display=swap', () => {
379
+ const ctx = ctxFromHtml('<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Inter">')
380
+ const v = checkFontDisplay(ctx)
381
+ assert.equal(v.length, 1)
382
+ assert.equal(v[0].id, 'perf-font-display')
383
+ assert.match(v[0].candidateFix, /&display=swap/)
384
+ })
385
+
386
+ test('perf-font-display: passes when display=swap present', () => {
387
+ const ctx = ctxFromHtml('<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Inter&display=swap">')
388
+ assert.equal(checkFontDisplay(ctx).length, 0)
389
+ })
390
+
391
+ // ─── perf-fetchpriority-hero (shopify only) ─────────────────────────────────
392
+
393
+ test('perf-fetchpriority-hero: flags hero without fetchpriority on shopify', () => {
394
+ const ctx = ctxFromHtml('<img src="hero.jpg" alt="hero">', 'shopify-section')
395
+ const v = checkFetchpriorityHero(ctx)
396
+ assert.equal(v.length, 1)
397
+ assert.equal(v[0].id, 'perf-fetchpriority-hero')
398
+ })
399
+
400
+ test('perf-fetchpriority-hero: silent on wp-elementor preset', () => {
401
+ const ctx = ctxFromHtml('<img src="hero.jpg" alt="hero">', 'wp-elementor')
402
+ assert.equal(checkFetchpriorityHero(ctx).length, 0)
403
+ })
404
+
405
+ // ─── web-srcset-responsive ──────────────────────────────────────────────────
406
+
407
+ test('web-srcset-responsive: flags wide img without srcset', () => {
408
+ const ctx = ctxFromHtml('<img src="big.jpg" width="1200">')
409
+ const v = checkSrcsetResponsive(ctx)
410
+ assert.equal(v.length, 1)
411
+ assert.equal(v[0].id, 'web-srcset-responsive')
412
+ })
413
+
414
+ test('web-srcset-responsive: passes when srcset present', () => {
415
+ const ctx = ctxFromHtml('<img src="big.jpg" width="1200" srcset="big.jpg 1x, big2x.jpg 2x">')
416
+ assert.equal(checkSrcsetResponsive(ctx).length, 0)
417
+ })
418
+
419
+ test('web-srcset-responsive: ignores narrow img', () => {
420
+ const ctx = ctxFromHtml('<img src="thumb.jpg" width="200">')
421
+ assert.equal(checkSrcsetResponsive(ctx).length, 0)
422
+ })
423
+
424
+ test('web-srcset-responsive: ignores img without explicit width', () => {
425
+ const ctx = ctxFromHtml('<img src="x.jpg">')
426
+ assert.equal(checkSrcsetResponsive(ctx).length, 0)
427
+ })
428
+
429
+ // ─── web-modal-dialog ───────────────────────────────────────────────────────
430
+
431
+ test('web-modal-dialog: flags <div class="modal">', () => {
432
+ const ctx = ctxFromHtml('<div class="modal">Hi</div>')
433
+ const v = checkModalDialog(ctx)
434
+ assert.equal(v.length, 1)
435
+ assert.equal(v[0].id, 'web-modal-dialog')
436
+ assert.match(v[0].candidateFix, /<dialog/)
437
+ })
438
+
439
+ test('web-modal-dialog: flags <div role="dialog">', () => {
440
+ const ctx = ctxFromHtml('<div role="dialog">x</div>')
441
+ assert.equal(checkModalDialog(ctx).length, 1)
442
+ })
443
+
444
+ test('web-modal-dialog: passes <dialog>', () => {
445
+ const ctx = ctxFromHtml('<dialog class="modal-x">x</dialog>')
446
+ assert.equal(checkModalDialog(ctx).length, 0)
447
+ })
448
+
449
+ // ─── web-preconnect-fonts ───────────────────────────────────────────────────
450
+
451
+ test('web-preconnect-fonts: flags Google Fonts without preconnect', () => {
452
+ const ctx = ctxFromHtml('<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Inter&display=swap">')
453
+ const v = checkPreconnectFonts(ctx)
454
+ assert.equal(v.length, 1)
455
+ assert.equal(v[0].id, 'web-preconnect-fonts')
456
+ assert.match(v[0].candidateFix, /preconnect/)
457
+ })
458
+
459
+ test('web-preconnect-fonts: passes when both preconnects exist', () => {
460
+ const ctx = ctxFromHtml(
461
+ '<link rel="preconnect" href="https://fonts.googleapis.com">' +
462
+ '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>' +
463
+ '<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Inter&display=swap">',
464
+ )
465
+ assert.equal(checkPreconnectFonts(ctx).length, 0)
466
+ })
467
+
468
+ test('web-preconnect-fonts: silent when no Google Fonts loaded', () => {
469
+ const ctx = ctxFromHtml('<link rel="stylesheet" href="local.css">')
470
+ assert.equal(checkPreconnectFonts(ctx).length, 0)
471
+ })
472
+
473
+ // ─── runDesignQuality: aggregator ───────────────────────────────────────────
474
+
475
+ test('runDesignQuality: aggregates and sorts by severity', () => {
476
+ const ctx = ctxFromHtml(`
477
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Inter">
478
+ <button class="menu"><svg></svg></button>
479
+ <img src="hero.jpg">
480
+ <img src="other.jpg">
481
+ `)
482
+ const v = runDesignQuality(ctx)
483
+ assert.ok(v.length >= 4, `expected >=4 violations, got ${v.length}`)
484
+ // High-severity first (img-alt + button-aria are both high)
485
+ assert.equal(v[0].severity, 'high')
486
+ })
487
+
488
+ if (failed > 0) {
489
+ process.stdout.write(`\n${failed} failed, ${passed} passed\n`)
490
+ process.exit(1)
491
+ }
492
+ process.stdout.write(`\n${passed} passed\n`)
493
+ process.exit(0)