@meddleware/frontend-config 0.0.0-stage → 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 +32 -0
- package/LICENSE +14 -0
- package/README.md +124 -2
- package/SECURITY.md +30 -0
- package/dist/a11y.d.ts +41 -0
- package/dist/axe.d.ts +12 -0
- package/dist/bundle.d.ts +21 -0
- package/dist/check-prod-bundle.d.ts +2 -0
- package/dist/eslint.d.ts +33 -0
- package/dist/html-validate.d.ts +30 -0
- package/dist/index.d.ts +6 -0
- package/dist/lighthouserc.d.ts +54 -0
- package/dist/look.d.ts +35 -0
- package/dist/playwright.d.ts +21 -0
- package/dist/postcss.d.ts +5 -0
- package/dist/shots.d.ts +10 -0
- package/dist/stylelint.d.ts +105 -0
- package/dist/vite.d.ts +43 -0
- package/dist/vitest.d.ts +11 -0
- package/dist/wallet.d.ts +42 -0
- package/package.json +167 -4
- package/src/a11y.js +235 -0
- package/src/axe.js +43 -0
- package/src/browserslist-config.cjs +21 -0
- package/src/bundle.js +57 -0
- package/src/check-prod-bundle.js +45 -0
- package/src/eslint.js +88 -0
- package/src/html-validate.js +49 -0
- package/src/index.js +12 -0
- package/src/lighthouserc.js +39 -0
- package/src/look.js +59 -0
- package/src/playwright.js +62 -0
- package/src/postcss.js +7 -0
- package/src/shots.js +33 -0
- package/src/stylelint.js +107 -0
- package/src/vite.js +87 -0
- package/src/vitest.js +52 -0
- package/src/wallet.js +38 -0
package/dist/vitest.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jsdom does not implement `<dialog>` modality (`showModal`, `close`, `returnValue`). This minimal shim models the parts `UiDialog` relies on:
|
|
3
|
+
* the `open` attribute, `returnValue`, and a `close` event on closing. Call it once (`beforeAll(installDialogShim)`) in a jsdom test that
|
|
4
|
+
* renders a dialog; the browser's real behaviour (top layer, inert background) is checked by the Playwright suites, not jsdom.
|
|
5
|
+
*/
|
|
6
|
+
export function installDialogShim(): void;
|
|
7
|
+
/**
|
|
8
|
+
* Packages to process through Vite in tests: the ones that import CSS or `.vue` files. Vitest matches these against the module's resolved
|
|
9
|
+
* file path (`…/node_modules/@meddleware/ui/dist/…`), not the package name, so the patterns match the path.
|
|
10
|
+
*/
|
|
11
|
+
export const inlineMeddleware: RegExp[];
|
package/dist/wallet.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connect the mock wallet as a visitor would: open the wallet dialog, choose the wallet, and wait for the dialog to close.
|
|
3
|
+
* @param {import('@playwright/test').Page} page
|
|
4
|
+
* @param {{ wallet?: string, trigger?: string | RegExp }} [options] The wallet's name in the picker; the name of the button that opens the dialog.
|
|
5
|
+
*/
|
|
6
|
+
export function connectMockWallet(page: import("@playwright/test").Page, { wallet, trigger }?: {
|
|
7
|
+
wallet?: string;
|
|
8
|
+
trigger?: string | RegExp;
|
|
9
|
+
}): Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {object} NodeSigner
|
|
12
|
+
* @property {(transactionJson: string, chain: string) => Promise<{ bytes: string, signature: string }>} [signTransaction]
|
|
13
|
+
* Build and sign a transaction (`Transaction.from(json)`); resolves base64 bytes and signature.
|
|
14
|
+
* @property {(message: string) => Promise<{ signature: string }>} [signPersonalMessage] Sign a personal message (base64 bytes).
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Let the page's mock wallet sign in this Node process. Call it before `page.goto`: the functions are exposed to every page load, and
|
|
18
|
+
* `installE2eWallet` uses them when they exist.
|
|
19
|
+
* @param {import('@playwright/test').Page} page
|
|
20
|
+
* @param {NodeSigner} signer
|
|
21
|
+
* @param {{ address?: string }} [options] The address the wallet connects with: the signer's own, so the transaction's sender and its signature agree.
|
|
22
|
+
*/
|
|
23
|
+
export function exposeSigner(page: import("@playwright/test").Page, signer: NodeSigner, { address }?: {
|
|
24
|
+
address?: string;
|
|
25
|
+
}): Promise<void>;
|
|
26
|
+
/** The address the mock wallet connects with (`MOCK_ADDRESS` in wallet-adapter's testing module). */
|
|
27
|
+
export const E2E_ADDRESS: string;
|
|
28
|
+
export type NodeSigner = {
|
|
29
|
+
/**
|
|
30
|
+
* Build and sign a transaction (`Transaction.from(json)`); resolves base64 bytes and signature.
|
|
31
|
+
*/
|
|
32
|
+
signTransaction?: ((transactionJson: string, chain: string) => Promise<{
|
|
33
|
+
bytes: string;
|
|
34
|
+
signature: string;
|
|
35
|
+
}>) | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Sign a personal message (base64 bytes).
|
|
38
|
+
*/
|
|
39
|
+
signPersonalMessage?: ((message: string) => Promise<{
|
|
40
|
+
signature: string;
|
|
41
|
+
}>) | undefined;
|
|
42
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,169 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meddleware/frontend-config",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Shared frontend tooling for MeddleWare apps: browser support, ESLint, Stylelint, html-validate, PostCSS, Playwright, axe, screenshots, Lighthouse and the Vite target.",
|
|
6
|
+
"license": "0BSD",
|
|
7
|
+
"author": "MeddleWare <dev@meddleware.co.uk>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/meddleware-org/frontend-config.git"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"browserslist",
|
|
14
|
+
"eslint",
|
|
15
|
+
"stylelint",
|
|
16
|
+
"html-validate",
|
|
17
|
+
"playwright",
|
|
18
|
+
"accessibility",
|
|
19
|
+
"wcag",
|
|
20
|
+
"vue"
|
|
21
|
+
],
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"dist",
|
|
25
|
+
"CHANGELOG.md",
|
|
26
|
+
"SECURITY.md"
|
|
27
|
+
],
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./src/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./browserslist-config": "./src/browserslist-config.cjs",
|
|
34
|
+
"./eslint": {
|
|
35
|
+
"types": "./dist/eslint.d.ts",
|
|
36
|
+
"default": "./src/eslint.js"
|
|
37
|
+
},
|
|
38
|
+
"./stylelint": {
|
|
39
|
+
"types": "./dist/stylelint.d.ts",
|
|
40
|
+
"default": "./src/stylelint.js"
|
|
41
|
+
},
|
|
42
|
+
"./html-validate": {
|
|
43
|
+
"types": "./dist/html-validate.d.ts",
|
|
44
|
+
"default": "./src/html-validate.js"
|
|
45
|
+
},
|
|
46
|
+
"./postcss": {
|
|
47
|
+
"types": "./dist/postcss.d.ts",
|
|
48
|
+
"default": "./src/postcss.js"
|
|
49
|
+
},
|
|
50
|
+
"./playwright": {
|
|
51
|
+
"types": "./dist/playwright.d.ts",
|
|
52
|
+
"default": "./src/playwright.js"
|
|
53
|
+
},
|
|
54
|
+
"./look": {
|
|
55
|
+
"types": "./dist/look.d.ts",
|
|
56
|
+
"default": "./src/look.js"
|
|
57
|
+
},
|
|
58
|
+
"./axe": {
|
|
59
|
+
"types": "./dist/axe.d.ts",
|
|
60
|
+
"default": "./src/axe.js"
|
|
61
|
+
},
|
|
62
|
+
"./a11y": {
|
|
63
|
+
"types": "./dist/a11y.d.ts",
|
|
64
|
+
"default": "./src/a11y.js"
|
|
65
|
+
},
|
|
66
|
+
"./shots": {
|
|
67
|
+
"types": "./dist/shots.d.ts",
|
|
68
|
+
"default": "./src/shots.js"
|
|
69
|
+
},
|
|
70
|
+
"./lighthouserc": {
|
|
71
|
+
"types": "./dist/lighthouserc.d.ts",
|
|
72
|
+
"default": "./src/lighthouserc.js"
|
|
73
|
+
},
|
|
74
|
+
"./vite": {
|
|
75
|
+
"types": "./dist/vite.d.ts",
|
|
76
|
+
"default": "./src/vite.js"
|
|
77
|
+
},
|
|
78
|
+
"./vitest": {
|
|
79
|
+
"types": "./dist/vitest.d.ts",
|
|
80
|
+
"default": "./src/vitest.js"
|
|
81
|
+
},
|
|
82
|
+
"./wallet": {
|
|
83
|
+
"types": "./dist/wallet.d.ts",
|
|
84
|
+
"default": "./src/wallet.js"
|
|
85
|
+
},
|
|
86
|
+
"./bundle": {
|
|
87
|
+
"types": "./dist/bundle.d.ts",
|
|
88
|
+
"default": "./src/bundle.js"
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"scripts": {
|
|
92
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
93
|
+
"prepublishOnly": "npm run build",
|
|
94
|
+
"type-check": "tsc --noEmit",
|
|
95
|
+
"test": "vitest run"
|
|
96
|
+
},
|
|
97
|
+
"dependencies": {
|
|
98
|
+
"@axe-core/playwright": "~4.14.0",
|
|
99
|
+
"@double-great/stylelint-a11y": "^3.8.1",
|
|
100
|
+
"@meddleware/eslint-config": "^0.0.4",
|
|
101
|
+
"@vue/eslint-config-typescript": "^14.9.0",
|
|
102
|
+
"autoprefixer": "^10.6.2",
|
|
103
|
+
"browserslist-to-esbuild": "^2.1.1",
|
|
104
|
+
"eslint-plugin-compat": "^7.0.2",
|
|
105
|
+
"eslint-plugin-vue": "^10.12.0",
|
|
106
|
+
"eslint-plugin-vuejs-accessibility": "^2.6.3",
|
|
107
|
+
"html-validate-vue": "^9.3.1",
|
|
108
|
+
"postcss-html": "^2.0.0",
|
|
109
|
+
"stylelint-config-standard": "^40.0.0",
|
|
110
|
+
"stylelint-declaration-strict-value": "^1.12.1",
|
|
111
|
+
"stylelint-no-unsupported-browser-features": "^8.1.2",
|
|
112
|
+
"typescript-eslint": "^8.72.1",
|
|
113
|
+
"vue-eslint-parser": "^10.4.1"
|
|
114
|
+
},
|
|
115
|
+
"peerDependencies": {
|
|
116
|
+
"@playwright/test": "~1.64.1",
|
|
117
|
+
"eslint": "^10.0.0",
|
|
118
|
+
"html-validate": "^11.0.0",
|
|
119
|
+
"postcss": "^8.5.0",
|
|
120
|
+
"stylelint": "^17.0.0"
|
|
121
|
+
},
|
|
122
|
+
"peerDependenciesMeta": {
|
|
123
|
+
"@playwright/test": {
|
|
124
|
+
"optional": true
|
|
125
|
+
},
|
|
126
|
+
"html-validate": {
|
|
127
|
+
"optional": true
|
|
128
|
+
},
|
|
129
|
+
"postcss": {
|
|
130
|
+
"optional": true
|
|
131
|
+
},
|
|
132
|
+
"stylelint": {
|
|
133
|
+
"optional": true
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
"devDependencies": {
|
|
137
|
+
"@playwright/test": "~1.64.1",
|
|
138
|
+
"@types/jsdom": "^30.0.0",
|
|
139
|
+
"@types/node": "~26.6.5",
|
|
140
|
+
"browserslist": "^4.29.1",
|
|
141
|
+
"eslint": "^10.13.0",
|
|
142
|
+
"globals": "^17.0.0",
|
|
143
|
+
"html-validate": "^11.17.0",
|
|
144
|
+
"jsdom": "^30.1.3",
|
|
145
|
+
"postcss": "^8.5.32",
|
|
146
|
+
"stylelint": "^17.16.0",
|
|
147
|
+
"typescript": "~6.0.3",
|
|
148
|
+
"vitest": "~5.0.5"
|
|
149
|
+
},
|
|
150
|
+
"publishConfig": {
|
|
151
|
+
"access": "public"
|
|
152
|
+
},
|
|
153
|
+
"sideEffects": false,
|
|
154
|
+
"browserslist": [
|
|
155
|
+
"last 2 Chrome versions",
|
|
156
|
+
"last 2 ChromeAndroid versions",
|
|
157
|
+
"last 2 Edge versions",
|
|
158
|
+
"last 2 Firefox versions",
|
|
159
|
+
"last 2 FirefoxAndroid versions",
|
|
160
|
+
"last 2 Safari versions",
|
|
161
|
+
"last 2 iOS versions",
|
|
162
|
+
"last 2 Samsung versions",
|
|
163
|
+
"Firefox ESR",
|
|
164
|
+
"not dead"
|
|
165
|
+
],
|
|
166
|
+
"bin": {
|
|
167
|
+
"check-prod-bundle": "./src/check-prod-bundle.js"
|
|
168
|
+
}
|
|
169
|
+
}
|
package/src/a11y.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The accessibility sweep every app runs, in one call: each state of the app (a route, a dialog open, an error page) in every colour mode
|
|
3
|
+
// × season at WCAG 2.2 AA and AAA, then at a 320 px reflow width, at 200% text size, in forced colours and with reduced motion, and a
|
|
4
|
+
// keyboard pass (every Tab stop is visible and shows where focus is). The assertions are exported on their own for specs that need one.
|
|
5
|
+
//
|
|
6
|
+
// // e2e/a11y.spec.ts
|
|
7
|
+
// defineA11ySuite({ home: async (page) => { await page.goto('/') }, empty: openEmpty }, { aaa: 'gate' })
|
|
8
|
+
import { expect, test } from '@playwright/test'
|
|
9
|
+
import { expectAccessible } from './axe.js'
|
|
10
|
+
import { applyLook, looks, offline } from './look.js'
|
|
11
|
+
|
|
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). */
|
|
14
|
+
|
|
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).
|
|
17
|
+
* @param {import('@playwright/test').Page} page
|
|
18
|
+
*/
|
|
19
|
+
export async function expectNoHorizontalScroll(page) {
|
|
20
|
+
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
|
21
|
+
expect(overflow, 'the page scrolls sideways by this many CSS pixels').toBeLessThanOrEqual(0)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* No animation is running: with `prefers-reduced-motion: reduce` the page holds still (WCAG 2.3.3).
|
|
26
|
+
* @param {import('@playwright/test').Page} page
|
|
27
|
+
*/
|
|
28
|
+
export async function expectNoRunningAnimations(page) {
|
|
29
|
+
await page.waitForTimeout(250)
|
|
30
|
+
const running = await page.evaluate(() => document.getAnimations().filter((a) => a.playState === 'running').map((a) => (/** @type {KeyframeEffect | null} */ (a.effect)?.target?.className || 'animation')))
|
|
31
|
+
expect(running, 'animations still running under reduced motion').toEqual([])
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
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).
|
|
129
|
+
* @param {import('@playwright/test').Page} page
|
|
130
|
+
* @param {{ maxStops?: number }} [options]
|
|
131
|
+
*/
|
|
132
|
+
export async function expectKeyboardFocusVisible(page, { maxStops = 300 } = {}) {
|
|
133
|
+
/** @type {string[]} */
|
|
134
|
+
const problems = []
|
|
135
|
+
let stops = 0
|
|
136
|
+
let wrapped = false
|
|
137
|
+
let finished = false
|
|
138
|
+
await startAtTop(page)
|
|
139
|
+
for (let i = 0; i < maxStops; i++) {
|
|
140
|
+
await page.keyboard.press('Tab')
|
|
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
|
|
148
|
+
}
|
|
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
|
+
}
|
|
156
|
+
stops++
|
|
157
|
+
if (!stop.visible) problems.push(`${stop.label} takes focus but is not visible`)
|
|
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)`)
|
|
160
|
+
}
|
|
161
|
+
expect(stops, 'the page has no keyboard stops').toBeGreaterThan(0)
|
|
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([])
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @param {Record<string, State>} states Opens each state of the app (navigates, mocks, opens the dialog).
|
|
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.
|
|
178
|
+
*/
|
|
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 ?? [])
|
|
183
|
+
test.describe(name, () => {
|
|
184
|
+
test.beforeEach(async ({ page }) => {
|
|
185
|
+
await offline(page)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
for (const { theme, season } of looks().filter((l) => l.viewport === 'desktop')) {
|
|
189
|
+
test(`WCAG 2.2 AA and AAA (${theme} · ${season})`, async ({ page }) => {
|
|
190
|
+
await page.emulateMedia({ colorScheme: /** @type {'light' | 'dark'} */ (theme), reducedMotion: 'reduce' })
|
|
191
|
+
await open(page)
|
|
192
|
+
await applyLook(page, theme, season)
|
|
193
|
+
await expectAccessible(page, { aaa, disableRules })
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
test('reflows at 320 CSS pixels', async ({ page }) => {
|
|
198
|
+
await page.setViewportSize({ width: 320, height: 640 })
|
|
199
|
+
await open(page)
|
|
200
|
+
await expectNoHorizontalScroll(page)
|
|
201
|
+
await expectAccessible(page, { aaa: 'off', disableRules })
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('holds at 200% text size', async ({ page }) => {
|
|
205
|
+
await open(page)
|
|
206
|
+
await page.addStyleTag({ content: 'html { font-size: 200% !important; }' })
|
|
207
|
+
await expectNoHorizontalScroll(page)
|
|
208
|
+
await expectAccessible(page, { aaa: 'off', disableRules })
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('works in forced colours', async ({ page }) => {
|
|
212
|
+
await page.emulateMedia({ forcedColors: 'active' })
|
|
213
|
+
await open(page)
|
|
214
|
+
// The browser chooses the colours in forced-colours mode, so axe's contrast arithmetic on the declared ones does not apply.
|
|
215
|
+
await expectAccessible(page, { aaa: 'off', disableRules: [...disableRules, 'color-contrast'] })
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('holds still with reduced motion', async ({ page }) => {
|
|
219
|
+
await page.emulateMedia({ reducedMotion: 'reduce' })
|
|
220
|
+
await open(page)
|
|
221
|
+
await expectNoRunningAnimations(page)
|
|
222
|
+
})
|
|
223
|
+
|
|
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
|
+
}
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
}
|
package/src/axe.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Accessibility assertion for Playwright: WCAG 2.2 AA is always a hard gate. WCAG AAA (enhanced contrast and so on) is
|
|
3
|
+
// `report` (attached to the test as annotations; the default while a repo still has findings) or `gate` (fails the run; a repo
|
|
4
|
+
// switches to it in the release that clears its AAA findings, which is the target) or `off`.
|
|
5
|
+
import AxeBuilder from '@axe-core/playwright'
|
|
6
|
+
import { expect, test } from '@playwright/test'
|
|
7
|
+
import { AXE_TAGS_AA, AXE_TAGS_AAA } from './index.js'
|
|
8
|
+
|
|
9
|
+
/** @param {import('axe-core').Result[]} violations */
|
|
10
|
+
function describe(violations) {
|
|
11
|
+
return violations.map((v) => ({
|
|
12
|
+
rule: v.id,
|
|
13
|
+
impact: v.impact,
|
|
14
|
+
help: v.help,
|
|
15
|
+
nodes: v.nodes.slice(0, 6).map((n) => `${n.target.join(' ')} — ${n.failureSummary?.split('\n').slice(0, 3).join(' | ')}`),
|
|
16
|
+
}))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Fail on any WCAG 2.2 A/AA (or best-practice) violation. AAA findings: `aaa: 'gate'` fails on them, `'report'` (default)
|
|
21
|
+
* attaches them to the test as `aaa` annotations, `'off'` skips the AAA run.
|
|
22
|
+
* @param {import('@playwright/test').Page} page
|
|
23
|
+
* @param {{ include?: string, exclude?: string[], disableRules?: string[], aaa?: 'gate' | 'report' | 'off' }} [options]
|
|
24
|
+
*/
|
|
25
|
+
export async function expectAccessible(page, options = {}) {
|
|
26
|
+
const build = (/** @type {readonly string[]} */ tags) => {
|
|
27
|
+
let b = new AxeBuilder({ page }).withTags([...tags]).disableRules(options.disableRules ?? [])
|
|
28
|
+
if (options.include) b = b.include(options.include)
|
|
29
|
+
for (const e of options.exclude ?? []) b = b.exclude(e)
|
|
30
|
+
return b
|
|
31
|
+
}
|
|
32
|
+
const { violations } = await build(AXE_TAGS_AA).analyze()
|
|
33
|
+
expect(describe(violations), 'WCAG 2.2 AA violations').toEqual([])
|
|
34
|
+
if (options.aaa === 'off') return
|
|
35
|
+
const aaa = await build(AXE_TAGS_AAA).analyze()
|
|
36
|
+
if (options.aaa === 'gate') {
|
|
37
|
+
expect(describe(aaa.violations), 'WCAG AAA violations').toEqual([])
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
for (const v of describe(aaa.violations)) {
|
|
41
|
+
test.info().annotations.push({ type: 'aaa', description: `${v.rule}: ${v.help} (${v.nodes.length} node(s))` })
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The workspace browser support promise, as a browserslist shared config. A repo uses it with
|
|
2
|
+
// "browserslist": ["extends @meddleware/frontend-config/browserslist-config"]
|
|
3
|
+
// (browserslist resolves `@scope/pkg/browserslist-config`). It drives autoprefixer, eslint-plugin-compat,
|
|
4
|
+
// stylelint-no-unsupported-browser-features and the Vite build target, so one list decides all four.
|
|
5
|
+
//
|
|
6
|
+
// Deliberately NOT "last 2 versions": that also selects Opera Mini, KaiOS, Baidu and QQ. Every app here is a Vue SPA and the
|
|
7
|
+
// wallet apps need wallet-standard browser extensions, so none of those engines can run them; fallbacks for them would
|
|
8
|
+
// cost CSS and complexity for no working user. Newer-than-the-oldest-browser features still need @supports / progressive
|
|
9
|
+
// enhancement, which the compat linters enforce.
|
|
10
|
+
module.exports = [
|
|
11
|
+
'last 2 Chrome versions',
|
|
12
|
+
'last 2 ChromeAndroid versions',
|
|
13
|
+
'last 2 Edge versions',
|
|
14
|
+
'last 2 Firefox versions',
|
|
15
|
+
'last 2 FirefoxAndroid versions',
|
|
16
|
+
'last 2 Safari versions',
|
|
17
|
+
'last 2 iOS versions',
|
|
18
|
+
'last 2 Samsung versions',
|
|
19
|
+
'Firefox ESR',
|
|
20
|
+
'not dead',
|
|
21
|
+
]
|
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)` : ''}).`)
|