@luigisbox/luma 0.0.0-stage → 0.2.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 (145) hide show
  1. package/LICENSE.md +90 -0
  2. package/README.md +615 -2
  3. package/a11y/audit.mjs +735 -0
  4. package/a11y/axe.mjs +73 -0
  5. package/a11y/report.mjs +347 -0
  6. package/a11y/run.mjs +256 -0
  7. package/a11y/wcag.mjs +233 -0
  8. package/bin/config.mjs +60 -0
  9. package/bin/luma.mjs +99 -0
  10. package/bin/paths.mjs +11 -0
  11. package/bin/tools.mjs +43 -0
  12. package/build/baseline.d.mts +8 -0
  13. package/build/baseline.mjs +167 -0
  14. package/build/tooling.mjs +102 -0
  15. package/build/vite.mjs +507 -0
  16. package/chrome/block.mjs +8 -0
  17. package/chrome/run.mjs +150 -0
  18. package/docs/ACCESSIBILITY.md +501 -0
  19. package/docs/INTEGRATION-PERFORMANCE.md +213 -0
  20. package/docs/PARITY.md +946 -0
  21. package/footprint/model.mjs +184 -0
  22. package/footprint/run.mjs +331 -0
  23. package/package.json +58 -4
  24. package/parity/alignment.mjs +109 -0
  25. package/parity/archive.mjs +223 -0
  26. package/parity/args.mjs +137 -0
  27. package/parity/behaviour.mjs +379 -0
  28. package/parity/compare.mjs +985 -0
  29. package/parity/engine.mjs +848 -0
  30. package/parity/hints.mjs +132 -0
  31. package/parity/hold.mjs +54 -0
  32. package/parity/init.mjs +703 -0
  33. package/parity/map.mjs +640 -0
  34. package/parity/owner.mjs +170 -0
  35. package/parity/pairing.mjs +110 -0
  36. package/parity/pixels.mjs +33 -0
  37. package/parity/probe.mjs +1239 -0
  38. package/parity/report.mjs +236 -0
  39. package/parity/run.mjs +267 -0
  40. package/snippet/early-hold.js +64 -0
  41. package/snippet/early-hold.min.js +1 -0
  42. package/src/analytics/chain.ts +137 -0
  43. package/src/analytics/collect.ts +64 -0
  44. package/src/analytics/datalayer.ts +67 -0
  45. package/src/analytics/from-ga4.ts +68 -0
  46. package/src/analytics/from-luma.ts +21 -0
  47. package/src/analytics/index.ts +24 -0
  48. package/src/analytics/outbox.ts +118 -0
  49. package/src/analytics/page.ts +46 -0
  50. package/src/analytics/pageview.ts +82 -0
  51. package/src/analytics/ref.ts +19 -0
  52. package/src/analytics/setup.ts +41 -0
  53. package/src/analytics/start.ts +53 -0
  54. package/src/analytics/triggers.ts +239 -0
  55. package/src/analytics/user.ts +12 -0
  56. package/src/analytics/wire.ts +67 -0
  57. package/src/api/ai.ts +98 -0
  58. package/src/api/index.ts +87 -0
  59. package/src/api/token-key.ts +42 -0
  60. package/src/api/token.ts +312 -0
  61. package/src/core/a11y-keys.ts +60 -0
  62. package/src/core/a11y.ts +160 -0
  63. package/src/core/boot-state.ts +6 -0
  64. package/src/core/color.ts +42 -0
  65. package/src/core/context.ts +108 -0
  66. package/src/core/early-rules.ts +96 -0
  67. package/src/core/entries.ts +25 -0
  68. package/src/core/fold.ts +7 -0
  69. package/src/core/health.ts +26 -0
  70. package/src/core/hold.ts +74 -0
  71. package/src/core/i18n.ts +66 -0
  72. package/src/core/image-load.ts +102 -0
  73. package/src/core/image-tags.ts +26 -0
  74. package/src/core/images.ts +170 -0
  75. package/src/core/integration.ts +126 -0
  76. package/src/core/kept.ts +25 -0
  77. package/src/core/listings.ts +100 -0
  78. package/src/core/locales.ts +14 -0
  79. package/src/core/own.ts +49 -0
  80. package/src/core/page.ts +14 -0
  81. package/src/core/prefetch.ts +88 -0
  82. package/src/core/recommenders.ts +166 -0
  83. package/src/core/reconciler.ts +397 -0
  84. package/src/core/result.ts +73 -0
  85. package/src/core/routes.ts +115 -0
  86. package/src/core/scheduler.ts +83 -0
  87. package/src/core/sense.ts +107 -0
  88. package/src/core/speculation.ts +59 -0
  89. package/src/core/stash.ts +38 -0
  90. package/src/core/storage.ts +45 -0
  91. package/src/core/styles.ts +106 -0
  92. package/src/core/support.ts +12 -0
  93. package/src/core/takeover.ts +220 -0
  94. package/src/core/templates.ts +74 -0
  95. package/src/core/tiles.ts +137 -0
  96. package/src/core/turbo.ts +64 -0
  97. package/src/core/url.ts +164 -0
  98. package/src/core/widget-inits.ts +40 -0
  99. package/src/env.d.ts +3 -0
  100. package/src/lit.ts +11 -0
  101. package/src/locales/cs.json +94 -0
  102. package/src/locales/de.json +88 -0
  103. package/src/locales/en.json +88 -0
  104. package/src/main.ts +493 -0
  105. package/src/types.ts +778 -0
  106. package/src/widgets/autocomplete/index.ts +1387 -0
  107. package/src/widgets/autocomplete/style-autocomplete.css +266 -0
  108. package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
  109. package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
  110. package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
  111. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
  112. package/src/widgets/listing/element.ts +1772 -0
  113. package/src/widgets/listing/flow.ts +30 -0
  114. package/src/widgets/listing/instant.ts +133 -0
  115. package/src/widgets/listing/intent.ts +193 -0
  116. package/src/widgets/listing/query.ts +298 -0
  117. package/src/widgets/listing/stash.ts +97 -0
  118. package/src/widgets/listing/state.ts +131 -0
  119. package/src/widgets/listing/view.ts +257 -0
  120. package/src/widgets/plp/index.ts +11 -0
  121. package/src/widgets/plp/route.ts +263 -0
  122. package/src/widgets/recommender/index.ts +261 -0
  123. package/src/widgets/recommender/style-recommender.css +26 -0
  124. package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
  125. package/src/widgets/recommender/template-recommender-popup.ts +12 -0
  126. package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
  127. package/src/widgets/search/assets.ts +7 -0
  128. package/src/widgets/search/first.ts +15 -0
  129. package/src/widgets/search/form-url.ts +59 -0
  130. package/src/widgets/search/in-place.ts +226 -0
  131. package/src/widgets/search/index.ts +16 -0
  132. package/src/widgets/search/range.ts +260 -0
  133. package/src/widgets/search/route.ts +32 -0
  134. package/src/widgets/search/speculate.ts +202 -0
  135. package/src/widgets/search/style-search.css +65 -0
  136. package/src/widgets/search/template-search-box.ts +17 -0
  137. package/src/widgets/search/template-search-facets-default.ts +73 -0
  138. package/src/widgets/search/template-search-header.ts +14 -0
  139. package/src/widgets/search/template-search-pagination.ts +12 -0
  140. package/src/widgets/search/template-search-range-default.ts +48 -0
  141. package/src/widgets/search/template-search-result-default.ts +13 -0
  142. package/src/widgets/search/template-search-results-default.ts +8 -0
  143. package/tasks/model.mjs +116 -0
  144. package/tasks/run.mjs +305 -0
  145. package/tsconfig.json +14 -0
