@uniaria/testing 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,341 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ //#region src/screen-reader-commands.ts
4
+ /**
5
+ * Node-side commands that drive a real screen reader through Guidepup
6
+ * (`@guidepup/guidepup`, an optional peer): VoiceOver on macOS and NVDA on
7
+ * Windows, for the scenarios that need one (design doc, Testing and
8
+ * accessibility: real screen readers, nightly). Register them beside
9
+ * `uniCommands` in a browser project that runs headed, one file at a time,
10
+ * on a machine prepared with `@guidepup/setup` (`setup --ci`, then
11
+ * `install`).
12
+ *
13
+ * Keys go through the operating system, as a user's would, so the browser
14
+ * window has to be in front and the screen reader inside the page: starting
15
+ * does what `@guidepup/playwright` does (MIT), ported because that package
16
+ * needs `@playwright/test`.
17
+ */
18
+ let running;
19
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
20
+ /** Playwright's browsers as macOS knows them, most likely first. */
21
+ const MAC_BUNDLE_IDS = {
22
+ chromium: ["com.google.chrome.for.testing", "org.chromium.Chromium"],
23
+ firefox: ["org.mozilla.nightlyunofficial", "org.mozilla.nightly"],
24
+ webkit: ["org.webkit.Playwright"]
25
+ };
26
+ /** Playwright's browsers as their Windows window titles end. */
27
+ const WINDOWS_NAMES = {
28
+ chromium: ["Google Chrome for Testing", "Chromium"],
29
+ firefox: ["Nightly", "Firefox"],
30
+ webkit: ["Playwright"]
31
+ };
32
+ function browserName(context) {
33
+ return context.context.browser()?.browserType().name() ?? "chromium";
34
+ }
35
+ function active() {
36
+ if (!running) throw new Error("[@uniaria/testing] No screen reader is running: the screen-reader project starts one for each file.");
37
+ return running;
38
+ }
39
+ /** Lower case, punctuation as spaces: how window titles are compared. */
40
+ const clean = (text) => text.toLowerCase().replace(/[|¦:;'"`\-‐–—·_()[\]{}\\^~]/g, " ").replace(/\s+/g, " ").trim();
41
+ /** Brings the browser in front by its bundle id, trying each in turn. */
42
+ async function activateMac(ids) {
43
+ const run = promisify(execFile);
44
+ for (const id of ids) try {
45
+ await run("osascript", ["-e", `tell application id "${id}" to activate`]);
46
+ return;
47
+ } catch {}
48
+ throw new Error(`[@uniaria/testing] Could not bring the browser in front (${ids.join(", ")}).`);
49
+ }
50
+ /**
51
+ * Brings the browser in front and moves VoiceOver into its web content
52
+ * through the item chooser, as `@guidepup/playwright` does.
53
+ */
54
+ async function enterPageWithVoiceOver(guidepup, context) {
55
+ const { voiceOver, MacOSKeyCodes, voiceOverKeyCodeCommands } = guidepup;
56
+ const silence = () => voiceOver.perform({ keyCode: MacOSKeyCodes.Control }, { capture: false });
57
+ await activateMac(MAC_BUNDLE_IDS[browserName(context)] ?? []);
58
+ await silence();
59
+ await context.page.bringToFront();
60
+ await context.page.evaluate(() => {
61
+ const marker = document.createElement("input");
62
+ marker.id = "__uniaria_screen_reader_marker__";
63
+ marker.readOnly = true;
64
+ marker.tabIndex = -1;
65
+ marker.setAttribute("aria-label", "Start");
66
+ marker.style.cssText = "position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)";
67
+ document.body.prepend(marker);
68
+ });
69
+ try {
70
+ for (let attempt = 0;; attempt++) {
71
+ await silence();
72
+ await delay(100);
73
+ await voiceOver.perform({ keyCode: MacOSKeyCodes.Escape }, { capture: false });
74
+ await delay(100);
75
+ await voiceOver.perform(voiceOverKeyCodeCommands.openItemChooser);
76
+ if ((await voiceOver.lastSpokenPhrase()).toLowerCase().includes("item chooser")) break;
77
+ if (attempt === 10) throw new Error("[@uniaria/testing] VoiceOver did not open its item chooser.");
78
+ }
79
+ let isFound = false;
80
+ for (const character of "web content") {
81
+ await silence();
82
+ await delay(100);
83
+ await voiceOver.type(character, { capture: "initial" });
84
+ if ((await voiceOver.lastSpokenPhrase()).toLowerCase().includes("web content")) {
85
+ isFound = true;
86
+ break;
87
+ }
88
+ }
89
+ if (!isFound) throw new Error("[@uniaria/testing] VoiceOver found no \"web content\" in its item chooser.");
90
+ await voiceOver.perform({ keyCode: MacOSKeyCodes.Enter }, { capture: false });
91
+ await voiceOver.interact({ capture: false });
92
+ await delay(100);
93
+ } finally {
94
+ await context.page.evaluate(() => document.getElementById("__uniaria_screen_reader_marker__")?.remove());
95
+ }
96
+ }
97
+ /**
98
+ * Switches windows until NVDA reports the browser's title, then puts NVDA
99
+ * in browse mode at the top of the page, as `@guidepup/playwright` does.
100
+ */
101
+ async function enterPageWithNvda(guidepup, context) {
102
+ const { nvda, NVDAKeyCodeCommands, WindowsKeyCodes, WindowsModifiers } = guidepup;
103
+ const title = clean(await context.page.title());
104
+ const names = (WINDOWS_NAMES[browserName(context)] ?? []).map(clean);
105
+ const isBrowser = (window) => title.length > 0 && window.startsWith(title) || names.some((name) => window.includes(name));
106
+ await nvda.perform(NVDAKeyCodeCommands.exitFocusMode, { capture: false });
107
+ await delay(100);
108
+ for (let attempt = 0;; attempt++) {
109
+ await nvda.perform(NVDAKeyCodeCommands.reportTitle);
110
+ if (isBrowser(clean(await nvda.lastSpokenPhrase()))) break;
111
+ if (attempt === 10) throw new Error("[@uniaria/testing] NVDA could not bring the browser in front.");
112
+ await nvda.perform({
113
+ keyCode: [WindowsKeyCodes.Escape],
114
+ modifiers: [WindowsModifiers.Alt]
115
+ }, { capture: false });
116
+ await delay(500);
117
+ }
118
+ await context.page.bringToFront();
119
+ const body = context.page.locator("body");
120
+ await body.focus();
121
+ await body.click();
122
+ await body.blur();
123
+ for (const command of [
124
+ NVDAKeyCodeCommands.readNextFocusableItem,
125
+ NVDAKeyCodeCommands.toggleBetweenBrowseAndFocusMode,
126
+ NVDAKeyCodeCommands.toggleBetweenBrowseAndFocusMode,
127
+ NVDAKeyCodeCommands.exitFocusMode
128
+ ]) {
129
+ await nvda.perform(command, { capture: false });
130
+ await delay(100);
131
+ }
132
+ for (let attempt = 0;; attempt++) {
133
+ await nvda.perform(NVDAKeyCodeCommands.reportCurrentFocus);
134
+ const focus = clean(await nvda.lastSpokenPhrase());
135
+ if (focus.includes("document") || title && focus.includes(title)) break;
136
+ if (attempt === 6) throw new Error(`[@uniaria/testing] NVDA could not move Chrome's focus into the page: ${focus}.`);
137
+ await nvda.press("F6", { capture: false });
138
+ await delay(300);
139
+ }
140
+ }
141
+ /**
142
+ * Starts the operating system's screen reader and moves it into the page.
143
+ * Returns which one runs; a second call while it runs returns the same.
144
+ */
145
+ const uniScreenReaderStart = async (context) => {
146
+ if (running) return running.name;
147
+ const name = process.platform === "darwin" ? "voiceover" : process.platform === "win32" ? "nvda" : void 0;
148
+ if (!name) throw new Error(`[@uniaria/testing] No screen reader to drive on ${process.platform}: VoiceOver needs macOS, NVDA needs Windows.`);
149
+ const guidepup = await import("@guidepup/guidepup");
150
+ const reader = name === "voiceover" ? guidepup.voiceOver : guidepup.nvda;
151
+ await reader.start();
152
+ try {
153
+ if (name === "voiceover") await enterPageWithVoiceOver(guidepup, context);
154
+ else await enterPageWithNvda(guidepup, context);
155
+ await reader.clearSpokenPhraseLog();
156
+ } catch (error) {
157
+ await reader.stop().catch(() => void 0);
158
+ throw error;
159
+ }
160
+ running = {
161
+ name,
162
+ reader,
163
+ guidepup,
164
+ read: 0,
165
+ hidden: /* @__PURE__ */ new Set()
166
+ };
167
+ return name;
168
+ };
169
+ /** Stops the screen reader, if one runs. */
170
+ const uniScreenReaderStop = async () => {
171
+ const current = running;
172
+ running = void 0;
173
+ await current?.reader.stop().catch(() => void 0);
174
+ };
175
+ /**
176
+ * Records what the screen reader is saying now, then forgets everything it
177
+ * has said: the next key's phrases are its own. Shift is the key it presses,
178
+ * which neither screen reader speaks.
179
+ */
180
+ const uniScreenReaderSettle = async () => {
181
+ const current = active();
182
+ await delay(500);
183
+ await current.reader.press("Shift");
184
+ await current.reader.clearSpokenPhraseLog();
185
+ current.read = 0;
186
+ current.hidden.clear();
187
+ };
188
+ /**
189
+ * What the screen reader holds for the element with keyboard focus: NVDA
190
+ * reads it again (NVDA+Tab), VoiceOver gives the text of the item in its
191
+ * cursor, which follows keyboard focus (a key to describe it would turn
192
+ * that tracking off). `spoken` leaves NVDA's report out.
193
+ */
194
+ async function readFocus(current) {
195
+ const { guidepup } = current;
196
+ if (current.name === "voiceover") return guidepup.voiceOver.itemText();
197
+ const from = (await current.reader.spokenPhraseLog()).length;
198
+ await guidepup.nvda.perform(guidepup.NVDAKeyCodeCommands.reportCurrentFocus);
199
+ const log = await current.reader.spokenPhraseLog();
200
+ for (let index = from; index < log.length; index++) current.hidden.add(index);
201
+ return log.slice(from).join(" / ");
202
+ }
203
+ /**
204
+ * With `UNIARIA_SCREEN_READERS=discover`, logs what the screen reader holds
205
+ * for the focused element after each key, to tell a focus that went astray
206
+ * from one it didn't announce.
207
+ */
208
+ async function reportFocus(current, after) {
209
+ if (process.env.UNIARIA_SCREEN_READERS !== "discover") return;
210
+ console.log(`[${current.name}] focus after ${after}: ${JSON.stringify(await readFocus(current))}`);
211
+ }
212
+ /** Presses a key through the screen reader and waits for its speech. */
213
+ const uniScreenReaderPress = async (_context, key) => {
214
+ const current = active();
215
+ await current.reader.press(key);
216
+ await reportFocus(current, key);
217
+ };
218
+ /** Types text through the screen reader, a key per character. */
219
+ const uniScreenReaderType = async (_context, text) => {
220
+ const current = active();
221
+ await current.reader.type(text);
222
+ await reportFocus(current, JSON.stringify(text));
223
+ };
224
+ /** What the screen reader holds for the element with keyboard focus. */
225
+ const uniScreenReaderReadFocus = () => readFocus(active());
226
+ /** Moves the screen reader's cursor to the next item. */
227
+ const uniScreenReaderNext = async () => {
228
+ await active().reader.next();
229
+ };
230
+ /** The phrases spoken since the last call, in order. */
231
+ const uniScreenReaderSpoken = async () => {
232
+ const current = active();
233
+ const log = await current.reader.spokenPhraseLog();
234
+ const fresh = log.slice(current.read).filter((_, index) => !current.hidden.has(current.read + index));
235
+ current.read = log.length;
236
+ return fresh;
237
+ };
238
+ const screenReaderCommands = {
239
+ uniScreenReaderStart,
240
+ uniScreenReaderStop,
241
+ uniScreenReaderSettle,
242
+ uniScreenReaderPress,
243
+ uniScreenReaderType,
244
+ uniScreenReaderNext,
245
+ uniScreenReaderReadFocus,
246
+ uniScreenReaderSpoken
247
+ };
248
+ //#endregion
249
+ //#region src/commands.ts
250
+ /** How far below a target `moveOutside` puts the pointer. */
251
+ const OUTSIDE_OFFSET = 24;
252
+ async function boxOf(context, selector) {
253
+ const locator = (await context.frame()).locator(selector);
254
+ await locator.scrollIntoViewIfNeeded();
255
+ const box = await locator.boundingBox();
256
+ if (!box) throw new Error(`Conformance input: "${selector}" has no box.`);
257
+ return box;
258
+ }
259
+ const uniPointerDown = async (context, selector, button) => {
260
+ const box = await boxOf(context, selector);
261
+ await context.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
262
+ await context.page.mouse.down({ button });
263
+ };
264
+ const uniPointerMove = async (context, selector, where) => {
265
+ const box = await boxOf(context, selector);
266
+ const y = where === "center" ? box.y + box.height / 2 : box.y + box.height + OUTSIDE_OFFSET;
267
+ await context.page.mouse.move(box.x + box.width / 2, y, { steps: 2 });
268
+ };
269
+ const uniPointerUp = async (context, button) => {
270
+ await context.page.mouse.up({ button });
271
+ };
272
+ /** Turns the wheel wherever the pointer is. */
273
+ const uniWheel = async (context, deltaX, deltaY) => {
274
+ await context.page.mouse.wheel(deltaX, deltaY);
275
+ };
276
+ const uniKeyDown = async (context, key) => {
277
+ await context.page.keyboard.down(key);
278
+ };
279
+ const uniKeyUp = async (context, key) => {
280
+ await context.page.keyboard.up(key);
281
+ };
282
+ /** One DevTools session per page, for touch input. */
283
+ const sessions = /* @__PURE__ */ new WeakMap();
284
+ function isChromium(context) {
285
+ return context.context.browser()?.browserType().name() === "chromium";
286
+ }
287
+ /**
288
+ * Whether `uniTouch` works in this browser. Only Chromium takes real touch
289
+ * input through DevTools (`Input.dispatchTouchEvent`); Playwright's own
290
+ * touchscreen needs `hasTouch` on the whole context, which would make every
291
+ * other test run as if on a touch device.
292
+ */
293
+ const uniTouchSupported = (context) => isChromium(context);
294
+ /**
295
+ * A finger at the target's centre (or 24px below it, `outside`) for
296
+ * `touchStart` and `touchMove`; lifted for `touchEnd` and `touchCancel`.
297
+ */
298
+ const uniTouch = async (context, phase, selector, where = "center") => {
299
+ if (!isChromium(context)) throw new Error("Conformance input: touch needs Chromium.");
300
+ let session = sessions.get(context.page);
301
+ if (!session) {
302
+ session = context.context.newCDPSession(context.page);
303
+ sessions.set(context.page, session);
304
+ }
305
+ const touchPoints = [];
306
+ if (selector !== void 0) {
307
+ const box = await boxOf(context, selector);
308
+ const y = where === "center" ? box.y + box.height / 2 : box.y + box.height + OUTSIDE_OFFSET;
309
+ touchPoints.push({
310
+ x: box.x + box.width / 2,
311
+ y,
312
+ id: 0
313
+ });
314
+ }
315
+ await (await session).send("Input.dispatchTouchEvent", {
316
+ type: phase,
317
+ touchPoints
318
+ });
319
+ };
320
+ /**
321
+ * Emulates media features for the whole page, frames included. Forced
322
+ * colors work in Chromium only.
323
+ */
324
+ const uniEmulateMedia = async (context, features) => {
325
+ await context.page.emulateMedia(features);
326
+ };
327
+ const uniCommands = {
328
+ uniTouchSupported,
329
+ uniTouch,
330
+ uniPointerDown,
331
+ uniPointerMove,
332
+ uniPointerUp,
333
+ uniWheel,
334
+ uniKeyDown,
335
+ uniKeyUp,
336
+ uniEmulateMedia
337
+ };
338
+ //#endregion
339
+ export { screenReaderCommands, uniCommands, uniEmulateMedia, uniKeyDown, uniKeyUp, uniPointerDown, uniPointerMove, uniPointerUp, uniTouch, uniTouchSupported, uniWheel };
340
+
341
+ //# sourceMappingURL=commands.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"commands.js","names":[],"sources":["../src/screen-reader-commands.ts","../src/commands.ts"],"sourcesContent":["/**\n * Node-side commands that drive a real screen reader through Guidepup\n * (`@guidepup/guidepup`, an optional peer): VoiceOver on macOS and NVDA on\n * Windows, for the scenarios that need one (design doc, Testing and\n * accessibility: real screen readers, nightly). Register them beside\n * `uniCommands` in a browser project that runs headed, one file at a time,\n * on a machine prepared with `@guidepup/setup` (`setup --ci`, then\n * `install`).\n *\n * Keys go through the operating system, as a user's would, so the browser\n * window has to be in front and the screen reader inside the page: starting\n * does what `@guidepup/playwright` does (MIT), ported because that package\n * needs `@playwright/test`.\n */\n\nimport { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\n\nimport type * as GuidepupModule from '@guidepup/guidepup';\nimport type {} from '@vitest/browser-playwright';\nimport type { BrowserCommand, BrowserCommandContext } from 'vitest/node';\n\nimport type { ScreenReaderName } from './conformance/scenario';\n\ntype Guidepup = typeof GuidepupModule;\ntype Reader = Guidepup['voiceOver'] | Guidepup['nvda'];\n\ninterface Running {\n readonly name: ScreenReaderName;\n readonly reader: Reader;\n readonly guidepup: Guidepup;\n /** How much of the spoken phrase log `spoken` has returned. */\n read: number;\n /** Log entries `spoken` leaves out: focus reports while discovering. */\n readonly hidden: Set<number>;\n}\n\nlet running: Running | undefined;\n\nconst delay = (ms: number) =>\n new Promise<void>((resolve) => setTimeout(resolve, ms));\n\n/** Playwright's browsers as macOS knows them, most likely first. */\nconst MAC_BUNDLE_IDS: Readonly<Record<string, readonly string[]>> = {\n chromium: ['com.google.chrome.for.testing', 'org.chromium.Chromium'],\n firefox: ['org.mozilla.nightlyunofficial', 'org.mozilla.nightly'],\n webkit: ['org.webkit.Playwright'],\n};\n\n/** Playwright's browsers as their Windows window titles end. */\nconst WINDOWS_NAMES: Readonly<Record<string, readonly string[]>> = {\n chromium: ['Google Chrome for Testing', 'Chromium'],\n firefox: ['Nightly', 'Firefox'],\n webkit: ['Playwright'],\n};\n\nfunction browserName(context: BrowserCommandContext): string {\n return context.context.browser()?.browserType().name() ?? 'chromium';\n}\n\nfunction active(): Running {\n if (!running) {\n throw new Error(\n '[@uniaria/testing] No screen reader is running: the screen-reader project starts one for each file.',\n );\n }\n return running;\n}\n\n/** Lower case, punctuation as spaces: how window titles are compared. */\nconst clean = (text: string) =>\n text\n .toLowerCase()\n .replace(/[|¦:;'\"`\\-‐–—·_()[\\]{}\\\\^~]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n\n/** Brings the browser in front by its bundle id, trying each in turn. */\nasync function activateMac(ids: readonly string[]): Promise<void> {\n const run = promisify(execFile);\n for (const id of ids) {\n try {\n await run('osascript', ['-e', `tell application id \"${id}\" to activate`]);\n return;\n } catch {\n // Not this build; try the next id.\n }\n }\n throw new Error(\n `[@uniaria/testing] Could not bring the browser in front (${ids.join(', ')}).`,\n );\n}\n\n/**\n * Brings the browser in front and moves VoiceOver into its web content\n * through the item chooser, as `@guidepup/playwright` does.\n */\nasync function enterPageWithVoiceOver(\n guidepup: Guidepup,\n context: BrowserCommandContext,\n): Promise<void> {\n const { voiceOver, MacOSKeyCodes, voiceOverKeyCodeCommands } = guidepup;\n const silence = () =>\n voiceOver.perform({ keyCode: MacOSKeyCodes.Control }, { capture: false });\n await activateMac(MAC_BUNDLE_IDS[browserName(context)] ?? []);\n await silence();\n await context.page.bringToFront();\n // Something the item chooser can find as \"web content\".\n await context.page.evaluate(() => {\n const marker = document.createElement('input');\n marker.id = '__uniaria_screen_reader_marker__';\n marker.readOnly = true;\n marker.tabIndex = -1;\n marker.setAttribute('aria-label', 'Start');\n marker.style.cssText =\n 'position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)';\n document.body.prepend(marker);\n });\n try {\n for (let attempt = 0; ; attempt++) {\n await silence();\n await delay(100);\n await voiceOver.perform(\n { keyCode: MacOSKeyCodes.Escape },\n { capture: false },\n );\n await delay(100);\n await voiceOver.perform(voiceOverKeyCodeCommands.openItemChooser);\n if (\n (await voiceOver.lastSpokenPhrase())\n .toLowerCase()\n .includes('item chooser')\n ) {\n break;\n }\n if (attempt === 10) {\n throw new Error(\n '[@uniaria/testing] VoiceOver did not open its item chooser.',\n );\n }\n }\n let isFound = false;\n for (const character of 'web content') {\n await silence();\n await delay(100);\n await voiceOver.type(character, { capture: 'initial' });\n if (\n (await voiceOver.lastSpokenPhrase())\n .toLowerCase()\n .includes('web content')\n ) {\n isFound = true;\n break;\n }\n }\n if (!isFound) {\n throw new Error(\n '[@uniaria/testing] VoiceOver found no \"web content\" in its item chooser.',\n );\n }\n await voiceOver.perform(\n { keyCode: MacOSKeyCodes.Enter },\n { capture: false },\n );\n await voiceOver.interact({ capture: false });\n await delay(100);\n } finally {\n await context.page.evaluate(() =>\n document.getElementById('__uniaria_screen_reader_marker__')?.remove(),\n );\n }\n}\n\n/**\n * Switches windows until NVDA reports the browser's title, then puts NVDA\n * in browse mode at the top of the page, as `@guidepup/playwright` does.\n */\nasync function enterPageWithNvda(\n guidepup: Guidepup,\n context: BrowserCommandContext,\n): Promise<void> {\n const { nvda, NVDAKeyCodeCommands, WindowsKeyCodes, WindowsModifiers } =\n guidepup;\n const title = clean(await context.page.title());\n const names = (WINDOWS_NAMES[browserName(context)] ?? []).map(clean);\n const isBrowser = (window: string) =>\n (title.length > 0 && window.startsWith(title)) ||\n names.some((name) => window.includes(name));\n await nvda.perform(NVDAKeyCodeCommands.exitFocusMode, { capture: false });\n await delay(100);\n for (let attempt = 0; ; attempt++) {\n await nvda.perform(NVDAKeyCodeCommands.reportTitle);\n if (isBrowser(clean(await nvda.lastSpokenPhrase()))) break;\n if (attempt === 10) {\n throw new Error(\n '[@uniaria/testing] NVDA could not bring the browser in front.',\n );\n }\n await nvda.perform(\n {\n keyCode: [WindowsKeyCodes.Escape],\n modifiers: [WindowsModifiers.Alt],\n },\n { capture: false },\n );\n await delay(500);\n }\n await context.page.bringToFront();\n const body = context.page.locator('body');\n await body.focus();\n await body.click();\n await body.blur();\n // Firefox can leave NVDA on the whole browser after switching windows;\n // moving to a focusable item and toggling the mode twice brings browse\n // mode back (nvaccess/nvda#5758).\n for (const command of [\n NVDAKeyCodeCommands.readNextFocusableItem,\n NVDAKeyCodeCommands.toggleBetweenBrowseAndFocusMode,\n NVDAKeyCodeCommands.toggleBetweenBrowseAndFocusMode,\n NVDAKeyCodeCommands.exitFocusMode,\n ]) {\n await nvda.perform(command, { capture: false });\n await delay(100);\n }\n // Keys go where Chrome's own focus is, which can stay in its toolbar\n // whatever the page focuses; F6 moves it on, back to the page.\n for (let attempt = 0; ; attempt++) {\n await nvda.perform(NVDAKeyCodeCommands.reportCurrentFocus);\n const focus = clean(await nvda.lastSpokenPhrase());\n if (focus.includes('document') || (title && focus.includes(title))) break;\n if (attempt === 6) {\n throw new Error(\n `[@uniaria/testing] NVDA could not move Chrome's focus into the page: ${focus}.`,\n );\n }\n await nvda.press('F6', { capture: false });\n await delay(300);\n }\n}\n\n/**\n * Starts the operating system's screen reader and moves it into the page.\n * Returns which one runs; a second call while it runs returns the same.\n */\nexport const uniScreenReaderStart: BrowserCommand<[]> = async (context) => {\n if (running) return running.name;\n const name: ScreenReaderName | undefined =\n process.platform === 'darwin'\n ? 'voiceover'\n : process.platform === 'win32'\n ? 'nvda'\n : undefined;\n if (!name) {\n throw new Error(\n `[@uniaria/testing] No screen reader to drive on ${process.platform}: VoiceOver needs macOS, NVDA needs Windows.`,\n );\n }\n const guidepup = await import('@guidepup/guidepup');\n const reader = name === 'voiceover' ? guidepup.voiceOver : guidepup.nvda;\n await reader.start();\n try {\n if (name === 'voiceover') await enterPageWithVoiceOver(guidepup, context);\n else await enterPageWithNvda(guidepup, context);\n await reader.clearSpokenPhraseLog();\n } catch (error) {\n await reader.stop().catch(() => undefined);\n throw error;\n }\n running = { name, reader, guidepup, read: 0, hidden: new Set() };\n return name;\n};\n\n/** Stops the screen reader, if one runs. */\nexport const uniScreenReaderStop: BrowserCommand<[]> = async () => {\n const current = running;\n running = undefined;\n await current?.reader.stop().catch(() => undefined);\n};\n\n/**\n * Records what the screen reader is saying now, then forgets everything it\n * has said: the next key's phrases are its own. Shift is the key it presses,\n * which neither screen reader speaks.\n */\nexport const uniScreenReaderSettle: BrowserCommand<[]> = async () => {\n const current = active();\n await delay(500);\n await current.reader.press('Shift');\n await current.reader.clearSpokenPhraseLog();\n current.read = 0;\n current.hidden.clear();\n};\n\n/**\n * What the screen reader holds for the element with keyboard focus: NVDA\n * reads it again (NVDA+Tab), VoiceOver gives the text of the item in its\n * cursor, which follows keyboard focus (a key to describe it would turn\n * that tracking off). `spoken` leaves NVDA's report out.\n */\nasync function readFocus(current: Running): Promise<string> {\n const { guidepup } = current;\n if (current.name === 'voiceover') return guidepup.voiceOver.itemText();\n const from = (await current.reader.spokenPhraseLog()).length;\n await guidepup.nvda.perform(guidepup.NVDAKeyCodeCommands.reportCurrentFocus);\n const log = await current.reader.spokenPhraseLog();\n for (let index = from; index < log.length; index++) current.hidden.add(index);\n return log.slice(from).join(' / ');\n}\n\n/**\n * With `UNIARIA_SCREEN_READERS=discover`, logs what the screen reader holds\n * for the focused element after each key, to tell a focus that went astray\n * from one it didn't announce.\n */\nasync function reportFocus(current: Running, after: string): Promise<void> {\n if (process.env.UNIARIA_SCREEN_READERS !== 'discover') return;\n // eslint-disable-next-line no-console\n console.log(\n `[${current.name}] focus after ${after}: ${JSON.stringify(await readFocus(current))}`,\n );\n}\n\n/** Presses a key through the screen reader and waits for its speech. */\nexport const uniScreenReaderPress: BrowserCommand<[key: string]> = async (\n _context,\n key,\n) => {\n const current = active();\n await current.reader.press(key);\n await reportFocus(current, key);\n};\n\n/** Types text through the screen reader, a key per character. */\nexport const uniScreenReaderType: BrowserCommand<[text: string]> = async (\n _context,\n text,\n) => {\n const current = active();\n await current.reader.type(text);\n await reportFocus(current, JSON.stringify(text));\n};\n\n/** What the screen reader holds for the element with keyboard focus. */\nexport const uniScreenReaderReadFocus: BrowserCommand<[]> = () =>\n readFocus(active());\n\n/** Moves the screen reader's cursor to the next item. */\nexport const uniScreenReaderNext: BrowserCommand<[]> = async () => {\n await active().reader.next();\n};\n\n/** The phrases spoken since the last call, in order. */\nexport const uniScreenReaderSpoken: BrowserCommand<[]> = async () => {\n const current = active();\n const log = await current.reader.spokenPhraseLog();\n const fresh = log\n .slice(current.read)\n .filter((_, index) => !current.hidden.has(current.read + index));\n current.read = log.length;\n return fresh;\n};\n\nexport const screenReaderCommands = {\n uniScreenReaderStart,\n uniScreenReaderStop,\n uniScreenReaderSettle,\n uniScreenReaderPress,\n uniScreenReaderType,\n uniScreenReaderNext,\n uniScreenReaderReadFocus,\n uniScreenReaderSpoken,\n};\n","/**\n * Node-side Playwright commands behind the conformance input helpers. Vitest's\n * `userEvent` cannot hold a mouse button while the pointer moves, which press\n * scenarios need (press, leave, come back), so these drive Playwright's mouse\n * and keyboard directly. Register them in the browser test project:\n * `browser: { commands: uniCommands }`.\n *\n * Targets arrive as CSS selectors because elements cannot cross from the\n * browser to Node. Coordinates come from the test frame's locator, so they\n * account for the iframe Vitest runs tests in.\n *\n * `screenReaderCommands` drive the machine's real screen reader through\n * Guidepup, for a project that runs scenarios with `screenReader: true`:\n * `commands: { ...uniCommands, ...screenReaderCommands }`.\n */\n\nimport type {} from '@vitest/browser-playwright';\nimport type { BrowserCommand, BrowserCommandContext } from 'vitest/node';\n\ntype MouseButton = 'left' | 'right' | 'middle';\n\n/** How far below a target `moveOutside` puts the pointer. */\nconst OUTSIDE_OFFSET = 24;\n\nasync function boxOf(context: BrowserCommandContext, selector: string) {\n const locator = (await context.frame()).locator(selector);\n await locator.scrollIntoViewIfNeeded();\n const box = await locator.boundingBox();\n if (!box) throw new Error(`Conformance input: \"${selector}\" has no box.`);\n return box;\n}\n\nexport const uniPointerDown: BrowserCommand<\n [selector: string, button: MouseButton]\n> = async (context, selector, button) => {\n const box = await boxOf(context, selector);\n await context.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);\n await context.page.mouse.down({ button });\n};\n\nexport const uniPointerMove: BrowserCommand<\n [selector: string, where: 'center' | 'outside']\n> = async (context, selector, where) => {\n const box = await boxOf(context, selector);\n const y =\n where === 'center'\n ? box.y + box.height / 2\n : box.y + box.height + OUTSIDE_OFFSET;\n await context.page.mouse.move(box.x + box.width / 2, y, { steps: 2 });\n};\n\nexport const uniPointerUp: BrowserCommand<[button: MouseButton]> = async (\n context,\n button,\n) => {\n await context.page.mouse.up({ button });\n};\n\n/** Turns the wheel wherever the pointer is. */\nexport const uniWheel: BrowserCommand<\n [deltaX: number, deltaY: number]\n> = async (context, deltaX, deltaY) => {\n await context.page.mouse.wheel(deltaX, deltaY);\n};\n\nexport const uniKeyDown: BrowserCommand<[key: string]> = async (\n context,\n key,\n) => {\n await context.page.keyboard.down(key);\n};\n\nexport const uniKeyUp: BrowserCommand<[key: string]> = async (context, key) => {\n await context.page.keyboard.up(key);\n};\n\ntype TouchPhase = 'touchStart' | 'touchMove' | 'touchEnd' | 'touchCancel';\n\ntype CDPSession = Awaited<\n ReturnType<BrowserCommandContext['context']['newCDPSession']>\n>;\n\n/** One DevTools session per page, for touch input. */\nconst sessions = new WeakMap<object, Promise<CDPSession>>();\n\nfunction isChromium(context: BrowserCommandContext): boolean {\n return context.context.browser()?.browserType().name() === 'chromium';\n}\n\n/**\n * Whether `uniTouch` works in this browser. Only Chromium takes real touch\n * input through DevTools (`Input.dispatchTouchEvent`); Playwright's own\n * touchscreen needs `hasTouch` on the whole context, which would make every\n * other test run as if on a touch device.\n */\nexport const uniTouchSupported: BrowserCommand<[]> = (context) =>\n isChromium(context);\n\n/**\n * A finger at the target's centre (or 24px below it, `outside`) for\n * `touchStart` and `touchMove`; lifted for `touchEnd` and `touchCancel`.\n */\nexport const uniTouch: BrowserCommand<\n [phase: TouchPhase, selector?: string, where?: 'center' | 'outside']\n> = async (context, phase, selector, where = 'center') => {\n if (!isChromium(context)) {\n throw new Error('Conformance input: touch needs Chromium.');\n }\n let session = sessions.get(context.page);\n if (!session) {\n session = context.context.newCDPSession(context.page);\n sessions.set(context.page, session);\n }\n const touchPoints = [];\n if (selector !== undefined) {\n const box = await boxOf(context, selector);\n const y =\n where === 'center'\n ? box.y + box.height / 2\n : box.y + box.height + OUTSIDE_OFFSET;\n touchPoints.push({ x: box.x + box.width / 2, y, id: 0 });\n }\n await (\n await session\n ).send('Input.dispatchTouchEvent', {\n type: phase,\n touchPoints,\n });\n};\n\n/** Media features Playwright emulates; `null` ends a feature's emulation. */\nexport interface MediaFeatures {\n readonly forcedColors?: 'active' | 'none' | null;\n readonly contrast?: 'more' | 'no-preference' | null;\n readonly reducedMotion?: 'reduce' | 'no-preference' | null;\n readonly colorScheme?: 'light' | 'dark' | 'no-preference' | null;\n}\n\n/**\n * Emulates media features for the whole page, frames included. Forced\n * colors work in Chromium only.\n */\nexport const uniEmulateMedia: BrowserCommand<\n [features: MediaFeatures]\n> = async (context, features) => {\n await context.page.emulateMedia(features);\n};\n\nexport { screenReaderCommands } from './screen-reader-commands';\n\nexport const uniCommands = {\n uniTouchSupported,\n uniTouch,\n uniPointerDown,\n uniPointerMove,\n uniPointerUp,\n uniWheel,\n uniKeyDown,\n uniKeyUp,\n uniEmulateMedia,\n};\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import { A as h, C as RenderedRows, D as TemplateNode, E as TemplateElement, F as partSelector, I as renderTemplate, L as rows, M as isRows, N as nested, O as TemplateRows, P as partName, R as templateParts, S as RenderTemplateOptions, T as TemplateComponent, _ as ConformanceAdapter, a as MouseButton, b as Mounted, c as ScenarioContext, d as ScenarioRun, f as ScreenReaderInput, g as AdapterMount, h as defineConformance, i as KeyboardInput, j as isNested, k as VIRTUALIZER, l as ScenarioGroup, m as TouchInput, n as ConformanceSuite, o as PointerInput, p as ScreenReaderName, r as InputNeed, s as Scenario, t as ConformanceBuilder, u as ScenarioOptions, v as MountOptions, w as TemplateAttrs, x as RecordedEvent, y as MountRequest } from "./scenario-DMaR4JJc.js";
2
+ export { type AdapterMount, type ConformanceAdapter, type ConformanceBuilder, type ConformanceSuite, type InputNeed, type KeyboardInput, type MountOptions, type MountRequest, type Mounted, type MouseButton, type PointerInput, type RecordedEvent, type RenderTemplateOptions, type RenderedRows, type Scenario, type ScenarioContext, type ScenarioGroup, type ScenarioOptions, type ScenarioRun, type ScreenReaderInput, type ScreenReaderName, type TemplateAttrs, type TemplateComponent, type TemplateElement, type TemplateNode, type TemplateRows, type TouchInput, VIRTUALIZER, defineConformance, h, isNested, isRows, nested, partName, partSelector, renderTemplate, rows, templateParts };
package/dist/index.js ADDED
@@ -0,0 +1,54 @@
1
+ import { a as nested, c as renderTemplate, i as isRows, l as rows, n as h, o as partName, r as isNested, s as partSelector, t as VIRTUALIZER, u as templateParts } from "./template-Cn6bp45u.js";
2
+ //#region src/conformance/scenario.ts
3
+ /**
4
+ * Declares a component's conformance suite:
5
+ *
6
+ * ```ts
7
+ * export const buttonConformance = defineConformance<ButtonProps, ButtonState>(
8
+ * 'button',
9
+ * ({ scenario, group }) => {
10
+ * group('keyboard', () => {
11
+ * scenario('Enter presses on keyup', async ({ mount, keyboard }) => { … });
12
+ * });
13
+ * },
14
+ * );
15
+ * ```
16
+ */
17
+ function defineConformance(component, define) {
18
+ const root = [];
19
+ let current = root;
20
+ define({
21
+ scenario(name, run, options) {
22
+ current.push({
23
+ kind: "scenario",
24
+ name,
25
+ run,
26
+ phase: options?.phase ?? 1,
27
+ needs: options?.needs ?? []
28
+ });
29
+ },
30
+ group(name, defineGroup) {
31
+ const children = [];
32
+ current.push({
33
+ kind: "group",
34
+ name,
35
+ children
36
+ });
37
+ const parent = current;
38
+ current = children;
39
+ try {
40
+ defineGroup();
41
+ } finally {
42
+ current = parent;
43
+ }
44
+ }
45
+ });
46
+ return {
47
+ component,
48
+ children: root
49
+ };
50
+ }
51
+ //#endregion
52
+ export { VIRTUALIZER, defineConformance, h, isNested, isRows, nested, partName, partSelector, renderTemplate, rows, templateParts };
53
+
54
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/conformance/scenario.ts"],"sourcesContent":["/**\n * Defining conformance suites. A suite is data: a component name and a tree of\n * scenarios, written from the component's approved spec. Running it is the\n * runner's job (`@uniaria/testing/browser`), once per adapter.\n *\n * Each scenario is tagged with the roadmap phase whose features it needs.\n * An adapter run declares the phase it has reached, and later scenarios are\n * reported as skipped instead of failing (Button's touch and virtual-click\n * scenarios wait for the phase 2 interaction layer). A scenario can also need\n * input that only some browsers take (touch); elsewhere it is skipped too.\n * Scenarios that need a real screen reader run only in the screen-reader\n * project, nightly, and only they run there.\n */\n\nimport type { MountOptions, Mounted } from './contract';\n\nexport type MouseButton = 'left' | 'right' | 'middle';\n\n/** Real mouse input through Playwright, at the target's centre. */\nexport interface PointerInput {\n /** Moves to the target and presses a button (left by default). */\n down(target: Element, options?: { button?: MouseButton }): Promise<void>;\n /** Moves to the target's centre with the button state unchanged. */\n move(target: Element): Promise<void>;\n /** Moves to a point 24px below the target. */\n moveOutside(target: Element): Promise<void>;\n /** Releases a button (left by default) wherever the pointer is. */\n up(options?: { button?: MouseButton }): Promise<void>;\n /**\n * Turns the wheel wherever the pointer is, with the modifier keys held\n * down: a negative `deltaY` scrolls up. One call is one `wheel` event.\n */\n wheel(delta: { deltaX?: number; deltaY?: number }): Promise<void>;\n /** `down` on the target, then `up`. */\n click(target: Element, options?: { button?: MouseButton }): Promise<void>;\n}\n\n/**\n * Real keyboard input through Playwright, sent to the focused element. Key\n * names follow Playwright: `Enter`, `Space`, `Escape`, `ArrowDown`.\n */\nexport interface KeyboardInput {\n /** Presses and holds a key. Calling it again while held sends a repeat. */\n down(key: string): Promise<void>;\n up(key: string): Promise<void>;\n /** `down`, then `up`. */\n press(key: string): Promise<void>;\n /** Moves focus with Tab, or Shift+Tab with `isBackward`. */\n tab(options?: { isBackward?: boolean }): Promise<void>;\n}\n\n/**\n * Real touch input, one finger at a time, through DevTools: only Chromium\n * takes it, so scenarios that use it declare `needs: ['touch']`. The browser\n * turns it into touch and pointer events, compatibility mouse events and a\n * click, and a finger that moves away starts a scroll.\n */\nexport interface TouchInput {\n /** Puts a finger down on the target's centre. */\n down(target: Element): Promise<void>;\n /** Moves the finger to the target's centre. */\n move(target: Element): Promise<void>;\n /** Moves the finger 24px below the target. */\n moveOutside(target: Element): Promise<void>;\n /** Lifts the finger wherever it is. */\n up(): Promise<void>;\n /** `down` on the target, then `up`. */\n tap(target: Element): Promise<void>;\n}\n\n/** Which real screen reader a scenario runs with. */\nexport type ScreenReaderName = 'voiceover' | 'nvda';\n\n/**\n * A real screen reader driven through Guidepup: VoiceOver on macOS, NVDA on\n * Windows, both with Chromium. Keys go through the operating system, so the\n * screen reader hears them as a user's and speaks what they do. Only in\n * scenarios that declare `needs: ['screenReader']`, which run nightly.\n *\n * The screen readers word things differently (`check box` in NVDA,\n * `checkbox` in VoiceOver), so scenarios match what they need to hear, not\n * whole phrases.\n */\nexport interface ScreenReaderInput {\n /** Which screen reader runs. */\n readonly name: ScreenReaderName;\n /**\n * Clicks `element` (a text field before the component), waits for the\n * screen reader to speak it and forgets what it said: the scenario starts\n * from there, usually with Tab into the component.\n */\n startFrom(element: HTMLElement): Promise<void>;\n /**\n * Presses a key through the screen reader (Playwright's names: `Tab`,\n * `ArrowDown`, `Shift+Tab`) and waits until it stops speaking.\n */\n press(key: string): Promise<void>;\n /** Types text through the screen reader, a key per character. */\n type(text: string): Promise<void>;\n /** Moves the screen reader's own cursor to the next item. */\n next(): Promise<void>;\n /**\n * What the screen reader holds for the element with keyboard focus: NVDA\n * reads it again (NVDA+Tab), VoiceOver gives its cursor's item text. A\n * check that holds when the speech on arrival is cut short.\n */\n readFocus(): Promise<string>;\n /**\n * What it has said since `startFrom` or the last call: each key's phrases\n * in order, joined with ` / `.\n */\n spoken(): Promise<string>;\n}\n\n/** Input a scenario needs beyond mouse and keyboard. */\nexport type InputNeed = 'touch' | 'screenReader';\n\nexport interface ScenarioContext<Props, State> {\n /** Mounts the suite's component through the adapter under test. */\n mount(options: MountOptions<Props>): Promise<Mounted<Props, State>>;\n pointer: PointerInput;\n keyboard: KeyboardInput;\n /** Only in scenarios that declare `needs: ['touch']`. */\n touch: TouchInput;\n /** Only in scenarios that declare `needs: ['screenReader']`. */\n screenReader: ScreenReaderInput;\n /**\n * Reads the whole document with the virtual screen reader and returns every\n * phrase, from `document` to `end of document`.\n */\n spoken(): Promise<string[]>;\n /**\n * Starts hearing live regions with the virtual screen reader. The returned\n * function gives what they have announced since, in order, each phrase\n * prefixed with its politeness: `assertive: Saved`. Listening lasts until\n * the scenario ends, and `spoken()` can't run meanwhile.\n */\n listen(): Promise<() => Promise<string[]>>;\n}\n\nexport type ScenarioRun<Props, State> = (\n context: ScenarioContext<Props, State>,\n) => Promise<void>;\n\nexport interface ScenarioOptions {\n /** Roadmap phase whose features the scenario needs. Default 1. */\n readonly phase?: number;\n /**\n * Input beyond mouse and keyboard. Where the browser can't take it, the\n * scenario is skipped; a scenario that needs a real screen reader runs\n * only where one runs.\n */\n readonly needs?: readonly InputNeed[];\n}\n\nexport interface Scenario<Props, State> {\n readonly kind: 'scenario';\n readonly name: string;\n readonly phase: number;\n readonly needs: readonly InputNeed[];\n readonly run: ScenarioRun<Props, State>;\n}\n\nexport interface ScenarioGroup<Props, State> {\n readonly kind: 'group';\n readonly name: string;\n readonly children: readonly (\n Scenario<Props, State> | ScenarioGroup<Props, State>\n )[];\n}\n\nexport interface ConformanceSuite<Props, State> {\n /** Definition name: `button`. */\n readonly component: string;\n readonly children: readonly (\n Scenario<Props, State> | ScenarioGroup<Props, State>\n )[];\n}\n\nexport interface ConformanceBuilder<Props, State> {\n scenario(\n name: string,\n run: ScenarioRun<Props, State>,\n options?: ScenarioOptions,\n ): void;\n group(name: string, define: () => void): void;\n}\n\n/**\n * Declares a component's conformance suite:\n *\n * ```ts\n * export const buttonConformance = defineConformance<ButtonProps, ButtonState>(\n * 'button',\n * ({ scenario, group }) => {\n * group('keyboard', () => {\n * scenario('Enter presses on keyup', async ({ mount, keyboard }) => { … });\n * });\n * },\n * );\n * ```\n */\nexport function defineConformance<Props, State>(\n component: string,\n define: (builder: ConformanceBuilder<Props, State>) => void,\n): ConformanceSuite<Props, State> {\n type Child = Scenario<Props, State> | ScenarioGroup<Props, State>;\n const root: Child[] = [];\n let current = root;\n\n define({\n scenario(name, run, options) {\n current.push({\n kind: 'scenario',\n name,\n run,\n phase: options?.phase ?? 1,\n needs: options?.needs ?? [],\n });\n },\n group(name, defineGroup) {\n const children: Child[] = [];\n current.push({ kind: 'group', name, children });\n const parent = current;\n current = children;\n try {\n defineGroup();\n } finally {\n current = parent;\n }\n },\n });\n\n return { component, children: root };\n}\n"],"mappings":";;;;;;;;;;;;;;;;AA0MA,SAAgB,kBACd,WACA,QACgC;CAEhC,MAAM,OAAgB,CAAC;CACvB,IAAI,UAAU;CAEd,OAAO;EACL,SAAS,MAAM,KAAK,SAAS;GAC3B,QAAQ,KAAK;IACX,MAAM;IACN;IACA;IACA,OAAO,SAAS,SAAS;IACzB,OAAO,SAAS,SAAS,CAAC;GAC5B,CAAC;EACH;EACA,MAAM,MAAM,aAAa;GACvB,MAAM,WAAoB,CAAC;GAC3B,QAAQ,KAAK;IAAE,MAAM;IAAS;IAAM;GAAS,CAAC;GAC9C,MAAM,SAAS;GACf,UAAU;GACV,IAAI;IACF,YAAY;GACd,UAAU;IACR,UAAU;GACZ;EACF;CACF,CAAC;CAED,OAAO;EAAE;EAAW,UAAU;CAAK;AACrC"}