@meddleware/frontend-config 0.0.1 → 0.0.2

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/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.0.2] - 2026-11-01
9
+
10
+ ### Added
11
+
12
+ - `check-prod-bundle` (and `scanBundle` at `/bundle`): the production-artefact scan for the e2e harness, moved here from token-issuance-ui so
13
+ every wallet app runs the same one (the mock-wallet hooks; `--also` / `--also-regex` add an app's own stand-in marker).
14
+
15
+ - `expectKeyboardFocusVisible` (and so `defineA11ySuite`) now also fails a stop that is entirely hidden behind sticky or fixed content when it
16
+ takes focus (WCAG 2.4.11 focus not obscured; axe has no rule for it), going forward and then backwards through the tab order; the message names the covering element, and the pointer is parked first so a stale hover tooltip cannot cover the walk; each stop is judged after the scroll has settled (WebKit animates the scroll that focus causes).
17
+
18
+ - `defineA11ySuite` takes `noKeyboard: [state names]` for states with nothing to operate (they skip the keyboard pass; every other state must
19
+ have a stop). `expectKeyboardFocusVisible` no longer reports "no keyboard stops" for a page reached with focus on its last control.
20
+
21
+ ### Changed
22
+
23
+ - The keyboard walk follows element identity (a repeated link label no longer ends it early), stops at 300 stops and fails when it is capped (`maxStops` raises the cap); its test limit scales with the cap (WebKit needs about half a minute for a page of a hundred stops).
24
+ - `SECURITY.md` is part of the published files.
25
+
8
26
  ## [0.0.1] - 2026-10-31
9
27
 
10
28
  ### Added
package/README.md CHANGED
@@ -17,6 +17,8 @@ same accessibility and browser-support bar.
17
17
  | `/shots` | `defineShots(states)` — one screenshot per state × look, for looking at the UI. |
18
18
  | `/a11y` | `defineA11ySuite(states, { aaa })` — the sweep every app runs, one call: each state in every colour mode × season at WCAG 2.2 AA and AAA, a 320 px reflow, 200% text size, forced colours, reduced motion and a keyboard pass. `expectNoHorizontalScroll`, `expectNoRunningAnimations` and `expectKeyboardFocusVisible` are exported on their own. |
19
19
  | `/vitest` | `inlineMeddleware` — the packages a test must process through Vite (they import `.css`/`.vue`), which also gives them the test's single copy of Vue; `installDialogShim()` — jsdom's missing `<dialog>` modality. |
20
+ | `/bundle` + `check-prod-bundle` | `scanBundle(dist, { also })` and the `check-prod-bundle [dist] [--also TEXT]... [--also-regex PATTERN]...` command: fails when the e2e harness (the mock-wallet hooks, or an app's own stand-in marker) is found in a production build. Run it after the production build in CI and in the Dockerfile. |
21
+ | `/wallet` | `connectMockWallet(page)`, `exposeSigner(page, signer)`, `E2E_ADDRESS` — the Playwright side of the mock wallet. |
20
22
  | `/lighthouserc` | `lighthouseConfig({ port, paths?, … })` — Lighthouse CI (accessibility must score 100). |
21
23
 
22
24
  ## Use
@@ -108,6 +110,14 @@ await connectMockWallet(page) // the app's wallet dialog → "Test Wallet"
108
110
 
109
111
  The page side is `@meddleware/wallet-adapter/testing` (`installE2eWallet`), registered by an app's e2e build.
110
112
 
113
+ ## Keeping the e2e harness out of production
114
+
115
+ An app's end-to-end build (`vite build --mode e2e`) registers a mock wallet and a stub chain. Three things keep that out of every
116
+ other build: the app's entry loads it only when `import.meta.env.MODE === 'e2e' && import.meta.env.VITE_E2E === '1'` (both static, so
117
+ the block is removed); `assertE2eOnlyInE2eMode(mode)` from `/vite` fails a build with `VITE_E2E` set in any other mode; and
118
+ `check-prod-bundle dist` (`/bundle`) scans the built files for the harness's hooks and fails if any is present. An app whose e2e build also
119
+ swaps a module for a stand-in adds a marker of that stand-in: `check-prod-bundle dist --also 'a string only the stand-in contains'` (`--also-regex` takes a pattern).
120
+
111
121
  ## Peers
112
122
 
113
123
  `eslint` ^10 (required for `/eslint`), and optional peers per subpath: `stylelint` ^17, `html-validate` ^11, `postcss` ^8,
package/SECURITY.md ADDED
@@ -0,0 +1,30 @@
1
+ # Security Policy
2
+
3
+ ## Scope
4
+
5
+ This policy covers `@meddleware/frontend-config`: lint, build and test configuration for MeddleWare frontends. It runs only in a
6
+ consumer's development and CI processes (ESLint, Stylelint, html-validate, PostCSS, Vite, Playwright, Lighthouse); it ships nothing
7
+ to end users and makes no network requests of its own.
8
+
9
+ It does not cover a consuming application, or the tools and plugins it configures.
10
+
11
+ ## What it guarantees
12
+
13
+ The rules are a **guard rail**: they catch accidents and shortcuts, not deliberate evasion (a `stylelint-disable` or
14
+ `eslint-disable` comment defeats them). Accessibility checks are automated and cover only what axe and the linters can detect;
15
+ they are not a substitute for manual testing with assistive technology.
16
+
17
+ ## Supply chain
18
+
19
+ Releases are published from this repository's `npm-publish.yml` with npm trusted publishing (OIDC and provenance); tokens are not
20
+ used. A compromised release could weaken the lint, accessibility and browser-support gates in every consumer, or run code in their
21
+ build, so report anything that suggests tampering.
22
+
23
+ ## Supported versions
24
+
25
+ Only the latest published npm version receives fixes.
26
+
27
+ ## Reporting a vulnerability
28
+
29
+ Please **do not** open a public GitHub issue for security vulnerabilities. Use GitHub's private vulnerability reporting for this
30
+ repository, or email dev@meddleware.co.uk.
package/dist/a11y.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- /** @typedef {(page: import('@playwright/test').Page) => Promise<void>} State */
1
+ /** @typedef {(page: import('@playwright/test').Page) => Promise<void>} Open */
2
+ /** @typedef {Open | { open: Open, disableRules?: string[] }} State A state is how to open it, or that and the axe rules that do not apply to it (a lone dialog has no landmarks). */
2
3
  /**
3
4
  * The page has no horizontal scroll: nothing is wider than the viewport (WCAG 1.4.10 reflow, and 1.4.4 at a larger text size).
4
5
  * @param {import('@playwright/test').Page} page
@@ -10,7 +11,9 @@ export function expectNoHorizontalScroll(page: import("@playwright/test").Page):
10
11
  */
11
12
  export function expectNoRunningAnimations(page: import("@playwright/test").Page): Promise<void>;
12
13
  /**
13
- * Tab through the page: every stop is visible and shows a focus indicator (an outline or a shadow), and the tab order ends (no trap).
14
+ * Tab through the page: every stop is visible, shows a focus indicator (an outline or a shadow) and is not hidden behind sticky or fixed
15
+ * content, and the tab order ends (no trap). Then Shift+Tab back through it, so a stop reached from below is checked as well (browsers
16
+ * scroll a stop into view from the side it arrives from, so a sticky header or a bottom bar can cover it in only one direction).
14
17
  * @param {import('@playwright/test').Page} page
15
18
  * @param {{ maxStops?: number }} [options]
16
19
  */
@@ -19,10 +22,20 @@ export function expectKeyboardFocusVisible(page: import("@playwright/test").Page
19
22
  }): Promise<void>;
