Skip to content

Commit 961e393

Browse files
feat: initial support act, press, and type
1 parent 39beeab commit 961e393

File tree

5 files changed

+322
-9
lines changed

5 files changed

+322
-9
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@guidepup/virtual-screen-reader",
3-
"version": "0.2.0",
3+
"version": "0.3.0",
44
"description": "Virtual screen reader driver for unit test automation.",
55
"main": "lib/index.js",
66
"author": "Craig Morten <craig.morten@hotmail.co.uk>",

src/Virtual.ts

Lines changed: 157 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import {
22
AccessibilityNode,
33
createAccessibilityTree,
44
} from "./createAccessibilityTree";
5-
import type { CommandOptions, ScreenReader } from "@guidepup/guidepup";
5+
import {
6+
CommandOptions,
7+
MacOSModifiers,
8+
ScreenReader,
9+
WindowsModifiers,
10+
} from "@guidepup/guidepup";
611
import {
712
ERR_VIRTUAL_MISSING_CONTAINER,
813
ERR_VIRTUAL_NOT_STARTED,
@@ -16,6 +21,11 @@ export interface StartOptions extends CommandOptions {
1621
container: HTMLElement;
1722
}
1823

24+
const defaultUserEventOptions = {
25+
delay: null,
26+
skipHover: true,
27+
};
28+
1929
const observedAttributes = [
2030
...aria.keys(),
2131
"type",
@@ -34,7 +44,6 @@ const observedAttributes = [
3444

3545
// TODO: handle aria-live, role="polite", role="alert", and other interruptions.
3646
// TODO: announce sensible attribute values, e.g. clicked, disabled, etc.
37-
// TODO: consider making the role, accessibleName, accessibleDescription, etc. available via their own APIs.
3847

3948
const observeDOM = (function () {
4049
const MutationObserver = window.MutationObserver;
@@ -147,14 +156,29 @@ export class Virtual implements ScreenReader {
147156
);
148157
}
149158

159+
/**
160+
* Detect whether the screen reader is supported for the current OS.
161+
*
162+
* @returns {Promise<boolean>}
163+
*/
150164
async detect() {
151165
return true;
152166
}
153167

168+
/**
169+
* Detect whether the screen reader is the default screen reader for the current OS.
170+
*
171+
* @returns {Promise<boolean>}
172+
*/
154173
async default() {
155174
return false;
156175
}
157176

177+
/**
178+
* Turn the screen reader on.
179+
*
180+
* @param {object} [options] Additional options.
181+
*/
158182
async start({ container }: StartOptions = { container: null }) {
159183
if (!container) {
160184
throw new Error(ERR_VIRTUAL_MISSING_CONTAINER);
@@ -176,6 +200,9 @@ export class Virtual implements ScreenReader {
176200
return;
177201
}
178202

203+
/**
204+
* Turn the screen reader off.
205+
*/
179206
async stop() {
180207
this.#disconnectDOMObserver?.();
181208
this.#invalidateTreeCache();
@@ -188,6 +215,9 @@ export class Virtual implements ScreenReader {
188215
return;
189216
}
190217

218+
/**
219+
* Move the screen reader cursor to the previous location.
220+
*/
191221
async previous() {
192222
this.#checkContainer();
193223

@@ -206,6 +236,9 @@ export class Virtual implements ScreenReader {
206236
return;
207237
}
208238

239+
/**
240+
* Move the screen reader cursor to the next location.
241+
*/
209242
async next() {
210243
this.#checkContainer();
211244

@@ -227,45 +260,138 @@ export class Virtual implements ScreenReader {
227260
return;
228261
}
229262

263+
/**
264+
* Perform the default action for the item in the screen reader cursor.
265+
*/
230266
async act() {
231267
this.#checkContainer();
232268

233-
notImplemented();
269+
if (!this.#activeNode) {
270+
return;
271+
}
272+
273+
const target = this.#activeNode.node as HTMLElement;
274+
275+
// TODO: verify that is appropriate for all default actions
276+
await userEvent.click(target, defaultUserEventOptions);
234277

235278
return;
236279
}
237280

281+
/**
282+
* Interact with the item under the screen reader cursor.
283+
*/
238284
async interact() {
239285
this.#checkContainer();
240286

241287
return;
242288
}
243289

290+
/**
291+
* Stop interacting with the current item.
292+
*/
244293
async stopInteracting() {
245294
this.#checkContainer();
246295

247296
return;
248297
}
249298

250-
async press() {
299+
/**
300+
* Press a key on the active item.
301+
*
302+
* `key` can specify the intended [keyboardEvent.key](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key)
303+
* value or a single character to generate the text for. A superset of the `key` values can be found
304+
* [on the MDN key values page](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values). Examples of the keys are:
305+
*
306+
* `F1` - `F20`, `Digit0` - `Digit9`, `KeyA` - `KeyZ`, `Backquote`, `Minus`, `Equal`, `Backslash`, `Backspace`, `Tab`,
307+
* `Delete`, `Escape`, `ArrowDown`, `End`, `Enter`, `Home`, `Insert`, `PageDown`, `PageUp`, `ArrowRight`, `ArrowUp`, etc.
308+
*
309+
* Following modification shortcuts are also supported: `Shift`, `Control`, `Alt`, `Meta` (OS permitting).
310+
*
311+
* Holding down `Shift` will type the text that corresponds to the `key` in the upper case.
312+
*
313+
* If `key` is a single character, it is case-sensitive, so the values `a` and `A` will generate different respective
314+
* texts.
315+
*
316+
* Shortcuts such as `key: "Control+f"` or `key: "Control+Shift+f"` are supported as well. When specified with the
317+
* modifier, modifier is pressed and being held while the subsequent key is being pressed.
318+
*
319+
* ```ts
320+
* await keyboard.press("Control+f");
321+
* ```
322+
*
323+
* @param {string} key Name of the key to press or a character to generate, such as `ArrowLeft` or `a`.
324+
*/
325+
async press(key: string) {
251326
this.#checkContainer();
252327

253-
notImplemented();
328+
if (!this.#activeNode) {
329+
return;
330+
}
331+
332+
const rawKeys = key.replaceAll("{", "{{").replaceAll("[", "[[").split("+");
333+
const modifiers = [];
334+
const keys = [];
335+
336+
rawKeys.forEach((rawKey) => {
337+
if (
338+
typeof MacOSModifiers[rawKey] !== "undefined" ||
339+
typeof WindowsModifiers[rawKey] !== "undefined"
340+
) {
341+
modifiers.push(rawKey);
342+
} else {
343+
keys.push(rawKey);
344+
}
345+
});
346+
347+
const keyboardCommand = [
348+
...modifiers.map((modifier) => `{${modifier}>}`),
349+
...keys,
350+
...modifiers.reverse().map((modifier) => `{/${modifier}}`),
351+
].join("");
352+
353+
await this.click();
354+
await userEvent.keyboard(keyboardCommand, defaultUserEventOptions);
254355

255356
return;
256357
}
257358

258-
async type() {
359+
/**
360+
* Type text into the active item.
361+
*
362+
* To press a special key, like `Control` or `ArrowDown`, use `virtual.press(key)`.
363+
*
364+
* ```ts
365+
* await virtual.type("my-username");
366+
* await virtual.press("Enter");
367+
* ```
368+
*
369+
* @param {string} text Text to type into the active item.
370+
*/
371+
async type(text: string) {
259372
this.#checkContainer();
260373

261-
notImplemented();
374+
if (!this.#activeNode) {
375+
return;
376+
}
377+
378+
const target = this.#activeNode.node as HTMLElement;
379+
await userEvent.type(target, text, defaultUserEventOptions);
262380

263381
return;
264382
}
265383

384+
/**
385+
* Perform a screen reader command.
386+
*
387+
* @param {any} command Screen reader command to execute.
388+
*/
266389
async perform() {
267390
this.#checkContainer();
268391

392+
// TODO: decide what this means as there is no established "common" command
393+
// set for different screen readers.
394+
269395
notImplemented();
270396

271397
return;
@@ -287,29 +413,52 @@ export class Virtual implements ScreenReader {
287413
const keys = key.repeat(clickCount);
288414
const target = this.#activeNode.node as HTMLElement;
289415

290-
await userEvent.pointer([{ target }, { keys, target }]);
416+
await userEvent.pointer(
417+
[{ target }, { keys, target }],
418+
defaultUserEventOptions
419+
);
291420

292421
return;
293422
}
294423

424+
/**
425+
* Get the last spoken phrase.
426+
*
427+
* @returns {Promise<string>} The last spoken phrase.
428+
*/
295429
async lastSpokenPhrase() {
296430
this.#checkContainer();
297431

298432
return this.#spokenPhraseLog.at(-1) ?? "";
299433
}
300434

435+
/**
436+
* Get the text of the item in the screen reader cursor.
437+
*
438+
* @returns {Promise<string>} The item's text.
439+
*/
301440
async itemText() {
302441
this.#checkContainer();
303442

304443
return this.#itemTextLog.at(-1) ?? "";
305444
}
306445

446+
/**
447+
* Get the log of all spoken phrases for this screen reader instance.
448+
*
449+
* @returns {Promise<string[]>} The spoken phrase log.
450+
*/
307451
async spokenPhraseLog() {
308452
this.#checkContainer();
309453

310454
return this.#spokenPhraseLog;
311455
}
312456

457+
/**
458+
* Get the log of all visited item text for this screen reader instance.
459+
*
460+
* @returns {Promise<string[]>} The item text log.
461+
*/
313462
async itemTextLog() {
314463
this.#checkContainer();
315464

test/int/act.int.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { getByText, queryByText } from "@testing-library/dom";
2+
import { virtual } from "../../src";
3+
4+
function setupButtonPage() {
5+
document.body.innerHTML = `
6+
<p id="status">Not Clicked</p>
7+
<div id="hidden" style="display: none;">Hidden</div>
8+
`;
9+
10+
const button = document.createElement("button");
11+
12+
button.addEventListener("click", function (event) {
13+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
14+
document.getElementById(
15+
"status"
16+
)!.innerHTML = `Clicked ${event.detail} Time(s)`;
17+
});
18+
19+
button.innerHTML = "Click Me";
20+
21+
document.body.appendChild(button);
22+
}
23+
24+
describe("act", () => {
25+
beforeEach(() => {
26+
setupButtonPage();
27+
});
28+
29+
it("should perform the default action", async () => {
30+
const container = document.body;
31+
32+
await virtual.start({ container });
33+
34+
expect(getByText(container, "Not Clicked")).toBeInTheDocument();
35+
36+
while ((await virtual.itemText()) !== "Click Me") {
37+
await virtual.next();
38+
}
39+
40+
await virtual.act();
41+
42+
expect(queryByText(container, "Not Clicked")).not.toBeInTheDocument();
43+
expect(getByText(container, "Clicked 1 Time(s)")).toBeInTheDocument();
44+
45+
await virtual.previous();
46+
47+
expect(await virtual.lastSpokenPhrase()).toEqual("Clicked 1 Time(s)");
48+
49+
await virtual.stop();
50+
});
51+
52+
it("should handle requests to perform the default action on hidden container gracefully", async () => {
53+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
54+
const container = document.querySelector("#hidden")! as HTMLElement;
55+
56+
await virtual.start({ container });
57+
58+
await virtual.act();
59+
60+
expect(await virtual.itemTextLog()).toEqual([]);
61+
expect(await virtual.spokenPhraseLog()).toEqual([]);
62+
63+
await virtual.stop();
64+
});
65+
});

0 commit comments

Comments
 (0)