Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 53 additions & 20 deletions src/Virtual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ import {
ERR_VIRTUAL_NOT_STARTED,
} from "./errors";
import { getLiveSpokenPhrase, LIVE } from "./getLiveSpokenPhrase";
import { UserEvent, userEvent } from "@testing-library/user-event";
import { flattenTree } from "./flattenTree";
import { getElementNode } from "./commands/getElementNode";
import { getItemText } from "./getItemText";
import { getSpokenPhrase } from "./getSpokenPhrase";
import { observeDOM } from "./observeDOM";
import { tick } from "./tick";
import { userEvent } from "@testing-library/user-event";
import type { VirtualCommandArgs } from "./commands/types";

/**
Expand Down Expand Up @@ -100,6 +100,15 @@ export interface StartOptions {
* Defaults to `false`.
*/
displayCursor?: boolean;

/**
* A function to be called internally to advance your fake timers (if applicable)
*
* @example jest.advanceTimersByTime
*
* @returns A promise that resolves after the specified delay, or void if not asynchronous.
*/
advanceTimers?: (delay: number) => Promise<void> | void;
}

const defaultUserEventOptions = {
Expand Down Expand Up @@ -206,13 +215,20 @@ export class Virtual {
#treeCache: AccessibilityNode[] | null = null;
#disconnectDOMObserver: (() => void) | null = null;
#boundHandleFocusChange: ((event: Event) => Promise<void>) | null = null;
#userEvent: UserEvent | null = null;

#checkContainer() {
if (!this.#container) {
throw new Error(ERR_VIRTUAL_NOT_STARTED);
}
}

#checkUserEvent() {
if (!this.#userEvent) {
throw new Error(ERR_VIRTUAL_NOT_STARTED);
}
}