package/a11y/audit.mjs ADDED
@@ -0,0 +1,735 @@
1
+ // The flows of `yarn luma a11y`: drives an integration's real widgets in a page
2
+ // (any Playwright page where the integration runs: the command's own browser,
3
+ // or the fake shop of Luma's browser tests) and
4
+ // checks each state:
5
+ //
6
+ // autocomplete open, the keyboard selection, no results, closed, focus gone,
7
+ // forced colours
8
+ // search, plp shown; a filter ticked from the keyboard; the sort menu; phone
9
+ // width with the filter drawer; reflow at 320 px; prefers-contrast:
10
+ // more; forced colours; reduced motion
11
+ // recommender inline: shown, keyboard, reflow, contrast, forced colours
12
+ //
13
+ // axe-core (WCAG 2.0–2.2 A/AA) on Luma's own roots in every state; the host
14
+ // page's own issues separately (informational). Never adds to a cart, a
15
+ // wishlist or a comparison: nothing is clicked or pressed on tiles, and the
16
+ // recommender popup (opened by an add-to-cart click) is not exercised.
17
+ import { ROOTS, runAxe } from './axe.mjs';
18
+
19
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
+ /** Text that marks a control we must never operate. */
21
+ const UNSAFE = /cart|basket|bag|wish|compare|košík|kosik|porovn|merkliste|warenkorb|vergleich/i;
22
+
23
+ /** The focused element across shadow roots, as a short description; null on <body>. */
24
+ const ACTIVE = () => {
25
+ let a = document.activeElement;
26
+ while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement;
27
+ if (!a || a === document.body || a === document.documentElement) return null;
28
+ const cls = typeof a.className === 'string' && a.className.trim() ? `.${a.className.trim().split(/\s+/).slice(0, 3).join('.')}` : '';
29
+ return `${a.localName}${a.id ? `#${a.id}` : ''}${cls}`;
30
+ };
31
+
32
+ /**
33
+ * @param {import('playwright-core').Page} page
34
+ * @param {{
35
+ * goto: (path: string) => Promise<unknown>,
36
+ * results: import('./report.mjs').Results,
37
+ * paths?: string[],
38
+ * query?: string,
39
+ * only?: string[],
40
+ * log?: (s: string) => void,
41
+ * }} opts
42
+ */
43
+ export const audit = async (page, opts) => {
44
+ const R = opts.results;
45
+ const log = opts.log ?? (() => {});
46
+ const query = opts.query || 'a';
47
+ const only = (w) => !opts.only?.length || opts.only.includes(w);
48
+ // The autocomplete alone needs one page.
49
+ const paths = (opts.paths?.length ? opts.paths : ['/']).slice(0, opts.only?.length === 1 && opts.only[0] === 'autocomplete' ? 1 : undefined);
50
+ page.on('dialog', (d) => d.dismiss().catch(() => {}));
51
+
52
+ let path = '';
53
+ const ctx = (widget, state) => ({ widget, state, page: path });
54
+ const desktop = () => page.setViewportSize({ width: 1280, height: 800 });
55
+
56
+ /** Loads a path; Luma's config once it booted (null if it did not). */
57
+ const load = async (p) => {
58
+ path = p;
59
+ log(`page ${p}`);
60
+ await desktop();
61
+ await opts.goto(p);
62
+ return booted();
63
+ };
64
+ const booted = async () => {
65
+ const cfg = await page
66
+ .waitForFunction(() => /** @type {any} */ (window).__lbx?.config, null, { timeout: 15000 })
67
+ .then((h) => h.jsonValue())
68
+ .catch(() => null);
69
+ if (!cfg) R.note(`${path}: Luma did not boot (no window.__lbx.config): is the bundle loading, is the integration enabled?`);
70
+ return cfg;
71
+ };
72
+ const healthErrors = async () => {
73
+ const errs = await page.evaluate(() => /** @type {any} */ (window).__lbx?.health?.errors ?? []).catch(() => []);
74
+ for (const e of errs.slice(0, 5)) R.note(`${path}: Luma reported an error: ${typeof e === 'string' ? e : JSON.stringify(e).slice(0, 300)}`);
75
+ };
76
+ /** The host page's own issues (everything but our roots): informational. */
77
+ const hostAxe = async () => {
78
+ const r = await runAxe(page, { exclude: ROOTS }).catch((e) => (R.note(`${path}: axe on the host page failed: ${e.message}`), null));
79
+ if (r) R.host(r, path);
80
+ };
81
+ /** axe on our roots in one state. */
82
+ const ours = async (c, include, rules = {}) => {
83
+ const r = await runAxe(page, { include, rules }).catch((e) => (R.note(`${c.page} ${c.widget} ${c.state}: axe failed: ${e.message}`), null));
84
+ if (r) R.axe(r, c);
85
+ };
86
+ const active = () => page.evaluate(ACTIVE).catch(() => null);
87
+ /** Whether the focused element (across shadow roots) is inside `sel`. */
88
+ const focusIn = (sel) =>
89
+ page.evaluate((sel) => {
90
+ let a = document.activeElement;
91
+ while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement;
92
+ if (!a || a === document.body) return false;
93
+ for (let n = a; n; n = n.parentElement ?? /** @type {any} */ (n.getRootNode()).host ?? null) if (n.matches?.(sel)) return true;
94
+ return false;
95
+ }, sel);
96
+
97
+ // ---------------------------------------------------------------- autocomplete
98
+
99
+ const autocomplete = async (cfg) => {
100
+ const sel = cfg._global?.inputSelector || 'input[name=q]';
101
+ // The first visible one: a closed modal's or drawer's input may come first (in a shadow root, or Playwright's order across them).
102
+ const input = page.locator(sel).filter({ visible: true }).first();
103
+ if (!(await input.isVisible().catch(() => false))) {
104
+ // A phone-only or collapsed header: the input of the desktop layout.
105
+ return R.skip('autocomplete', `the search input ${sel} (_global.inputSelector) is not visible on ${path}`);
106
+ }
107
+ const OVERLAY = '[data-lbx="ac-overlay"]';
108
+ const NAMES = ['role', 'aria-expanded', 'aria-controls', 'aria-activedescendant', 'aria-autocomplete', 'aria-owns', 'aria-haspopup'];
109
+ const aria = () => input.evaluate((n, names) => Object.fromEntries(names.filter((a) => n.hasAttribute(a)).map((a) => [a, n.getAttribute(a)])), NAMES);
110
+ const before = await aria();
111
+ const open = () => page.evaluate((s) => !!document.querySelector(`${s}:popover-open`), OVERLAY).catch(() => false);
112
+ const options = page.locator(`${OVERLAY} [role=option]`);
113
+ log(' autocomplete');
114
+
115
+ await input.focus();
116
+ await sleep(100);
117
+ // Typed as a shopper types; the status region speaks once typing pauses.
118
+ await input.pressSequentially(query, { delay: 60 });
119
+ const shown = await options.first().waitFor({ state: 'visible', timeout: 10000 }).then(() => true, () => false);
120
+ if (!shown) {
121
+ return R.skip('autocomplete', `no suggestions for "${query}" within 10 s (pass a query the catalog has: --query)`);
122
+ }
123
+ let c = ctx('autocomplete', 'open');
124
+ const a1 = await aria();
125
+ const problems = [];
126
+ if (a1.role !== 'combobox') problems.push(`role="${a1.role ?? ''}" (expected combobox)`);
127
+ if (a1['aria-expanded'] !== 'true') problems.push(`aria-expanded="${a1['aria-expanded'] ?? ''}" while open (expected true)`);
128
+ if (!a1['aria-autocomplete']) problems.push('no aria-autocomplete');
129
+ const html = await input.evaluate((n) => n.outerHTML.slice(0, 200));
130
+ if (problems.length) R.fail('combobox-role', c, 'The search input is not exposed as a combobox while the dropdown is open', [{ target: sel, html, summary: problems.join('; ') }]);
131
+ else R.pass('combobox-role', c);
132
+ const controls = a1['aria-controls'];
133
+ if (!controls || !(await page.evaluate((id) => !!document.getElementById(id), controls))) R.fail('combobox-controls', c, 'aria-controls does not name the dropdown on the page', [{ target: sel, html, summary: `aria-controls="${controls ?? ''}"` }]);
134
+ else R.pass('combobox-controls', c);
135
+ await ours(c, [OVERLAY], { 'scrollable-region-focusable': false });
136
+
137
+ // The status region: the count once typing paused.
138
+ const said = await page
139
+ .waitForFunction(
140
+ (s) => {
141
+ const h = document.querySelector(s);
142
+ const st = (h?.shadowRoot ?? h)?.querySelector('[role=status], [aria-live]');
143
+ return st?.textContent?.trim() || '';
144
+ },
145
+ OVERLAY,
146
+ { timeout: 3000 },
147
+ )
148
+ .then((h) => h.jsonValue(), () => '');
149
+ if (!said) R.fail('status-message', c, 'The number of suggestions is not announced (no text in a status region of the dropdown)', [{ target: OVERLAY }]);
150
+ else R.pass('status-message', c);
151
+
152
+ // The keyboard selection.
153
+ await input.press('ArrowDown');
154
+ await input.press('ArrowDown');
155
+ await sleep(150);
156
+ c = ctx('autocomplete', 'keyboard selection');
157
+ const sel1 = await page.evaluate((s) => {
158
+ const h = document.querySelector(s);
159
+ const root = h?.shadowRoot ?? h;
160
+ const opts = [...(root?.querySelectorAll('[role=option]') ?? [])];
161
+ const on = opts.filter((o) => o.getAttribute('aria-selected') === 'true');
162
+ return { count: on.length, id: on[0]?.id ?? '', shadow: !!h?.shadowRoot, html: on[0]?.outerHTML.slice(0, 200) ?? '' };
163
+ }, OVERLAY);
164
+ const focusStays = await input.evaluate((n) => {
165
+ let a = document.activeElement;
166
+ while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement;
167
+ return a === n;
168
+ });
169
+ if (!focusStays) R.fail('combobox-focus', c, 'The arrow keys moved focus out of the search input', [{ target: (await active()) ?? 'body' }]);
170
+ else R.pass('combobox-focus', c);
171
+ const a2 = await aria();
172
+ // An input in a shadow root (a web-component header) cannot reference the
173
+ // light-DOM dropdown's option by id (ids do not cross shadow boundaries):
174
+ // Luma points at it by element reflection there (ariaActiveDescendantElement).
175
+ const reflected = await input.evaluate((n, s) => {
176
+ const h = document.querySelector(s);
177
+ const on = (h?.shadowRoot ?? h)?.querySelector('[role=option][aria-selected=true]');
178
+ return n.getRootNode() !== document && 'ariaActiveDescendantElement' in n && !!on && /** @type {any} */ (n).ariaActiveDescendantElement === on;
179
+ }, OVERLAY);
180
+ if (sel1.count !== 1) R.fail('combobox-active-option', c, `After ArrowDown ${sel1.count} options have aria-selected="true" (expected exactly one)`, [{ target: `${OVERLAY} [role=option]` }]);
181
+ else if (!sel1.shadow && !reflected && (a2['aria-activedescendant'] !== sel1.id || !sel1.id))
182
+ R.fail('combobox-active-option', c, 'aria-activedescendant on the input does not point at the selected option', [{ target: `${OVERLAY} [aria-selected=true]`, html: sel1.html, summary: `aria-activedescendant="${a2['aria-activedescendant'] ?? ''}", option id="${sel1.id}"` }]);
183
+ else R.pass('combobox-active-option', c);
184
+ await ours(c, [OVERLAY], { 'scrollable-region-focusable': false });
185
+
186
+ // Forced colours: the selected row must still look selected.
187
+ await page.emulateMedia({ forcedColors: 'active' });
188
+ await sleep(150);
189
+ c = ctx('autocomplete', 'forced colours');
190
+ const distinct = await page.evaluate((s) => {
191
+ const h = document.querySelector(s);
192
+ const root = h?.shadowRoot ?? h;
193
+ const on = root?.querySelector('[role=option][aria-selected=true]');
194
+ const off = root?.querySelector('[role=option]:not([aria-selected=true])');
195
+ if (!on || !off) return null;
196
+ const look = (e) => {
197
+ const cs = getComputedStyle(e);
198
+ return [cs.backgroundColor, cs.color, cs.outlineStyle === 'none' ? 'none' : `${cs.outlineStyle}${cs.outlineWidth}`, cs.borderTopStyle + cs.borderLeftStyle + cs.borderLeftWidth, cs.boxShadow].join('|');
199
+ };
200
+ return { same: look(on) === look(off), html: on.outerHTML.slice(0, 200) };
201
+ }, OVERLAY);
202
+ if (distinct?.same) R.fail('forced-colors-selection', c, 'In forced colours (Windows contrast themes) the keyboard-selected suggestion looks like the others', [{ target: `${OVERLAY} [role=option][aria-selected=true]`, html: distinct.html }]);
203
+ else if (distinct) R.pass('forced-colors-selection', c);
204
+ await ours(c, [OVERLAY], { 'scrollable-region-focusable': false, 'color-contrast': false });
205
+ await page.emulateMedia({ forcedColors: 'none' });
206
+
207
+ // prefers-contrast: more
208
+ await page.emulateMedia({ contrast: 'more' });
209
+ await sleep(150);
210
+ await ours(ctx('autocomplete', 'prefers-contrast: more'), [OVERLAY], { 'scrollable-region-focusable': false });
211
+ await page.emulateMedia({ contrast: 'no-preference' });
212
+
213
+ // No results.
214
+ await input.fill('');
215
+ await input.pressSequentially('zzqxjvw', { delay: 40 });
216
+ await sleep(1500);
217
+ if (await open()) await ours(ctx('autocomplete', 'no results'), [OVERLAY], { 'scrollable-region-focusable': false });
218
+
219
+ // Escape closes ours; Tab away puts the input's ARIA back.
220
+ await input.fill('');
221
+ await input.pressSequentially(query, { delay: 40 });
222
+ await options.first().waitFor({ state: 'visible', timeout: 8000 }).catch(() => {});
223
+ await input.press('Escape');
224
+ await sleep(200);
225
+ c = ctx('autocomplete', 'closed');
226
+ const a3 = await aria();
227
+ if (a3['aria-expanded'] === 'true' || (await open())) R.fail('combobox-escape', c, 'Escape did not close the dropdown', [{ target: sel, html: await input.evaluate((n) => n.outerHTML.slice(0, 200)) }]);
228
+ else R.pass('combobox-escape', c);
229
+ await input.press('Tab');
230
+ await sleep(300);
231
+ // A page with nothing else to focus (a stub page) wraps Tab back to the input: leave it for real.
232
+ if (await input.evaluate((n) => document.activeElement === n)) {
233
+ await input.evaluate((n) => /** @type {HTMLElement} */ (n).blur());
234
+ await sleep(300);
235
+ }
236
+ c = ctx('autocomplete', 'focus left the input');
237
+ const a4 = await aria();
238
+ if (JSON.stringify(a4) !== JSON.stringify(before)) {
239
+ // What tells the causes apart: an input in a shadow root, and whether Luma saw focus leave (its dropdown still open, aria-expanded still ours).
240
+ const where = await input.evaluate((n) => {
241
+ const root = n.getRootNode();
242
+ let a = document.activeElement;
243
+ while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement;
244
+ return { shadow: root !== document, sameRoot: !!a && a !== n && a.getRootNode() === root && root !== document, focus: a ? a.tagName.toLowerCase() + (a.className ? `.${String(a.className).trim().split(/\s+/).join('.')}` : '') : 'none' };
245
+ });
246
+ const still = a4['aria-expanded'] === 'true' || (await open());
247
+ const why = [
248
+ where.shadow ? `the input is in a shadow root${where.sameRoot ? `, focus moved to ${where.focus} in the same shadow root` : ''}` : '',
249
+ still ? 'the dropdown was still open (Luma did not see focus leave)' : '',
250
+ ].filter(Boolean);
251
+ R.fail('combobox-restore', c, 'Once focus left the input, its ARIA attributes were not as the host had them', [{ target: sel, summary: `before ${JSON.stringify(before)}, after ${JSON.stringify(a4)}${why.length ? `; ${why.join('; ')}` : ''}` }]);
252
+ }
253
+ else R.pass('combobox-restore', c);
254
+ };
255
+
256
+ // ---------------------------------------------------------------- listings
257
+
258
+ const LISTING = 'lbx-search, lbx-plp';
259
+ /** A listing shown and settled (not busy, with its total), or null. */
260
+ const listingShown = async (timeout = 15000) => {
261
+ // A page of the listing gets its element at boot (hidden until rendered); other pages never do.
262
+ const there = await page.waitForSelector(LISTING, { state: 'attached', timeout: Math.min(timeout, 3000) }).then(() => true, () => false);
263
+ if (!there) return null;
264
+ const ok = await page
265
+ .waitForFunction(
266
+ (s) => {
267
+ const el = /** @type {HTMLElement | null} */ (document.querySelector(s));
268
+ return !!el && el.checkVisibility() && el.getAttribute('aria-busy') === 'false' && /\d/.test(el.getAttribute('data-total') ?? '');
269
+ },
270
+ LISTING,
271
+ { timeout },
272
+ )
273
+ .then(() => true, () => false);
274
+ return ok ? page.evaluate((s) => document.querySelector(s)?.localName.replace('lbx-', '') ?? null, LISTING) : null;
275
+ };
276
+ const settled = async () => {
277
+ await sleep(150);
278
+ await page.waitForFunction((s) => document.querySelector(s)?.getAttribute('aria-busy') === 'false', LISTING, { timeout: 10000 }).catch(() => {});
279
+ await sleep(200);
280
+ };
281
+
282
+ /** Starts recording what a listing's status region says. */
283
+ const recordStatus = (root) =>
284
+ page.evaluate((root) => {
285
+ const w = /** @type {any} */ (window);
286
+ ((w.__lbx ??= {}).a11y ??= {}).said = [];
287
+ const el = document.querySelector(`${root} [data-lbx-a11y] [role=status]`) ?? document.querySelector(`${root} [role=status], ${root} [aria-live]`);
288
+ if (!el) return false;
289
+ new MutationObserver(() => el.textContent?.trim() && w.__lbx.a11y.said.push(el.textContent.trim())).observe(el, { childList: true, characterData: true, subtree: true });
290
+ return true;
291
+ }, root);
292
+
293
+ /**
294
+ * Tab through `root` from its start: every stop's focus must be visible; an
295
+ * element that acts as a control must be focusable; a checkbox hidden behind
296
+ * a drawn box must keep focus.
297
+ */
298
+ const keyboardWalk = async (c, root, max = 300) => {
299
+ const pre = await page.evaluate((root) => {
300
+ const r = document.querySelector(root);
301
+ if (!r) return null;
302
+ const w = /** @type {any} */ (window);
303
+ const all = [...r.querySelectorAll('*')];
304
+ const visible = (e) => e.checkVisibility({ visibilityProperty: true }) && e.getBoundingClientRect().width > 0 && e.getBoundingClientRect().height > 0;
305
+ const look = (e) => {
306
+ if (!e) return '';
307
+ const cs = getComputedStyle(e);
308
+ return [cs.outlineStyle === 'none' ? 'none' : `${cs.outlineStyle} ${cs.outlineWidth} ${cs.outlineColor}`, cs.boxShadow, cs.backgroundColor, cs.borderTopColor + cs.borderBottomColor, cs.borderBottomWidth, cs.color, cs.textDecorationLine].join('|');
309
+ };
310
+ // Unfocused looks of every focusable element (and the parts that draw a custom control's focus).
311
+ const focusable = all.filter((e) => /** @type {HTMLElement} */ (e).tabIndex >= 0 && !(/** @type {any} */ (e).disabled) && visible(e));
312
+ ((w.__lbx ??= {}).a11y ??= {}).walk = { els: focusable, looks: focusable.map((e) => [look(e), look(e.parentElement), look(e.nextElementSibling)].join('#')), look: (e) => [look(e), look(e.parentElement), look(e.nextElementSibling)].join('#') };
313
+ const html = (e) => e.outerHTML.slice(0, 200);
314
+ // Controls by role that cannot get focus.
315
+ const roles = 'button, link, checkbox, radio, switch, slider, spinbutton, combobox'.split(', ');
316
+ const unfocusable = all
317
+ .filter((e) => roles.includes(e.getAttribute('role') ?? '') && /** @type {HTMLElement} */ (e).tabIndex < 0 && e.getAttribute('aria-disabled') !== 'true' && !e.closest('[aria-hidden=true], [inert]') && visible(e))
318
+ .map(html);
319
+ // Checkboxes and radios not rendered (display:none / visibility:hidden) whose label is shown: not reachable by keyboard.
320
+ const hidden = all
321
+ .filter((e) => e.matches('input[type=checkbox], input[type=radio]') && !e.closest('[aria-hidden=true], [inert]'))
322
+ .filter((e) => {
323
+ const cs = getComputedStyle(e);
324
+ if (cs.display !== 'none' && cs.visibility !== 'hidden') return false;
325
+ const label = e.closest('label') ?? (e.id ? r.querySelector(`label[for="${CSS.escape(e.id)}"]`) : null);
326
+ return !!label && visible(label) && /** @type {HTMLElement} */ (label).tabIndex < 0;
327
+ })
328
+ .map(html);
329
+ return { unfocusable, hidden, count: focusable.length };
330
+ }, root);
331
+ if (!pre) return;
332
+ if (pre.unfocusable.length) R.fail('not-focusable', c, 'Controls (by their role) that the keyboard cannot reach', pre.unfocusable.slice(0, 8).map((h) => ({ target: tagOf(h), html: h })));
333
+ else R.pass('not-focusable', c);
334
+ if (pre.hidden.length) R.fail('hidden-control', c, 'Checkboxes or radios hidden with display:none / visibility:hidden behind a drawn box: not reachable by keyboard', pre.hidden.slice(0, 8).map((h) => ({ target: tagOf(h), html: h })));
335
+ else if (pre.count) R.pass('hidden-control', c);
336
+ // Tab from the start of the root.
337
+ await page.evaluate((root) => {
338
+ const r = /** @type {HTMLElement} */ (document.querySelector(root));
339
+ if (r.tabIndex < 0 && !r.hasAttribute('tabindex')) r.setAttribute('tabindex', '-1');
340
+ r.focus({ preventScroll: true });
341
+ }, root);
342
+ const invisible = new Map();
343
+ let stops = 0;
344
+ for (; stops < max; stops++) {
345
+ await page.keyboard.press('Tab');
346
+ const s = await page.evaluate((root) => {
347
+ let a = document.activeElement;
348
+ while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement;
349
+ const r = document.querySelector(root);
350
+ if (!a || !r || !r.contains(a)) return { inside: false };
351
+ const w = /** @type {any} */ (window).__lbx?.a11y?.walk;
352
+ const i = w.els.indexOf(a);
353
+ const now = w.look(a);
354
+ return { inside: true, same: i >= 0 && w.looks[i] === now, known: i >= 0, html: a.outerHTML.slice(0, 200) };
355
+ }, root);
356
+ if (!s.inside) break;
357
+ if (s.known && s.same && !invisible.has(s.html)) invisible.set(s.html, true);
358
+ }
359
+ if (invisible.size) R.fail('focus-visible', c, 'Keyboard focus is not visible on these controls (nothing in their look changes when they get focus)', [...invisible.keys()].slice(0, 8).map((h) => ({ target: tagOf(h), html: h })));
360
+ else if (stops) R.pass('focus-visible', c);
361
+ return stops;
362
+ };
363
+
364
+ /** A filter we may operate: in the facets, not on a tile, never anything cart-like. */
365
+ const pickFilter = (root) =>
366
+ page.evaluate(
367
+ ({ root, unsafe }) => {
368
+ const r = document.querySelector(root);
369
+ if (!r) return null;
370
+ const bad = new RegExp(unsafe, 'i');
371
+ const shown = (e) => {
372
+ const label = e.closest('label') ?? (e.id ? r.querySelector(`label[for="${CSS.escape(e.id)}"]`) : null);
373
+ return [e, label].some((x) => x && x.checkVisibility({ visibilityProperty: true }) && x.getBoundingClientRect().height > 0);
374
+ };
375
+ const ok = (e) => !e.closest('[data-lbx-list], [data-lbx-menu]') && shown(e) && !bad.test(`${e.textContent} ${e.closest('label')?.textContent ?? ''} ${e.getAttribute('aria-label') ?? ''} ${e.className}`);
376
+ const groups = [
377
+ '[data-facet] input[type=checkbox]:not(:checked), input[type=checkbox][data-facet]:not(:checked)',
378
+ '[role=checkbox][aria-checked=false]',
379
+ 'fieldset input[type=checkbox]:not(:checked), [role=group] input[type=checkbox]:not(:checked)',
380
+ '[data-facet] a[href], fieldset a[href]',
381
+ ];
382
+ for (const g of groups) {
383
+ const el = [...r.querySelectorAll(g)].find(ok);
384
+ if (el) {
385
+ const all = [...r.querySelectorAll('*')];
386
+ return { index: all.indexOf(el), html: el.outerHTML.slice(0, 200), kind: el.matches('input') ? 'input' : el.getAttribute('role') === 'checkbox' ? 'role' : 'link' };
387
+ }
388
+ }
389
+ return null;
390
+ },
391
+ { root, unsafe: UNSAFE.source },
392
+ );
393
+ const nth = (root, index) => page.locator(`${root} *`).nth(index);
394
+
395
+ const listing = async (route) => {
396
+ const root = `lbx-${route}`;
397
+ log(` ${route}`);
398
+ let c = ctx(route, 'shown');
399
+ // A named region (Luma's; a template must not take the name away).
400
+ const name = await page.evaluate((root) => {
401
+ const r = document.querySelector(root);
402
+ return { role: r?.getAttribute('role'), label: r?.getAttribute('aria-label') || r?.getAttribute('aria-labelledby') || '' };
403
+ }, root);
404
+ if (name.role !== 'region' || !name.label) R.fail('region-name', c, 'The listing is not a named region', [{ target: root, summary: JSON.stringify(name) }]);
405
+ else R.pass('region-name', c);
406
+ await ours(c, [root]);
407
+ await keyboardWalk(ctx(route, 'keyboard (Tab through)'), root);
408
+
409
+ // A filter, from the keyboard.
410
+ c = ctx(route, 'filter applied (keyboard)');
411
+ const f = await pickFilter(root);
412
+ if (!f) R.skip(route, `no filter control found in ${root} (checkboxes or links in [data-facet] / fieldsets)`);
413
+ else {
414
+ const recording = await recordStatus(root);
415
+ const state = () => page.evaluate((root) => `${location.href} ${document.querySelector(root)?.getAttribute('data-total')} ${document.querySelector(root)?.getAttribute('aria-busy')}`, root);
416
+ const was = await state();
417
+ const el = nth(root, f.index);
418
+ let moved = false;
419
+ if (await el.evaluate((e) => /** @type {HTMLElement} */ (e).tabIndex >= 0 && getComputedStyle(e).display !== 'none').catch(() => false)) {
420
+ await el.focus();
421
+ await page.keyboard.press(f.kind === 'link' ? 'Enter' : 'Space');
422
+ moved = await page.waitForFunction(({ root, was }) => `${location.href} ${document.querySelector(root)?.getAttribute('data-total')} ${document.querySelector(root)?.getAttribute('aria-busy')}` !== was, { root, was }, { timeout: 6000 }).then(() => true, () => false);
423
+ }
424
+ if (!moved) {
425
+ R.fail('keyboard-filter', c, 'A filter could not be applied from the keyboard', [{ target: tagOf(f.html), html: f.html }]);
426
+ // Go on with the mouse, so the states after it are still checked.
427
+ const target = (await el.evaluate((e) => getComputedStyle(e).display !== 'none' && e.checkVisibility())) ? el : el.locator('xpath=ancestor::label[1]');
428
+ await target.click({ timeout: 5000 }).catch(() => {});
429
+ } else R.pass('keyboard-filter', c);
430
+ await settled();
431
+ if (moved) {
432
+ if (!(await active())) R.fail('focus-lost', c, 'After the filter re-rendered the listing, focus was lost to the page (<body>)', [{ target: tagOf(f.html), html: f.html }]);
433
+ else R.pass('focus-lost', c);
434
+ }
435
+ if (recording) {
436
+ const said = await page.waitForFunction(() => /** @type {any} */ (window).__lbx?.a11y?.said?.length > 0, null, { timeout: 3000 }).then(() => true, () => false);
437
+ if (!said && (await state()) !== was) R.fail('status-message', c, 'The new result count was not announced after the filter', [{ target: `${root} [data-lbx-a11y]` }]);
438
+ else if (said) R.pass('status-message', c);
439
+ } else R.fail('status-message', c, 'The listing has no status region (Luma\'s [data-lbx-a11y] was removed or hidden)', [{ target: root }]);
440
+ await ours(c, [root]);
441
+ }
442
+
443
+ // The sort menu (desktop and phone).
444
+ const menu = async (c) => {
445
+ const m = await page.evaluate((root) => {
446
+ const all = [...document.querySelectorAll(`${root} [data-lbx-menu]`)].filter((e) => e.checkVisibility());
447
+ for (const mn of all) {
448
+ const toggles = [...mn.querySelectorAll('[data-lbx-toggle]')];
449
+ const button = toggles.find((t) => t.hasAttribute('aria-haspopup')) ?? toggles[0];
450
+ if (button && button.checkVisibility()) return { menu: [...document.querySelectorAll(`${root} [data-lbx-menu]`)].indexOf(mn), button: toggles.indexOf(button), html: button.outerHTML.slice(0, 200) };
451
+ }
452
+ return null;
453
+ }, root);
454
+ if (!m) return false;
455
+ const button = page.locator(`${root} [data-lbx-menu]`).nth(m.menu).locator('[data-lbx-toggle]').nth(m.button);
456
+ await button.focus();
457
+ await page.keyboard.press('Enter');
458
+ await sleep(300);
459
+ if ((await button.getAttribute('aria-expanded')) !== 'true') R.fail('menu-expanded', c, 'The menu button does not say it is open (aria-expanded)', [{ target: tagOf(m.html), html: m.html }]);
460
+ else R.pass('menu-expanded', c);
461
+ const inside = await page.evaluate(
462
+ ({ root, i }) => {
463
+ let a = document.activeElement;
464
+ while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement;
465
+ const mn = document.querySelectorAll(`${root} [data-lbx-menu]`)[i];
466
+ return !!a && !!mn?.contains(a) && !a.matches('[data-lbx-toggle][aria-haspopup], [data-lbx-toggle]:first-child');
467
+ },
468
+ { root, i: m.menu },
469
+ );
470
+ if (!inside) R.fail('menu-focus', c, 'Opened from the keyboard, focus did not move into the menu', [{ target: tagOf(m.html), html: m.html, summary: `focus on ${await active()}` }]);
471
+ else R.pass('menu-focus', c);
472
+ await ours(c, [root]);
473
+ await page.keyboard.press('Escape');
474
+ await sleep(250);
475
+ const closed = (await button.getAttribute('aria-expanded')) !== 'true';
476
+ const back = await button.evaluate((b) => document.activeElement === b);
477
+ if (!closed || !back) R.fail('menu-escape', c, `Escape ${!closed ? 'did not close the menu' : 'did not return focus to the menu button'}`, [{ target: tagOf(m.html), html: m.html }]);
478
+ else R.pass('menu-escape', c);
479
+ return true;
480
+ };
481
+ const hadMenu = await menu(ctx(route, 'sort menu (keyboard)'));
482
+
483
+ // Phone width: the layout, the sort menu there, the filter drawer.
484
+ await page.setViewportSize({ width: 375, height: 740 });
485
+ await sleep(500);
486
+ await ours(ctx(route, 'phone width (375 px)'), [root]);
487
+ const hadPhoneMenu = await menu(ctx(route, 'sort menu (phone)'));
488
+ if (!hadMenu && !hadPhoneMenu) R.skip(route, 'no sort menu ([data-lbx-menu] with a [data-lbx-toggle]) on the page');
489
+ c = ctx(route, 'filter drawer (phone)');
490
+ const opener = await page.evaluate((root) => {
491
+ const all = [...document.querySelectorAll(`${root} [data-lbx-drawer="open"]`)];
492
+ const i = all.findIndex((e) => e.checkVisibility() && e.getBoundingClientRect().height > 0);
493
+ return i < 0 ? null : { i, html: all[i].outerHTML.slice(0, 200) };
494
+ }, root);
495
+ if (!opener) R.skip(route, 'no filter drawer opener ([data-lbx-drawer="open"]) visible at 375 px');
496
+ else {
497
+ const o = page.locator(`${root} [data-lbx-drawer="open"]`).nth(opener.i);
498
+ await o.focus();
499
+ await page.keyboard.press('Enter');
500
+ await sleep(500);
501
+ const panel = page.locator(`${root} [data-lbx-drawer-panel]`).first();
502
+ if (!(await panel.count())) R.fail('drawer-dialog', c, 'The drawer has no [data-lbx-drawer-panel]: while open it is not a modal dialog, focus is not moved in or kept there', [{ target: tagOf(opener.html), html: opener.html }]);
503
+ else {
504
+ const d = await panel.evaluate((p) => ({ role: p.getAttribute('role'), modal: p.getAttribute('aria-modal'), name: p.getAttribute('aria-label') || p.getAttribute('aria-labelledby') || '', html: p.outerHTML.slice(0, 200), stops: p.querySelectorAll('a[href], button, input, select, textarea, [tabindex="0"]').length }));
505
+ if (d.role !== 'dialog' || d.modal !== 'true' || !d.name) R.fail('drawer-dialog', c, 'The open drawer is not a named modal dialog', [{ target: '[data-lbx-drawer-panel]', html: d.html, summary: `role=${d.role} aria-modal=${d.modal} name=${JSON.stringify(d.name)}` }]);
506
+ else R.pass('drawer-dialog', c);
507
+ let trapped = await focusIn('[data-lbx-drawer-panel]');
508
+ if (trapped) {
509
+ for (let i = 0; i < Math.min(d.stops, 60) + 2 && trapped; i++) {
510
+ await page.keyboard.press('Tab');
511
+ trapped = await focusIn('[data-lbx-drawer-panel]');
512
+ }
513
+ }
514
+ if (!trapped) R.fail('drawer-focus', c, 'Focus is not kept inside the open drawer (it did not move in, or Tab left it)', [{ target: '[data-lbx-drawer-panel]', html: d.html, summary: `focus on ${await active()}` }]);
515
+ else R.pass('drawer-focus', c);
516
+ await ours(c, [root]);
517
+ await page.keyboard.press('Escape');
518
+ await sleep(400);
519
+ const closed = await panel.evaluate((p) => p.getAttribute('aria-modal') !== 'true' || !p.checkVisibility());
520
+ const back = await o.evaluate((b) => document.activeElement === b);
521
+ if (!closed || !back) R.fail('drawer-escape', c, `Escape ${!closed ? 'did not close the drawer' : 'did not return focus to the button that opened it'}`, [{ target: tagOf(opener.html), html: opener.html }]);
522
+ else R.pass('drawer-escape', c);
523
+ }
524
+ }
525
+
526
+ await reflow(ctx(route, 'reflow (320 px)'), root);
527
+ await desktop();
528
+ await sleep(300);
529
+ await mediaStates(route, root);
530
+ };
531
+
532
+ /** 1.4.10: at 320 px nothing of ours past the viewport (a scrolling row of its own excepted). */
533
+ const reflow = async (c, root) => {
534
+ await page.setViewportSize({ width: 320, height: 640 });
535
+ await sleep(600);
536
+ const past = await page.evaluate((root) => {
537
+ const r = document.querySelector(root);
538
+ if (!r) return [];
539
+ const vw = document.documentElement.clientWidth;
540
+ const out = [];
541
+ for (const n of r.querySelectorAll('*')) {
542
+ const b = n.getBoundingClientRect();
543
+ if (b.width <= 1 || b.height <= 1 || !n.checkVisibility()) continue;
544
+ if (b.right <= vw + 1 && b.left >= -1) continue;
545
+ if (n.closest('[data-lbx-a11y], .lbx-sr-only')) continue;
546
+ // Inside a container that scrolls or clips on its own (a carousel), which itself fits: allowed.
547
+ let clipped = false;
548
+ for (let p = n.parentElement; p && p !== r.parentElement && !clipped; p = p.parentElement) {
549
+ if (getComputedStyle(p).overflowX === 'visible') continue;
550
+ const pb = p.getBoundingClientRect();
551
+ clipped = pb.right <= vw + 1 && pb.left >= -1;
552
+ }
553
+ if (clipped) continue;
554
+ // Report the outermost offender only.
555
+ if (out.some((o) => o.el.contains(n))) continue;
556
+ out.push({ el: n, html: n.outerHTML.slice(0, 200), right: Math.round(b.right) });
557
+ if (out.length > 6) break;
558
+ }
559
+ return out.map(({ html, right }) => ({ html, right, vw }));
560
+ }, root);
561
+ if (past.length) R.fail('reflow', c, 'At 320 CSS px wide, parts of the widget extend past the viewport (horizontal scrolling)', past.map((p) => ({ target: tagOf(p.html), html: p.html, summary: `right edge at ${p.right} px, viewport ${p.vw} px` })));
562
+ else R.pass('reflow', c);
563
+ };
564
+
565
+ /** prefers-contrast: more, forced colours, reduced motion. */
566
+ const mediaStates = async (widget, root) => {
567
+ await page.emulateMedia({ contrast: 'more' });
568
+ await sleep(300);
569
+ let c = ctx(widget, 'prefers-contrast: more');
570
+ await ours(c, [root]);
571
+ await ringCheck(c, root);
572
+ await page.emulateMedia({ contrast: 'no-preference', forcedColors: 'active' });
573
+ await sleep(300);
574
+ c = ctx(widget, 'forced colours');
575
+ // The visitor's theme sets the colours there (the emulation does not repaint the author's, so
576
+ // axe's contrast would only repeat the normal mode's): what matters is that states stay visible.
577
+ await ours(c, [root], { 'color-contrast': false });
578
+ await ringCheck(c, root);
579
+ // A custom-drawn checkbox: checked and unchecked must still differ.
580
+ const boxes = await page.evaluate((root) => {
581
+ const r = document.querySelector(root);
582
+ if (!r) return null;
583
+ const drawn = (i) => {
584
+ const cs = getComputedStyle(i);
585
+ return cs.opacity === '0' || cs.display === 'none' || i.getBoundingClientRect().width <= 1 || cs.appearance === 'none';
586
+ };
587
+ const on = [...r.querySelectorAll('input[type=checkbox]:checked')].find(drawn);
588
+ const off = [...r.querySelectorAll('input[type=checkbox]:not(:checked)')].find(drawn);
589
+ if (!on || !off) return null;
590
+ const look = (i) => {
591
+ const parts = [i, i.nextElementSibling, i.parentElement].filter(Boolean);
592
+ return parts
593
+ .flatMap((e) => [getComputedStyle(e), getComputedStyle(e, '::before'), getComputedStyle(e, '::after')])
594
+ .map((cs) => [cs.backgroundColor, cs.backgroundImage, cs.borderTopColor, cs.borderTopWidth, cs.outlineStyle, cs.content, cs.color, cs.boxShadow, cs.maskImage].join('|'))
595
+ .join('#');
596
+ };
597
+ return { same: look(on) === look(off), html: (on.nextElementSibling ?? on).outerHTML.slice(0, 200) };
598
+ }, root);
599
+ if (boxes?.same) R.fail('forced-colors-selection', c, 'In forced colours a ticked custom checkbox looks like an unticked one (its fill or tick is dropped)', [{ target: tagOf(boxes.html), html: boxes.html }]);
600
+ else if (boxes) R.pass('forced-colors-selection', c);
601
+ await page.emulateMedia({ forcedColors: 'none', reducedMotion: 'reduce' });
602
+ await sleep(300);
603
+ c = ctx(widget, 'reduced motion');
604
+ const moving = await page.evaluate((root) => {
605
+ const r = document.querySelector(root);
606
+ if (!r) return [];
607
+ const MOTION = /transform|translate|left|top|right|bottom|margin|scale|rotate|^all$/;
608
+ const out = [];
609
+ for (const e of [r, ...r.querySelectorAll('*')]) {
610
+ const cs = getComputedStyle(e);
611
+ const secs = (s) => Math.max(...s.split(',').map((x) => parseFloat(x) * (x.trim().endsWith('ms') ? 0.001 : 1)));
612
+ const anim = cs.animationName !== 'none' && secs(cs.animationDuration) > 0.01;
613
+ const props = cs.transitionProperty.split(',').map((x) => x.trim());
614
+ const durs = cs.transitionDuration.split(',').map((x) => secs(x));
615
+ const trans = props.some((p, i) => MOTION.test(p) && (durs[i % durs.length] ?? 0) > 0.01);
616
+ const smooth = cs.scrollBehavior === 'smooth' && e.scrollWidth > e.clientWidth;
617
+ if (anim || trans || smooth) out.push({ html: e.outerHTML.slice(0, 160), what: anim ? `animation ${cs.animationName}` : trans ? `transition ${cs.transitionProperty} ${cs.transitionDuration}` : 'scroll-behavior: smooth' });
618
+ if (out.length > 5) break;
619
+ }
620
+ return out;
621
+ }, root);
622
+ if (moving.length) R.fail('reduced-motion', c, 'Motion continues under prefers-reduced-motion: reduce', moving.map((m) => ({ target: tagOf(m.html), html: m.html, summary: m.what })));
623
+ else R.pass('reduced-motion', c);
624
+ await page.emulateMedia({ reducedMotion: 'no-preference' });
625
+ };
626
+
627
+ /** Under the contrast modes, focus of ours keeps a ring (a site's outline reset cannot remove it there). */
628
+ const ringCheck = async (c, root) => {
629
+ await page.evaluate((root) => /** @type {HTMLElement} */ (document.querySelector(root))?.focus({ preventScroll: true }), root);
630
+ await page.keyboard.press('Tab');
631
+ const ring = await page.evaluate((root) => {
632
+ let a = document.activeElement;
633
+ while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement;
634
+ if (!a || !document.querySelector(root)?.contains(a)) return null;
635
+ const cs = getComputedStyle(a);
636
+ return { ok: (cs.outlineStyle !== 'none' && parseFloat(cs.outlineWidth) >= 1) || cs.boxShadow !== 'none', html: a.outerHTML.slice(0, 200), outline: `${cs.outlineStyle} ${cs.outlineWidth}` };
637
+ }, root);
638
+ if (ring && !ring.ok) R.fail('focus-visible', c, 'No focus ring in this mode', [{ target: tagOf(ring.html), html: ring.html, summary: `outline: ${ring.outline}` }]);
639
+ else if (ring) R.pass('focus-visible', c);
640
+ };
641
+
642
+ // ---------------------------------------------------------------- recommenders
643
+
644
+ const recommender = async () => {
645
+ // Inline boxes load when they near the viewport: scroll down in steps.
646
+ let found = false;
647
+ for (let i = 0; i < 30 && !found; i++) {
648
+ found = await page.evaluate(() => [...document.querySelectorAll('lbx-reco')].some((r) => r.getAttribute('data-state') === 'ready' && r.querySelector('a[href]')));
649
+ if (!found) {
650
+ await page.mouse.wheel(0, 600);
651
+ await sleep(400);
652
+ }
653
+ }
654
+ if (!found) return false;
655
+ const n = await page.locator('lbx-reco[data-state=ready]').count();
656
+ for (let i = 0; i < n; i++) {
657
+ const id = await page.locator('lbx-reco[data-state=ready]').nth(i).getAttribute('data-lbx');
658
+ const root = `lbx-reco[data-lbx="${id}"]`;
659
+ const widget = `recommender ${id?.replace(/^reco-/, '') ?? i}`;
660
+ log(` ${widget}`);
661
+ await page.locator(root).scrollIntoViewIfNeeded();
662
+ let c = ctx(widget, 'shown');
663
+ const named = await page.evaluate((root) => {
664
+ const r = document.querySelector(root);
665
+ const region = [r, ...(r?.querySelectorAll('section, [role=region]') ?? [])].find((e) => e && (e.getAttribute('role') === 'region' || e.localName === 'section'));
666
+ return !!region && !!(region.getAttribute('aria-label') || region.getAttribute('aria-labelledby'));
667
+ }, root);
668
+ if (!named) R.fail('region-name', c, 'The recommender is not a named region (a section named by its title)', [{ target: root }]);
669
+ else R.pass('region-name', c);
670
+ await ours(c, [root]);
671
+ await keyboardWalk(ctx(widget, 'keyboard (Tab through)'), root, 80);
672
+ await reflow(ctx(widget, 'reflow (320 px)'), root);
673
+ await desktop();
674
+ await sleep(300);
675
+ await mediaStates(widget, root);
676
+ }
677
+ return true;
678
+ };
679
+
680
+ // ---------------------------------------------------------------- the run
681
+
682
+ let searchSeen = false;
683
+ let recoSeen = false;
684
+ let recoConfigured = false;
685
+ let first = true;
686
+ for (const p of paths) {
687
+ const cfg = await load(p);
688
+ if (!cfg) continue;
689
+ await hostAxe();
690
+ if (first && cfg.autocomplete && only('autocomplete')) await autocomplete(cfg);
691
+ let route = await listingShown(first && !opts.paths?.length ? 4000 : 15000);
692
+ if (route && only(route)) {
693
+ if (route === 'search') searchSeen = true;
694
+ await listing(route);
695
+ } else if (opts.paths?.length && !route && !cfg.recommenders) R.note(`${p}: none of Luma's listings was shown (not a search or category page of the integration, or handed back to the host)`);
696
+ // The search page, from the header's search form (as the shopper gets there), once.
697
+ if (first && !searchSeen && cfg.search && only('search') && !paths.slice(1).some((x) => /[?&]q=/.test(x))) {
698
+ const sel = cfg._global?.inputSelector || 'input[name=q]';
699
+ const input = page.locator(sel).first();
700
+ if (await input.isVisible().catch(() => false)) {
701
+ await desktop();
702
+ await input.fill(query);
703
+ await input.press('Escape').catch(() => {});
704
+ await input.press('Enter');
705
+ await page.waitForLoadState('load').catch(() => {});
706
+ await booted();
707
+ path = await page.evaluate(() => location.pathname + location.search);
708
+ log(`page ${path} (the search form)`);
709
+ route = await listingShown();
710
+ if (route === 'search') {
711
+ searchSeen = true;
712
+ await hostAxe();
713
+ await listing('search');
714
+ } else R.skip('search', `the search form led to ${path}, where Luma's search listing was not shown`);
715
+ }
716
+ }
717
+ if (cfg.recommenders && Object.keys(cfg.recommenders).length && only('recommender')) {
718
+ recoConfigured = true;
719
+ if (await recommender()) recoSeen = true;
720
+ }
721
+ await healthErrors();
722
+ first = false;
723
+ }
724
+ if (only('recommender') && recoConfigured && !recoSeen) R.skip('recommender', 'no inline recommender rendered on the pages checked (pass a product page: yarn luma a11y /some-product)');
725
+ if (only('recommender') && recoConfigured) R.skip('recommender popup', 'opens on an add-to-cart click, which this command never makes');
726
+ };
727
+
728
+ /** `<tag class="a b">` → `tag.a.b` (for a finding's target when the node has no selector). */
729
+ const tagOf = (html) => {
730
+ const m = /^<([\w-]+)([^>]*)>/.exec(html ?? '');
731
+ if (!m) return 'node';
732
+ const id = /\bid="([^"]+)"/.exec(m[2])?.[1];
733
+ const cls = /\bclass="([^"]+)"/.exec(m[2])?.[1]?.trim().split(/\s+/).slice(0, 3).join('.');
734
+ return `${m[1]}${id ? `#${id}` : ''}${cls ? `.${cls}` : ''}`;
735
+ };