From 961e3936ade66ba5d091ba01429b78e204993952 Mon Sep 17 00:00:00 2001 From: jlp-craigmorten Date: Thu, 25 May 2023 22:15:18 +0100 Subject: [PATCH] feat: initial support act, press, and type --- package.json | 2 +- src/Virtual.ts | 165 +++++++++++++++++++++++++++++++++++-- test/int/act.int.test.ts | 65 +++++++++++++++ test/int/press.int.test.ts | 50 +++++++++++ test/int/type.int.test.ts | 49 +++++++++++ 5 files changed, 322 insertions(+), 9 deletions(-) create mode 100644 test/int/act.int.test.ts create mode 100644 test/int/press.int.test.ts create mode 100644 test/int/type.int.test.ts diff --git a/package.json b/package.json index 62dc3e9..8553192 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@guidepup/virtual-screen-reader", - "version": "0.2.0", + "version": "0.3.0", "description": "Virtual screen reader driver for unit test automation.", "main": "lib/index.js", "author": "Craig Morten ", diff --git a/src/Virtual.ts b/src/Virtual.ts index 79bc3bd..70b45bc 100644 --- a/src/Virtual.ts +++ b/src/Virtual.ts @@ -2,7 +2,12 @@ import { AccessibilityNode, createAccessibilityTree, } from "./createAccessibilityTree"; -import type { CommandOptions, ScreenReader } from "@guidepup/guidepup"; +import { + CommandOptions, + MacOSModifiers, + ScreenReader, + WindowsModifiers, +} from "@guidepup/guidepup"; import { ERR_VIRTUAL_MISSING_CONTAINER, ERR_VIRTUAL_NOT_STARTED, @@ -16,6 +21,11 @@ export interface StartOptions extends CommandOptions { container: HTMLElement; } +const defaultUserEventOptions = { + delay: null, + skipHover: true, +}; + const observedAttributes = [ ...aria.keys(), "type", @@ -34,7 +44,6 @@ const observedAttributes = [ // TODO: handle aria-live, role="polite", role="alert", and other interruptions. // TODO: announce sensible attribute values, e.g. clicked, disabled, etc. -// TODO: consider making the role, accessibleName, accessibleDescription, etc. available via their own APIs. const observeDOM = (function () { const MutationObserver = window.MutationObserver; @@ -147,14 +156,29 @@ export class Virtual implements ScreenReader { ); } + /** + * Detect whether the screen reader is supported for the current OS. + * + * @returns {Promise} + */ async detect() { return true; } + /** + * Detect whether the screen reader is the default screen reader for the current OS. + * + * @returns {Promise} + */ async default() { return false; } + /** + * Turn the screen reader on. + * + * @param {object} [options] Additional options. + */ async start({ container }: StartOptions = { container: null }) { if (!container) { throw new Error(ERR_VIRTUAL_MISSING_CONTAINER); @@ -176,6 +200,9 @@ export class Virtual implements ScreenReader { return; } + /** + * Turn the screen reader off. + */ async stop() { this.#disconnectDOMObserver?.(); this.#invalidateTreeCache(); @@ -188,6 +215,9 @@ export class Virtual implements ScreenReader { return; } + /** + * Move the screen reader cursor to the previous location. + */ async previous() { this.#checkContainer(); @@ -206,6 +236,9 @@ export class Virtual implements ScreenReader { return; } + /** + * Move the screen reader cursor to the next location. + */ async next() { this.#checkContainer(); @@ -227,45 +260,138 @@ export class Virtual implements ScreenReader { return; } + /** + * Perform the default action for the item in the screen reader cursor. + */ async act() { this.#checkContainer(); - notImplemented(); + if (!this.#activeNode) { + return; + } + + const target = this.#activeNode.node as HTMLElement; + + // TODO: verify that is appropriate for all default actions + await userEvent.click(target, defaultUserEventOptions); return; } + /** + * Interact with the item under the screen reader cursor. + */ async interact() { this.#checkContainer(); return; } + /** + * Stop interacting with the current item. + */ async stopInteracting() { this.#checkContainer(); return; } - async press() { + /** + * Press a key on the active item. + * + * `key` can specify the intended [keyboardEvent.key](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key) + * value or a single character to generate the text for. A superset of the `key` values can be found + * [on the MDN key values page](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values). Examples of the keys are: + * + * `F1` - `F20`, `Digit0` - `Digit9`, `KeyA` - `KeyZ`, `Backquote`, `Minus`, `Equal`, `Backslash`, `Backspace`, `Tab`, + * `Delete`, `Escape`, `ArrowDown`, `End`, `Enter`, `Home`, `Insert`, `PageDown`, `PageUp`, `ArrowRight`, `ArrowUp`, etc. + * + * Following modification shortcuts are also supported: `Shift`, `Control`, `Alt`, `Meta` (OS permitting). + * + * Holding down `Shift` will type the text that corresponds to the `key` in the upper case. + * + * If `key` is a single character, it is case-sensitive, so the values `a` and `A` will generate different respective + * texts. + * + * Shortcuts such as `key: "Control+f"` or `key: "Control+Shift+f"` are supported as well. When specified with the + * modifier, modifier is pressed and being held while the subsequent key is being pressed. + * + * ```ts + * await keyboard.press("Control+f"); + * ``` + * + * @param {string} key Name of the key to press or a character to generate, such as `ArrowLeft` or `a`. + */ + async press(key: string) { this.#checkContainer(); - notImplemented(); + if (!this.#activeNode) { + return; + } + + const rawKeys = key.replaceAll("{", "{{").replaceAll("[", "[[").split("+"); + const modifiers = []; + const keys = []; + + rawKeys.forEach((rawKey) => { + if ( + typeof MacOSModifiers[rawKey] !== "undefined" || + typeof WindowsModifiers[rawKey] !== "undefined" + ) { + modifiers.push(rawKey); + } else { + keys.push(rawKey); + } + }); + + const keyboardCommand = [ + ...modifiers.map((modifier) => `{${modifier}>}`), + ...keys, + ...modifiers.reverse().map((modifier) => `{/${modifier}}`), + ].join(""); + + await this.click(); + await userEvent.keyboard(keyboardCommand, defaultUserEventOptions); return; } - async type() { + /** + * Type text into the active item. + * + * To press a special key, like `Control` or `ArrowDown`, use `virtual.press(key)`. + * + * ```ts + * await virtual.type("my-username"); + * await virtual.press("Enter"); + * ``` + * + * @param {string} text Text to type into the active item. + */ + async type(text: string) { this.#checkContainer(); - notImplemented(); + if (!this.#activeNode) { + return; + } + + const target = this.#activeNode.node as HTMLElement; + await userEvent.type(target, text, defaultUserEventOptions); return; } + /** + * Perform a screen reader command. + * + * @param {any} command Screen reader command to execute. + */ async perform() { this.#checkContainer(); + // TODO: decide what this means as there is no established "common" command + // set for different screen readers. + notImplemented(); return; @@ -287,29 +413,52 @@ export class Virtual implements ScreenReader { const keys = key.repeat(clickCount); const target = this.#activeNode.node as HTMLElement; - await userEvent.pointer([{ target }, { keys, target }]); + await userEvent.pointer( + [{ target }, { keys, target }], + defaultUserEventOptions + ); return; } + /** + * Get the last spoken phrase. + * + * @returns {Promise} The last spoken phrase. + */ async lastSpokenPhrase() { this.#checkContainer(); return this.#spokenPhraseLog.at(-1) ?? ""; } + /** + * Get the text of the item in the screen reader cursor. + * + * @returns {Promise} The item's text. + */ async itemText() { this.#checkContainer(); return this.#itemTextLog.at(-1) ?? ""; } + /** + * Get the log of all spoken phrases for this screen reader instance. + * + * @returns {Promise} The spoken phrase log. + */ async spokenPhraseLog() { this.#checkContainer(); return this.#spokenPhraseLog; } + /** + * Get the log of all visited item text for this screen reader instance. + * + * @returns {Promise} The item text log. + */ async itemTextLog() { this.#checkContainer(); diff --git a/test/int/act.int.test.ts b/test/int/act.int.test.ts new file mode 100644 index 0000000..3e5a8f9 --- /dev/null +++ b/test/int/act.int.test.ts @@ -0,0 +1,65 @@ +import { getByText, queryByText } from "@testing-library/dom"; +import { virtual } from "../../src"; + +function setupButtonPage() { + document.body.innerHTML = ` +

Not Clicked

+ + `; + + const button = document.createElement("button"); + + button.addEventListener("click", function (event) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + document.getElementById( + "status" + )!.innerHTML = `Clicked ${event.detail} Time(s)`; + }); + + button.innerHTML = "Click Me"; + + document.body.appendChild(button); +} + +describe("act", () => { + beforeEach(() => { + setupButtonPage(); + }); + + it("should perform the default action", async () => { + const container = document.body; + + await virtual.start({ container }); + + expect(getByText(container, "Not Clicked")).toBeInTheDocument(); + + while ((await virtual.itemText()) !== "Click Me") { + await virtual.next(); + } + + await virtual.act(); + + expect(queryByText(container, "Not Clicked")).not.toBeInTheDocument(); + expect(getByText(container, "Clicked 1 Time(s)")).toBeInTheDocument(); + + await virtual.previous(); + + expect(await virtual.lastSpokenPhrase()).toEqual("Clicked 1 Time(s)"); + + await virtual.stop(); + }); + + it("should handle requests to perform the default action on hidden container gracefully", async () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const container = document.querySelector("#hidden")! as HTMLElement; + + await virtual.start({ container }); + + await virtual.act(); + + expect(await virtual.itemTextLog()).toEqual([]); + expect(await virtual.spokenPhraseLog()).toEqual([]); + + await virtual.stop(); + }); +}); diff --git a/test/int/press.int.test.ts b/test/int/press.int.test.ts new file mode 100644 index 0000000..069b7af --- /dev/null +++ b/test/int/press.int.test.ts @@ -0,0 +1,50 @@ +import { getByRole } from "@testing-library/dom"; +import { virtual } from "../../src"; + +function setupInputPage() { + document.body.innerHTML = ` + + + + `; +} + +describe("press", () => { + beforeEach(() => { + setupInputPage(); + }); + + it("should press keys on the active element", async () => { + const container = document.body; + + await virtual.start({ container }); + + await virtual.next(); + await virtual.next(); + + expect(await virtual.itemText()).toEqual("Input Some Text"); + + await virtual.press("Shift+a+b+c"); + // TODO: FAIL:Testing Library user-event doesn't support modification yet, this should be "A" + expect(getByRole(container, "textbox")).toHaveValue("abc"); + + // TODO: FAIL: accessible name should include the now non-empty value "abc" + expect(await virtual.itemText()).toEqual("Input Some Text"); + + await virtual.stop(); + }); + + it("should handle requests to press on hidden container gracefully", async () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const container = document.querySelector("#hidden")! as HTMLElement; + + await virtual.start({ container }); + + await virtual.press("Shift+a+b+c"); + + expect(await virtual.itemTextLog()).toEqual([]); + expect(await virtual.spokenPhraseLog()).toEqual([]); + + await virtual.stop(); + }); +}); diff --git a/test/int/type.int.test.ts b/test/int/type.int.test.ts new file mode 100644 index 0000000..ea485d7 --- /dev/null +++ b/test/int/type.int.test.ts @@ -0,0 +1,49 @@ +import { getByRole } from "@testing-library/dom"; +import { virtual } from "../../src"; + +function setupInputPage() { + document.body.innerHTML = ` + + + + `; +} + +describe("type", () => { + beforeEach(() => { + setupInputPage(); + }); + + it("should type on the active element", async () => { + const container = document.body; + + await virtual.start({ container }); + + await virtual.next(); + await virtual.next(); + + expect(await virtual.itemText()).toEqual("Input Some Text"); + + await virtual.type("Hello World!"); + expect(getByRole(container, "textbox")).toHaveValue("Hello World!"); + + // TODO: FAIL: accessible name should include the now non-empty value "abc" + expect(await virtual.itemText()).toEqual("Input Some Text"); + + await virtual.stop(); + }); + + it("should handle requests to type on hidden container gracefully", async () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const container = document.querySelector("#hidden")! as HTMLElement; + + await virtual.start({ container }); + + await virtual.type("Hello World!"); + + expect(await virtual.itemTextLog()).toEqual([]); + expect(await virtual.spokenPhraseLog()).toEqual([]); + + await virtual.stop(); + }); +});