#createCursor(root: Root | undefined) {
if (!root?.document) {
return;
Expand Down Expand Up @@ -284,7 +300,7 @@ export class Virtual {
* REF: https://www.w3.org/TR/wai-aria-1.2/#aria-modal
*/
return tree.filter(
({ parentDialog }) => this.#activeNode!.parentDialog === parentDialog
({ parentDialog }) => this.#activeNode!.parentDialog === parentDialog,
);
}

Expand Down Expand Up @@ -330,7 +346,7 @@ export class Virtual {
getLiveSpokenPhrase({
container,
mutation,
})
}),
)
.filter(Boolean)
.forEach((spokenPhrase) => {
Expand All @@ -342,7 +358,7 @@ export class Virtual {
return this.#spokenPhraseLog.filter(
(spokenPhrase) =>
!spokenPhrase.startsWith(LIVE.ASSERTIVE) &&
!spokenPhrase.startsWith(LIVE.POLITE)
!spokenPhrase.startsWith(LIVE.POLITE),
);
}

Expand All @@ -368,7 +384,7 @@ export class Virtual {
// cursor has changed.
const tree = this.#getAccessibilityTree();
const parentDialogNode = tree.find(
({ node }) => node === accessibilityNode.parentDialog
({ node }) => node === accessibilityNode.parentDialog,
)!;

const spokenPhrase = getSpokenPhrase(parentDialogNode);
Expand Down Expand Up @@ -426,7 +442,7 @@ export class Virtual {
accessibleValue === this.#activeNode?.accessibleValue &&
node === this.#activeNode?.node &&
role === this.#activeNode?.role &&
spokenRole === this.#activeNode?.spokenRole
spokenRole === this.#activeNode?.spokenRole,
);
}

Expand Down Expand Up @@ -490,8 +506,8 @@ export class Virtual {
get commands() {
return Object.fromEntries<keyof VirtualCommands>(
(Object.keys(commands) as (keyof VirtualCommands)[]).map(
(command: keyof VirtualCommands) => [command, command]
)
(command: keyof VirtualCommands) => [command, command],
),
) as { [K in keyof VirtualCommands]: K };
}

Expand Down Expand Up @@ -568,10 +584,15 @@ export class Virtual {
// @ts-ignore for non-TS users we default the container to `null` which
// prompts the missing container error.
async start(
{ container, displayCursor = false, window: root }: StartOptions = {
{
container,
displayCursor = false,
window: root,
advanceTimers,
}: StartOptions = {
container: null as never,
displayCursor: false,
}
},
) {
if (!container) {
throw new Error(ERR_VIRTUAL_MISSING_CONTAINER);
Expand All @@ -593,9 +614,15 @@ export class Virtual {
(mutations: MutationRecord[]) => {
this.#invalidateTreeCache();
this.#announceLiveRegions(mutations);
}
},
);

this.#userEvent = userEvent.setup({
...defaultUserEventOptions,
document: container.ownerDocument ?? globalThis.document,
...(advanceTimers ? { advanceTimers } : {}),
});

const tree = this.#getAccessibilityTree();

if (!tree.length) {
Expand Down Expand Up @@ -634,7 +661,10 @@ export class Virtual {
*/
async stop() {
this.#disconnectDOMObserver?.();
this.#container?.removeEventListener("focusin", this.#boundHandleFocusChange);
this.#container?.removeEventListener(
"focusin",
this.#boundHandleFocusChange,
);
this.#invalidateTreeCache();

if (this.#cursor) {
Expand All @@ -647,6 +677,7 @@ export class Virtual {
this.#itemTextLog = [];
this.#spokenPhraseLog = [];
this.#boundHandleFocusChange = null;
this.#userEvent = null;
return;
}

Expand Down Expand Up @@ -762,6 +793,8 @@ export class Virtual {
*/
async act() {
this.#checkContainer();
this.#checkUserEvent();

await tick();

if (!this.#activeNode) {
Expand All @@ -776,7 +809,7 @@ export class Virtual {
*
* REF: https://www.w3.org/TR/core-aam-1.2/#mapping_actions
*/
await userEvent.click(target, defaultUserEventOptions);
await this.#userEvent?.click(target);

return;
}
Expand Down Expand Up @@ -850,6 +883,7 @@ export class Virtual {
*/
async press(key: string) {
this.#checkContainer();
this.#checkUserEvent();
await tick();

if (!this.#activeNode) {
Expand Down Expand Up @@ -878,7 +912,7 @@ export class Virtual {
].join("");

this.#focusActiveElement();
await userEvent.keyboard(keyboardCommand, defaultUserEventOptions);
await this.#userEvent?.keyboard(keyboardCommand);
await this.#refreshState(true);

return;
Expand Down Expand Up @@ -911,14 +945,15 @@ export class Virtual {
*/
async type(text: string) {
this.#checkContainer();
this.#checkUserEvent();
await tick();

if (!this.#activeNode) {
return;
}

const target = getElementNode(this.#activeNode);
await userEvent.type(target, text, defaultUserEventOptions);
await this.#userEvent?.type(target, text);
await this.#refreshState(true);

return;
Expand Down Expand Up @@ -949,7 +984,7 @@ export class Virtual {
*/
async perform<
T extends keyof VirtualCommands,
K extends Omit<Parameters<VirtualCommands[T]>[0], keyof VirtualCommandArgs>
K extends Omit<Parameters<VirtualCommands[T]>[0], keyof VirtualCommandArgs>,
>(command: T, options?: { [L in keyof K]: K[L] }) {
this.#checkContainer();
await tick();
Expand Down Expand Up @@ -1013,6 +1048,7 @@ export class Virtual {
*/
async click({ button = "left", clickCount = 1 } = {}) {
this.#checkContainer();
this.#checkUserEvent();
await tick();

if (!this.#activeNode) {
Expand All @@ -1023,10 +1059,7 @@ export class Virtual {
const keys = key.repeat(clickCount);
const target = getElementNode(this.#activeNode);

await userEvent.pointer(
[{ target }, { keys, target }],
defaultUserEventOptions
);
await this.#userEvent?.pointer([{ target }, { keys, target }]);

return;
}
Expand Down
2 changes: 1 addition & 1 deletion src/tick.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export async function tick() {
return await new Promise<void>((resolve) => setTimeout(() => resolve()));
return await Promise.resolve();
}
33 changes: 28 additions & 5 deletions test/int/act.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,8 @@ function setupButtonPage() {
const button = document.createElement("button");

button.addEventListener("click", function (event) {

document.getElementById(
"status"
)!.innerHTML = `Clicked ${event.detail} Time(s)`;
document.getElementById("status")!.innerHTML =
`Clicked ${event.detail} Time(s)`;
});

button.innerHTML = "Click Me";
Expand Down Expand Up @@ -51,7 +49,6 @@ describe("act", () => {
});

it("should handle requests to perform the default action on hidden container gracefully", async () => {

const container = document.querySelector("#hidden")!;

await virtual.start({ container });
Expand All @@ -63,4 +60,30 @@ describe("act", () => {

await virtual.stop();
});

it("should support custom advanceTimers implementations", async () => {
const container = document.body;

const advanceTimers = jest.fn();
await virtual.start({ container, advanceTimers });

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();
expect(advanceTimers).toHaveBeenCalled();

await virtual.previous();
await virtual.previous();

expect(await virtual.lastSpokenPhrase()).toEqual("Clicked 1 Time(s)");

await virtual.stop();
});
});
35 changes: 29 additions & 6 deletions test/int/click.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,15 @@ function setupButtonPage() {
const button = document.createElement("button");

button.addEventListener("click", function (event) {

document.getElementById(
"status"
)!.innerHTML = `Clicked ${event.detail} Time(s)`;
document.getElementById("status")!.innerHTML =
`Clicked ${event.detail} Time(s)`;
});

button.innerHTML = "Click Me";

document.body.appendChild(button);

document.body.addEventListener("contextmenu", () => {

document.getElementById("status")!.innerHTML = "Right Clicked";
});
}
Expand Down Expand Up @@ -128,7 +125,6 @@ describe("click", () => {
});

it("should handle requests to click on hidden container gracefully", async () => {

const container = document.querySelector("#hidden")!;

await virtual.start({ container });
Expand All @@ -140,4 +136,31 @@ describe("click", () => {

await virtual.stop();
});

it("should support custom advance timers implementations", async () => {
const container = document.body;

const advanceTimers = jest.fn();

await virtual.start({ container, advanceTimers });

expect(getByText(container, "Not Clicked")).toBeInTheDocument();

while ((await virtual.itemText()) !== "Click Me") {
await virtual.next();
}

await virtual.click();

expect(queryByText(container, "Not Clicked")).not.toBeInTheDocument();
expect(getByText(container, "Clicked 1 Time(s)")).toBeInTheDocument();
expect(advanceTimers).toHaveBeenCalled();

await virtual.previous();
await virtual.previous();

expect(await virtual.lastSpokenPhrase()).toEqual("Clicked 1 Time(s)");

await virtual.stop();
});
});
21 changes: 20 additions & 1 deletion test/int/press.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ describe("press", () => {
});

it("should handle requests to press on hidden container gracefully", async () => {

const container = document.querySelector("#hidden")!;

await virtual.start({ container });
Expand All @@ -68,4 +67,24 @@ describe("press", () => {

await virtual.stop();
});

it("should support custom advanceTimers implementations", async () => {
const advanceTimers = jest.fn();
const container = document.body;

await virtual.start({ container, advanceTimers });

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 "ABC"
expect(getByRole(container, "textbox")).toHaveValue("abc");
expect(await virtual.itemText()).toEqual("Input Some Text, abc");
expect(advanceTimers).toHaveBeenCalled();

await virtual.stop();
});
});
Loading