20
23
  /**
21
24
  * @param {Record<string, State>} states Opens each state of the app (navigates, mocks, opens the dialog).
22
- * @param {{ aaa?: 'gate' | 'report' | 'off' }} [options] How WCAG AAA findings are treated (default `report`; a repo moves to `gate`
23
- * in the release that clears them).
25
+ * @param {{ aaa?: 'gate' | 'report' | 'off', noKeyboard?: string[], maxStops?: number }} [options] How WCAG AAA findings are treated (default `report`; a repo
26
+ * moves to `gate` in the release that clears them); `noKeyboard` names states with nothing to operate (a static summary, a modal that
27
+ * only reports progress), which skip the keyboard pass: every other state must have at least one keyboard stop; `maxStops` (default 300) caps the keyboard walk, and a page with more stops fails rather than passing unchecked.
24
28
  */
25
- export function defineA11ySuite(states: Record<string, State>, { aaa }?: {
29
+ export function defineA11ySuite(states: Record<string, State>, { aaa, noKeyboard, maxStops }?: {
26
30
  aaa?: "gate" | "report" | "off";
31
+ noKeyboard?: string[];
32
+ maxStops?: number;
27
33
  }): void;
28
- export type State = (page: import("@playwright/test").Page) => Promise<void>;
34
+ export type Open = (page: import("@playwright/test").Page) => Promise<void>;
35
+ /**
36
+ * A state is how to open it, or that and the axe rules that do not apply to it (a lone dialog has no landmarks).
37
+ */
38
+ export type State = Open | {
39
+ open: Open;
40
+ disableRules?: string[];
41
+ };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Scan the built files under `dist` for the harness.
3
+ * @param {string} dist The build output directory.
4
+ * @param {{ also?: (string | RegExp)[] }} [options] Extra markers: an app's own stand-in (a string is matched literally).
5
+ * @returns {{ scanned: number, hits: string[] }}
6
+ * @throws When `dist` cannot be read.
7
+ */
8
+ export function scanBundle(dist: string, { also }?: {
9
+ also?: (string | RegExp)[];
10
+ }): {
11
+ scanned: number;
12
+ hits: string[];
13
+ };
14
+ /**
15
+ * What the shared harness (`@meddleware/wallet-adapter/testing` `installE2eWallet`, `@meddleware/frontend-config/wallet`) leaves in a
16
+ * bundle that includes it: the hooks it puts on `window`. Property names survive minification.
17
+ */
18
+ export const E2E_MARKERS: readonly {
19
+ name: string;
20
+ re: RegExp;
21
+ }[];
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meddleware/frontend-config",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "type": "module",
5
5
  "description": "Shared frontend tooling for MeddleWare apps: browser support, ESLint, Stylelint, html-validate, PostCSS, Playwright, axe, screenshots, Lighthouse and the Vite target.",
6
6
  "license": "0BSD",
@@ -22,7 +22,8 @@
22
22
  "files": [
23
23
  "src",
24
24
  "dist",
25
- "CHANGELOG.md"
25
+ "CHANGELOG.md",
26
+ "SECURITY.md"
26
27
  ],
27
28
  "exports": {
28
29
  ".": {
@@ -81,6 +82,10 @@
81
82
  "./wallet": {
82
83
  "types": "./dist/wallet.d.ts",
83
84
  "default": "./src/wallet.js"
85
+ },
86
+ "./bundle": {
87
+ "types": "./dist/bundle.d.ts",
88
+ "default": "./src/bundle.js"
84
89
  }
85
90
  },
86
91
  "scripts": {
@@ -157,5 +162,8 @@
157
162
  "last 2 Samsung versions",
158
163
  "Firefox ESR",
159
164
  "not dead"
160
- ]
165
+ ],
166
+ "bin": {
167
+ "check-prod-bundle": "./src/check-prod-bundle.js"
168
+ }
161
169
  }
package/src/a11y.js CHANGED
@@ -9,7 +9,8 @@ import { expect, test } from '@playwright/test'
9
9
  import { expectAccessible } from './axe.js'
10
10
  import { applyLook, looks, offline } from './look.js'
11
11
 
12
- /** @typedef {(page: import('@playwright/test').Page) => Promise<void>} State */
12
+ /** @typedef {(page: import('@playwright/test').Page) => Promise<void>} Open */
13
+ /** @typedef {Open | { open: Open, disableRules?: string[] }} State A state is how to open it, or that and the axe rules that do not apply to it (a lone dialog has no landmarks). */
13
14
 
14
15
  /**
15
16
  * The page has no horizontal scroll: nothing is wider than the viewport (WCAG 1.4.10 reflow, and 1.4.4 at a larger text size).
@@ -31,54 +32,154 @@ export async function expectNoRunningAnimations(page) {
31
32
  }
32
33
 
33
34
  /**
34
- * Tab through the page: every stop is visible and shows a focus indicator (an outline or a shadow), and the tab order ends (no trap).
35
+ * What the focused element looks like: visible, shows an indicator, and is not hidden behind other content. Runs in the page.
36
+ * @param {import('@playwright/test').Page} page
37
+ */
38
+ async function probeFocus(page) {
39
+ // A browser may animate the scroll that brings the focused control into view (WebKit does, and `scroll-behavior: smooth` makes the
40
+ // others): judge where it comes to rest, not where it passes through.
41
+ await page.evaluate(
42
+ () =>
43
+ new Promise((done) => {
44
+ let last = [scrollX, scrollY]
45
+ let still = 0
46
+ let frames = 0
47
+ const tick = () => {
48
+ const now = [scrollX, scrollY]
49
+ if (now[0] === last[0] && now[1] === last[1]) still++
50
+ else {
51
+ still = 0
52
+ last = now
53
+ }
54
+ if (still >= 3 || ++frames > 60) done(undefined)
55
+ else requestAnimationFrame(tick)
56
+ }
57
+ requestAnimationFrame(tick)
58
+ }),
59
+ )
60
+ return page.evaluate(() => {
61
+ const el = document.activeElement
62
+ // Once Tab has left the last control, Firefox hands focus to the browser's own chrome: the document no longer has focus, yet
63
+ // `activeElement` still names the control it left. That is the end of the tab order, not a stop.
64
+ if (!el || el === document.body || !document.hasFocus()) return null
65
+ // A visually hidden control (a radio or checkbox inside its label) shows focus on its label, so that is what is checked.
66
+ const tiny = el.getBoundingClientRect().width < 4 || el.getBoundingClientRect().height < 4
67
+ const target = tiny ? (el.closest('label') ?? el.parentElement ?? el) : el
68
+ const box = target.getBoundingClientRect()
69
+ const shows = (/** @type {Element} */ node) => {
70
+ const style = getComputedStyle(node)
71
+ return (style.outlineStyle !== 'none' && parseFloat(style.outlineWidth) > 0) || style.boxShadow !== 'none'
72
+ }
73
+ const indicator = shows(target) || (!tiny && shows(el)) || ['::before', '::after'].some((p) => {
74
+ const s = getComputedStyle(target, p)
75
+ return (s.outlineStyle !== 'none' && parseFloat(s.outlineWidth) > 0) || s.boxShadow !== 'none'
76
+ })
77
+ // WCAG 2.4.11 (focus not obscured): once focus has scrolled the stop into view, it is not entirely hidden behind sticky or fixed
78
+ // content. Five points are sampled (the centre and four inset corners of the part inside the viewport); hidden means all are covered.
79
+ const x0 = Math.max(box.left, 0)
80
+ const x1 = Math.min(box.right, innerWidth)
81
+ const y0 = Math.max(box.top, 0)
82
+ const y1 = Math.min(box.bottom, innerHeight)
83
+ const inView = x1 > x0 && y1 > y0
84
+ const samples = [[0.5, 0.5], [0.1, 0.1], [0.9, 0.1], [0.1, 0.9], [0.9, 0.9]]
85
+ /** @type {Element | null} */
86
+ let cover = null
87
+ const obscured = inView && samples.every(([fx, fy]) => {
88
+ const top = document.elementFromPoint(x0 + (x1 - x0) * /** @type {number} */ (fx), y0 + (y1 - y0) * /** @type {number} */ (fy))
89
+ cover = top
90
+ return !!top && !target.contains(top) && !top.contains(target)
91
+ })
92
+ const coveredBy = obscured && cover ? `${/** @type {Element} */ (cover).tagName.toLowerCase()}${/** @type {Element} */ (cover).id ? `#${/** @type {Element} */ (cover).id}` : ''}${/** @type {Element} */ (cover).className && typeof (/** @type {Element} */ (cover).className) === 'string' ? `.${String(/** @type {Element} */ (cover).className).trim().split(/\s+/).slice(0, 2).join('.')}` : ''}` : ''
93
+ // The walk is complete when focus comes back to an element it has already stopped on: elements are compared by identity (two links can
94
+ // share a label, as the same link often appears in a menu and in a sidebar), through a set the walk resets at its start.
95
+ const seen = /** @type {{ __mwSeen?: WeakSet<Element> }} */ (/** @type {unknown} */ (window)).__mwSeen
96
+ const repeat = !!seen && seen.has(el)
97
+ seen?.add(el)
98
+ const label = `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}${el.getAttribute('aria-label') ? `[${el.getAttribute('aria-label')}]` : ''} "${(target.textContent ?? '').trim().slice(0, 24)}"`
99
+ return { label, visible: box.width > 0 && box.height > 0, indicator, obscured, coveredBy, repeat }
100
+ })
101
+ }
102
+
103
+ /**
104
+ * Put the keyboard at the start of the document: nothing focused, the pointer out of the way, and the point sequential navigation continues
105
+ * from at the top (a button that was just clicked is where the next Tab would otherwise start).
106
+ * @param {import('@playwright/test').Page} page
107
+ */
108
+ async function startAtTop(page) {
109
+ // The pointer where an earlier click left it would keep a hover tooltip open over the controls the keyboard walk is about to reach.
110
+ await page.mouse.move(0, 0)
111
+ await page.evaluate(() => {
112
+ ;/** @type {{ __mwSeen?: WeakSet<Element> }} */ (/** @type {unknown} */ (window)).__mwSeen = new WeakSet()
113
+ const active = document.activeElement
114
+ if (active instanceof HTMLElement && active !== document.body) active.blur()
115
+ const selection = getSelection()
116
+ if (!selection) return
117
+ selection.removeAllRanges()
118
+ const range = document.createRange()
119
+ range.setStart(document.body, 0)
120
+ range.collapse(true)
121
+ selection.addRange(range)
122
+ })
123
+ }
124
+
125
+ /**
126
+ * Tab through the page: every stop is visible, shows a focus indicator (an outline or a shadow) and is not hidden behind sticky or fixed
127
+ * content, and the tab order ends (no trap). Then Shift+Tab back through it, so a stop reached from below is checked as well (browsers
128
+ * scroll a stop into view from the side it arrives from, so a sticky header or a bottom bar can cover it in only one direction).
35
129
  * @param {import('@playwright/test').Page} page
36
130
  * @param {{ maxStops?: number }} [options]
37
131
  */
38
- export async function expectKeyboardFocusVisible(page, { maxStops = 120 } = {}) {
132
+ export async function expectKeyboardFocusVisible(page, { maxStops = 300 } = {}) {
39
133
  /** @type {string[]} */
40
134
  const problems = []
41
135
  let stops = 0
42
- const seen = new Set()
136
+ let wrapped = false
137
+ let finished = false
138
+ await startAtTop(page)
43
139
  for (let i = 0; i < maxStops; i++) {
44
140
  await page.keyboard.press('Tab')
45
- const stop = await page.evaluate(() => {
46
- const el = document.activeElement
47
- if (!el || el === document.body) return null
48
- // A visually hidden control (a radio or checkbox inside its label) shows focus on its label, so that is what is checked.
49
- const tiny = el.getBoundingClientRect().width < 4 || el.getBoundingClientRect().height < 4
50
- const target = tiny ? (el.closest('label') ?? el.parentElement ?? el) : el
51
- const box = target.getBoundingClientRect()
52
- const shows = (/** @type {Element} */ node) => {
53
- const style = getComputedStyle(node)
54
- return (style.outlineStyle !== 'none' && parseFloat(style.outlineWidth) > 0) || style.boxShadow !== 'none'
141
+ const stop = await probeFocus(page)
142
+ if (!stop) {
143
+ // Focus left the document: the cycle is complete. But a page that is reached with focus already on its last control (a button just
144
+ // clicked) leaves on the first Tab, before any stop was seen: that press only wraps round to the start.
145
+ if (stops === 0 && !wrapped) {
146
+ wrapped = true
147
+ continue
55
148
  }
56
- const indicator = shows(target) || (!tiny && shows(el)) || ['::before', '::after'].some((p) => {
57
- const s = getComputedStyle(target, p)
58
- return (s.outlineStyle !== 'none' && parseFloat(s.outlineWidth) > 0) || s.boxShadow !== 'none'
59
- })
60
- const label = `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}${el.getAttribute('aria-label') ? `[${el.getAttribute('aria-label')}]` : ''} "${(target.textContent ?? '').trim().slice(0, 24)}"`
61
- return { label, visible: box.width > 0 && box.height > 0, indicator }
62
- })
63
- if (!stop) break // focus left the document: the cycle is complete
64
- const key = stop.label
65
- if (seen.has(key) && seen.size > 1 && i > seen.size + 1) break
66
- seen.add(key)
149
+ finished = true
150
+ break
151
+ }
152
+ if (stop.repeat) {
153
+ finished = true // back at a stop already seen: a focus cycle (a modal dialog holds focus inside itself), so the walk is complete
154
+ break
155
+ }
67
156
  stops++
68
157
  if (!stop.visible) problems.push(`${stop.label} takes focus but is not visible`)
69
158
  if (!stop.indicator) problems.push(`${stop.label} shows no focus indicator`)
159
+ if (stop.obscured) problems.push(`${stop.label} is hidden behind ${stop.coveredBy || 'other content'} when focused (WCAG 2.4.11: add scroll-padding for sticky chrome)`)
70
160
  }
71
161
  expect(stops, 'the page has no keyboard stops').toBeGreaterThan(0)
72
- expect(problems).toEqual([])
162
+ // A walk that hit its cap has not seen the rest of the page: say so instead of passing it.
163
+ if (!finished) problems.push(`the tab order has more than ${maxStops} stops, so the rest of the page was not checked (raise maxStops for this state)`)
164
+ for (let i = 0; i < Math.min(stops, maxStops); i++) {
165
+ await page.keyboard.press('Shift+Tab')
166
+ const stop = await probeFocus(page)
167
+ if (!stop) break
168
+ if (stop.obscured) problems.push(`${stop.label} is hidden behind ${stop.coveredBy || 'other content'} when reached backwards (WCAG 2.4.11: add scroll-padding for sticky chrome)`)
169
+ }
170
+ expect([...new Set(problems)]).toEqual([])
73
171
  }
74
172
 
75
173
  /**
76
174
  * @param {Record<string, State>} states Opens each state of the app (navigates, mocks, opens the dialog).
77
- * @param {{ aaa?: 'gate' | 'report' | 'off' }} [options] How WCAG AAA findings are treated (default `report`; a repo moves to `gate`
78
- * in the release that clears them).
175
+ * @param {{ aaa?: 'gate' | 'report' | 'off', noKeyboard?: string[], maxStops?: number }} [options] How WCAG AAA findings are treated (default `report`; a repo
176
+ * moves to `gate` in the release that clears them); `noKeyboard` names states with nothing to operate (a static summary, a modal that
177
+ * only reports progress), which skip the keyboard pass: every other state must have at least one keyboard stop; `maxStops` (default 300) caps the keyboard walk, and a page with more stops fails rather than passing unchecked.
79
178
  */
80
- export function defineA11ySuite(states, { aaa = 'report' } = {}) {
81
- for (const [name, open] of Object.entries(states)) {
179
+ export function defineA11ySuite(states, { aaa = 'report', noKeyboard = [], maxStops } = {}) {
180
+ for (const [name, state] of Object.entries(states)) {
181
+ const open = typeof state === 'function' ? state : state.open
182
+ const disableRules = typeof state === 'function' ? [] : (state.disableRules ?? [])
82
183
  test.describe(name, () => {
83
184
  test.beforeEach(async ({ page }) => {
84
185
  await offline(page)
@@ -89,7 +190,7 @@ export function defineA11ySuite(states, { aaa = 'report' } = {}) {
89
190
  await page.emulateMedia({ colorScheme: /** @type {'light' | 'dark'} */ (theme), reducedMotion: 'reduce' })
90
191
  await open(page)
91
192
  await applyLook(page, theme, season)
92
- await expectAccessible(page, { aaa })
193
+ await expectAccessible(page, { aaa, disableRules })
93
194
  })
94
195
  }
95
196
 
@@ -97,21 +198,21 @@ export function defineA11ySuite(states, { aaa = 'report' } = {}) {
97
198
  await page.setViewportSize({ width: 320, height: 640 })
98
199
  await open(page)
99
200
  await expectNoHorizontalScroll(page)
100
- await expectAccessible(page, { aaa: 'off' })
201
+ await expectAccessible(page, { aaa: 'off', disableRules })
101
202
  })
102
203
 
103
204
  test('holds at 200% text size', async ({ page }) => {
104
205
  await open(page)
105
206
  await page.addStyleTag({ content: 'html { font-size: 200% !important; }' })
106
207
  await expectNoHorizontalScroll(page)
107
- await expectAccessible(page, { aaa: 'off' })
208
+ await expectAccessible(page, { aaa: 'off', disableRules })
108
209
  })
109
210
 
110
211
  test('works in forced colours', async ({ page }) => {
111
212
  await page.emulateMedia({ forcedColors: 'active' })
112
213
  await open(page)
113
214
  // The browser chooses the colours in forced-colours mode, so axe's contrast arithmetic on the declared ones does not apply.
114
- await expectAccessible(page, { aaa: 'off', disableRules: ['color-contrast'] })
215
+ await expectAccessible(page, { aaa: 'off', disableRules: [...disableRules, 'color-contrast'] })
115
216
  })
116
217
 
117
218
  test('holds still with reduced motion', async ({ page }) => {
@@ -120,10 +221,15 @@ export function defineA11ySuite(states, { aaa = 'report' } = {}) {
120
221
  await expectNoRunningAnimations(page)
121
222
  })
122
223
 
123
- test('can be used from the keyboard, and shows where focus is', async ({ page }) => {
124
- await open(page)
125
- await expectKeyboardFocusVisible(page)
126
- })
224
+ if (!noKeyboard.includes(name)) {
225
+ test('can be used from the keyboard, and shows where focus is', async ({ page }) => {
226
+ // The walk probes every stop twice (forward, then backward), and WebKit animates the scroll each one causes: a page of a
227
+ // hundred stops takes about half a minute there, longer than the default 30 s. Scale the limit with the cap.
228
+ test.setTimeout(Math.max(60_000, (maxStops || 300) * 400))
229
+ await open(page)
230
+ await expectKeyboardFocusVisible(page, maxStops ? { maxStops } : {})
231
+ })
232
+ }
127
233
  })
128
234
  }
129
235
  }
package/src/bundle.js ADDED
@@ -0,0 +1,57 @@
1
+ // @ts-check
2
+ // Production-artefact scan for the end-to-end harness. An app's e2e build (`vite build --mode e2e`) registers a mock wallet, a stub chain
3
+ // and sometimes a stand-in for a module that needs a live service; none of it may reach a production build. The app's entry loads the
4
+ // harness only when the mode is `e2e` and `VITE_E2E` is `1` (both statically replaced), so every other build drops the block. This scan
5
+ // proves it on the built files instead of trusting that: it fails when a hook or marker is found in `dist/`.
6
+ //
7
+ // // package.json: "check:bundle": "check-prod-bundle dist"
8
+ // // CI, after the production build; and in the Dockerfile, right after `npm run build`
9
+ // // an app with its own stand-in adds a marker: "check-prod-bundle dist --also 'e2e session key'"
10
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
11
+ import { join, relative } from 'node:path'
12
+
13
+ /**
14
+ * What the shared harness (`@meddleware/wallet-adapter/testing` `installE2eWallet`, `@meddleware/frontend-config/wallet`) leaves in a
15
+ * bundle that includes it: the hooks it puts on `window`. Property names survive minification.
16
+ */
17
+ export const E2E_MARKERS = Object.freeze([
18
+ { name: '__registerMockWallet hook', re: /__registerMockWallet/ },
19
+ { name: '__unregisterMockWallet hook', re: /__unregisterMockWallet/ },
20
+ { name: '__e2eAddress hook', re: /__e2eAddress/ },
21
+ { name: '__e2eWalletAddress hook', re: /__e2eWalletAddress/ },
22
+ { name: '__e2eSignTransaction hook', re: /__e2eSignTransaction/ },
23
+ { name: '__e2eSignPersonalMessage hook', re: /__e2eSignPersonalMessage/ },
24
+ { name: '__getSuiClient hook', re: /__getSuiClient/ },
25
+ ])
26
+
27
+ /** @param {string} dir @returns {Generator<string>} */
28
+ function* files(dir) {
29
+ for (const name of readdirSync(dir)) {
30
+ const path = join(dir, name)
31
+ if (statSync(path).isDirectory()) yield* files(path)
32
+ else if (/\.(m?js|html)$/.test(name)) yield path
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Scan the built files under `dist` for the harness.
38
+ * @param {string} dist The build output directory.
39
+ * @param {{ also?: (string | RegExp)[] }} [options] Extra markers: an app's own stand-in (a string is matched literally).
40
+ * @returns {{ scanned: number, hits: string[] }}
41
+ * @throws When `dist` cannot be read.
42
+ */
43
+ export function scanBundle(dist, { also = [] } = {}) {
44
+ const markers = [
45
+ ...E2E_MARKERS,
46
+ ...also.map((m) => ({ name: `marker ${String(m)}`, re: typeof m === 'string' ? new RegExp(m.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) : m })),
47
+ ]
48
+ let scanned = 0
49
+ /** @type {string[]} */
50
+ const hits = []
51
+ for (const file of files(dist)) {
52
+ scanned++
53
+ const text = readFileSync(file, 'utf8')
54
+ for (const { name, re } of markers) if (re.test(text)) hits.push(`${relative(dist, file)}: ${name}`)
55
+ }
56
+ return { scanned, hits }
57
+ }
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // CLI for `scanBundle`: `check-prod-bundle [dist] [--also TEXT]... [--also-regex PATTERN]...` (text is matched literally). Exit 1 when the harness is found, when `dist` is missing or when it
4
+ // holds no .js/.html file (a build that has not run must not pass).
5
+ import { scanBundle } from './bundle.js'
6
+
7
+ const args = process.argv.slice(2)
8
+ /** @type {(string | RegExp)[]} */
9
+ const also = []
10
+ /** @type {string[]} */
11
+ const rest = []
12
+ for (let i = 0; i < args.length; i++) {
13
+ if (args[i] === '--also' || args[i] === '--also-regex') {
14
+ const flag = args[i]
15
+ const marker = args[++i]
16
+ if (!marker) {
17
+ console.error(`check-prod-bundle: ${flag} needs a marker`)
18
+ process.exit(2)
19
+ }
20
+ try {
21
+ also.push(flag === '--also' ? marker : new RegExp(marker))
22
+ } catch {
23
+ console.error(`check-prod-bundle: ${marker} is not a valid pattern`)
24
+ process.exit(2)
25
+ }
26
+ } else rest.push(/** @type {string} */ (args[i]))
27
+ }
28
+ const dist = rest[0] ?? 'dist'
29
+
30
+ let result
31
+ try {
32
+ result = scanBundle(dist, { also })
33
+ } catch (e) {
34
+ console.error(`check-prod-bundle: cannot read ${dist}/ (${/** @type {Error} */ (e).message}) — build first.`)
35
+ process.exit(1)
36
+ }
37
+ if (result.scanned === 0) {
38
+ console.error(`check-prod-bundle: no .js/.html files under ${dist}/ — build first.`)
39
+ process.exit(1)
40
+ }
41
+ if (result.hits.length > 0) {
42
+ console.error(`check-prod-bundle: the e2e harness is in the production bundle:\n ${result.hits.join('\n ')}`)
43
+ process.exit(1)
44
+ }
45
+ console.log(`check-prod-bundle: ${result.scanned} files clean (no e2e hooks${also.length ? ` or the ${also.length} extra marker(s)` : ''}).`)