Skip to content

Commit

Permalink
feat: initial support act, press, and type
Browse files Browse the repository at this point in the history
  • Loading branch information
jlp-craigmorten committed May 25, 2023
1 parent 39beeab commit 961e393
Show file tree
Hide file tree
Showing 5 changed files with 322 additions and 9 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>",
Expand Down
165 changes: 157 additions & 8 deletions src/Virtual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -16,6 +21,11 @@ export interface StartOptions extends CommandOptions {
container: HTMLElement;
}

const defaultUserEventOptions = {
delay: null,
skipHover: true,
};

const observedAttributes = [
...aria.keys(),
"type",
Expand All @@ -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;
Expand Down Expand Up @@ -147,14 +156,29 @@ export class Virtual implements ScreenReader {
);
}

/**
* Detect whether the screen reader is supported for the current OS.
*
* @returns {Promise<boolean>}
*/
async detect() {
return true;
}

/**
* Detect whether the screen reader is the default screen reader for the current OS.
*
* @returns {Promise<boolean>}
*/
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);
Expand All @@ -176,6 +200,9 @@ export class Virtual implements ScreenReader {
return;
}

/**
* Turn the screen reader off.
*/
async stop() {
this.#disconnectDOMObserver?.();
this.#invalidateTreeCache();
Expand All @@ -188,6 +215,9 @@ export class Virtual implements ScreenReader {
return;
}

/**
* Move the screen reader cursor to the previous location.
*/
async previous() {
this.#checkContainer();

Expand All @@ -206,6 +236,9 @@ export class Virtual implements ScreenReader {
return;
}

/**
* Move the screen reader cursor to the next location.
*/
async next() {
this.#checkContainer();

Expand All @@ -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;
Expand All @@ -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<string>} 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<string>} 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<string[]>} 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<string[]>} The item text log.
*/
async itemTextLog() {
this.#checkContainer();

Expand Down
65 changes: 65 additions & 0 deletions test/int/act.int.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { getByText, queryByText } from "@testing-library/dom";
import { virtual } from "../../src";

function setupButtonPage() {
document.body.innerHTML = `
<p id="status">Not Clicked</p>
<div id="hidden" style="display: none;">Hidden</div>
`;

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();
});
});
Loading

0 comments on commit 961e393

Please sign in to comment.