Skip to content

Commit ad6e308

Browse files
authored
feat: allow custom advanceTimers implementation and update tick to microtask based approach (#231)
* feat: allow custom advanceTimers method to be passed to start - Reworks userEvent to use a common user event session across all commands. * test: add tests for custom advanceTimers * feat: convert tick to microtask approach with Promise.resolve * be more explicit in type forcing userEvent * properly mark UserEvent import as a type import * remove unnecessary checkUserEvent method
1 parent c5aad98 commit ad6e308

6 files changed

Lines changed: 140 additions & 34 deletions

File tree

‎src/Virtual.ts‎

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ import {
88
ERR_VIRTUAL_NOT_STARTED,
99
} from "./errors";
1010
import { getLiveSpokenPhrase, LIVE } from "./getLiveSpokenPhrase";
11+
import { type UserEvent, userEvent } from "@testing-library/user-event";
1112
import { flattenTree } from "./flattenTree";
1213
import { getElementNode } from "./commands/getElementNode";
1314
import { getItemText } from "./getItemText";
1415
import { getSpokenPhrase } from "./getSpokenPhrase";
1516
import { observeDOM } from "./observeDOM";
1617
import { tick } from "./tick";
17-
import { userEvent } from "@testing-library/user-event";
1818
import type { VirtualCommandArgs } from "./commands/types";
1919

2020
/**
@@ -100,6 +100,15 @@ export interface StartOptions {
100100
* Defaults to `false`.
101101
*/
102102
displayCursor?: boolean;
103+
104+
/**
105+
* A function to be called internally to advance your fake timers (if applicable)
106+
*
107+
* @example jest.advanceTimersByTime
108+
*
109+
* @returns A promise that resolves after the specified delay, or void if not asynchronous.
110+
*/
111+
advanceTimers?: (delay: number) => Promise<void> | void;
103112
}
104113

105114
const defaultUserEventOptions = {
@@ -206,6 +215,7 @@ export class Virtual {
206215
#treeCache: AccessibilityNode[] | null = null;
207216
#disconnectDOMObserver: (() => void) | null = null;
208217
#boundHandleFocusChange: ((event: Event) => Promise<void>) | null = null;
218+
#userEvent: UserEvent | null = null;
209219

210220
#checkContainer() {
211221
if (!this.#container) {
@@ -284,7 +294,7 @@ export class Virtual {
284294
* REF: https://www.w3.org/TR/wai-aria-1.2/#aria-modal
285295
*/
286296
return tree.filter(
287-
({ parentDialog }) => this.#activeNode!.parentDialog === parentDialog
297+
({ parentDialog }) => this.#activeNode!.parentDialog === parentDialog,
288298
);
289299
}
290300

@@ -330,7 +340,7 @@ export class Virtual {
330340
getLiveSpokenPhrase({
331341
container,
332342
mutation,
333-
})
343+
}),
334344
)
335345
.filter(Boolean)
336346
.forEach((spokenPhrase) => {
@@ -342,7 +352,7 @@ export class Virtual {
342352
return this.#spokenPhraseLog.filter(
343353
(spokenPhrase) =>
344354
!spokenPhrase.startsWith(LIVE.ASSERTIVE) &&
345-
!spokenPhrase.startsWith(LIVE.POLITE)
355+
!spokenPhrase.startsWith(LIVE.POLITE),
346356
);
347357
}
348358

@@ -368,7 +378,7 @@ export class Virtual {
368378
// cursor has changed.
369379
const tree = this.#getAccessibilityTree();
370380
const parentDialogNode = tree.find(
371-
({ node }) => node === accessibilityNode.parentDialog
381+
({ node }) => node === accessibilityNode.parentDialog,
372382
)!;
373383

374384
const spokenPhrase = getSpokenPhrase(parentDialogNode);
@@ -426,7 +436,7 @@ export class Virtual {
426436
accessibleValue === this.#activeNode?.accessibleValue &&
427437
node === this.#activeNode?.node &&
428438
role === this.#activeNode?.role &&
429-
spokenRole === this.#activeNode?.spokenRole
439+
spokenRole === this.#activeNode?.spokenRole,
430440
);
431441
}
432442

@@ -490,8 +500,8 @@ export class Virtual {
490500
get commands() {
491501
return Object.fromEntries<keyof VirtualCommands>(
492502
(Object.keys(commands) as (keyof VirtualCommands)[]).map(
493-
(command: keyof VirtualCommands) => [command, command]
494-
)
503+
(command: keyof VirtualCommands) => [command, command],
504+
),
495505
) as { [K in keyof VirtualCommands]: K };
496506
}
497507

@@ -568,10 +578,15 @@ export class Virtual {
568578
// @ts-ignore for non-TS users we default the container to `null` which
569579
// prompts the missing container error.
570580
async start(
571-
{ container, displayCursor = false, window: root }: StartOptions = {
581+
{
582+
container,
583+
displayCursor = false,
584+
window: root,
585+
advanceTimers,
586+
}: StartOptions = {
572587
container: null as never,
573588
displayCursor: false,
574-
}
589+
},
575590
) {
576591
if (!container) {
577592
throw new Error(ERR_VIRTUAL_MISSING_CONTAINER);
@@ -593,9 +608,15 @@ export class Virtual {
593608
(mutations: MutationRecord[]) => {
594609
this.#invalidateTreeCache();
595610
this.#announceLiveRegions(mutations);
596-
}
611+
},
597612
);
598613

614+
this.#userEvent = userEvent.setup({
615+
...defaultUserEventOptions,
616+
document: container.ownerDocument ?? globalThis.document,
617+
...(advanceTimers ? { advanceTimers } : {}),
618+
});
619+
599620
const tree = this.#getAccessibilityTree();
600621

601622
if (!tree.length) {
@@ -634,7 +655,10 @@ export class Virtual {
634655
*/
635656
async stop() {
636657
this.#disconnectDOMObserver?.();
637-
this.#container?.removeEventListener("focusin", this.#boundHandleFocusChange);
658+
this.#container?.removeEventListener(
659+
"focusin",
660+
this.#boundHandleFocusChange,
661+
);
638662
this.#invalidateTreeCache();
639663

640664
if (this.#cursor) {
@@ -647,6 +671,7 @@ export class Virtual {
647671
this.#itemTextLog = [];
648672
this.#spokenPhraseLog = [];
649673
this.#boundHandleFocusChange = null;
674+
this.#userEvent = null;
650675
return;
651676
}
652677

@@ -762,6 +787,7 @@ export class Virtual {
762787
*/
763788
async act() {
764789
this.#checkContainer();
790+
765791
await tick();
766792

767793
if (!this.#activeNode) {
@@ -776,7 +802,7 @@ export class Virtual {
776802
*
777803
* REF: https://www.w3.org/TR/core-aam-1.2/#mapping_actions
778804
*/
779-
await userEvent.click(target, defaultUserEventOptions);
805+
await this.#userEvent!.click(target);
780806

781807
return;
782808
}
@@ -878,7 +904,7 @@ export class Virtual {
878904
].join("");
879905

880906
this.#focusActiveElement();
881-
await userEvent.keyboard(keyboardCommand, defaultUserEventOptions);
907+
await this.#userEvent!.keyboard(keyboardCommand);
882908
await this.#refreshState(true);
883909

884910
return;
@@ -918,7 +944,7 @@ export class Virtual {
918944
}
919945

920946
const target = getElementNode(this.#activeNode);
921-
await userEvent.type(target, text, defaultUserEventOptions);
947+
await this.#userEvent!.type(target, text);
922948
await this.#refreshState(true);
923949

924950
return;
@@ -949,7 +975,7 @@ export class Virtual {
949975
*/
950976
async perform<
951977
T extends keyof VirtualCommands,
952-
K extends Omit<Parameters<VirtualCommands[T]>[0], keyof VirtualCommandArgs>
978+
K extends Omit<Parameters<VirtualCommands[T]>[0], keyof VirtualCommandArgs>,
953979
>(command: T, options?: { [L in keyof K]: K[L] }) {
954980
this.#checkContainer();
955981
await tick();
@@ -1023,10 +1049,7 @@ export class Virtual {
10231049
const keys = key.repeat(clickCount);
10241050
const target = getElementNode(this.#activeNode);
10251051

1026-
await userEvent.pointer(
1027-
[{ target }, { keys, target }],
1028-
defaultUserEventOptions
1029-
);
1052+
await this.#userEvent!.pointer([{ target }, { keys, target }]);
10301053

10311054
return;
10321055
}

‎src/tick.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
export async function tick() {
2-
return await new Promise<void>((resolve) => setTimeout(() => resolve()));
2+
return await Promise.resolve();
33
}

‎test/int/act.int.test.ts‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,8 @@ function setupButtonPage() {
1010
const button = document.createElement("button");
1111

1212
button.addEventListener("click", function (event) {
13-
14-
document.getElementById(
15-
"status"
16-
)!.innerHTML = `Clicked ${event.detail} Time(s)`;
13+
document.getElementById("status")!.innerHTML =
14+
`Clicked ${event.detail} Time(s)`;
1715
});
1816

1917
button.innerHTML = "Click Me";
@@ -51,7 +49,6 @@ describe("act", () => {
5149
});
5250

5351
it("should handle requests to perform the default action on hidden container gracefully", async () => {
54-
5552
const container = document.querySelector("#hidden")!;
5653

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

6461
await virtual.stop();
6562
});
63+
64+
it("should support custom advanceTimers implementations", async () => {
65+
const container = document.body;
66+
67+
const advanceTimers = jest.fn();
68+
await virtual.start({ container, advanceTimers });
69+
70+
expect(getByText(container, "Not Clicked")).toBeInTheDocument();
71+
72+
while ((await virtual.itemText()) !== "Click Me") {
73+
await virtual.next();
74+
}
75+
76+
await virtual.act();
77+
78+
expect(queryByText(container, "Not Clicked")).not.toBeInTheDocument();
79+
expect(getByText(container, "Clicked 1 Time(s)")).toBeInTheDocument();
80+
expect(advanceTimers).toHaveBeenCalled();
81+
82+
await virtual.previous();
83+
await virtual.previous();
84+
85+
expect(await virtual.lastSpokenPhrase()).toEqual("Clicked 1 Time(s)");
86+
87+
await virtual.stop();
88+
});
6689
});

‎test/int/click.int.test.ts‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,15 @@ function setupButtonPage() {
1010
const button = document.createElement("button");
1111

1212
button.addEventListener("click", function (event) {
13-
14-
document.getElementById(
15-
"status"
16-
)!.innerHTML = `Clicked ${event.detail} Time(s)`;
13+
document.getElementById("status")!.innerHTML =
14+
`Clicked ${event.detail} Time(s)`;
1715
});
1816

1917
button.innerHTML = "Click Me";
2018

2119
document.body.appendChild(button);
2220

2321
document.body.addEventListener("contextmenu", () => {
24-
2522
document.getElementById("status")!.innerHTML = "Right Clicked";
2623
});
2724
}
@@ -128,7 +125,6 @@ describe("click", () => {
128125
});
129126

130127
it("should handle requests to click on hidden container gracefully", async () => {
131-
132128
const container = document.querySelector("#hidden")!;
133129

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

141137
await virtual.stop();
142138
});
139+
140+
it("should support custom advance timers implementations", async () => {
141+
const container = document.body;
142+
143+
const advanceTimers = jest.fn();
144+
145+
await virtual.start({ container, advanceTimers });
146+
147+
expect(getByText(container, "Not Clicked")).toBeInTheDocument();
148+
149+
while ((await virtual.itemText()) !== "Click Me") {
150+
await virtual.next();
151+
}
152+
153+
await virtual.click();
154+
155+
expect(queryByText(container, "Not Clicked")).not.toBeInTheDocument();
156+
expect(getByText(container, "Clicked 1 Time(s)")).toBeInTheDocument();
157+
expect(advanceTimers).toHaveBeenCalled();
158+
159+
await virtual.previous();
160+
await virtual.previous();
161+
162+
expect(await virtual.lastSpokenPhrase()).toEqual("Clicked 1 Time(s)");
163+
164+
await virtual.stop();
165+
});
143166
});

‎test/int/press.int.test.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ describe("press", () => {
4343
});
4444

4545
it("should handle requests to press on hidden container gracefully", async () => {
46-
4746
const container = document.querySelector("#hidden")!;
4847

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

6968
await virtual.stop();
7069
});
70+
71+
it("should support custom advanceTimers implementations", async () => {
72+
const advanceTimers = jest.fn();
73+
const container = document.body;
74+
75+
await virtual.start({ container, advanceTimers });
76+
77+
await virtual.next();
78+
await virtual.next();
79+
80+
expect(await virtual.itemText()).toEqual("Input Some Text");
81+
82+
await virtual.press("Shift+a+b+c");
83+
// TODO: FAIL Testing Library user-event doesn't support modification yet, this should be "ABC"
84+
expect(getByRole(container, "textbox")).toHaveValue("abc");
85+
expect(await virtual.itemText()).toEqual("Input Some Text, abc");
86+
expect(advanceTimers).toHaveBeenCalled();
87+
88+
await virtual.stop();
89+
});
7190
});

‎test/int/type.int.test.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ describe("type", () => {
3737
});
3838

3939
it("should handle requests to type on hidden container gracefully", async () => {
40-
4140
const container = document.querySelector("#hidden")!;
4241

4342
await virtual.start({ container });
@@ -49,4 +48,23 @@ describe("type", () => {
4948

5049
await virtual.stop();
5150
});
51+
52+
it("should support custom advanceTimers implementations", async () => {
53+
const advanceTimers = jest.fn();
54+
const container = document.body;
55+
56+
await virtual.start({ container, advanceTimers });
57+
58+
await virtual.next();
59+
await virtual.next();
60+
61+
expect(await virtual.itemText()).toEqual("Input Some Text");
62+
63+
await virtual.type("Hello World!");
64+
expect(getByRole(container, "textbox")).toHaveValue("Hello World!");
65+
expect(await virtual.itemText()).toEqual("Input Some Text, Hello World!");
66+
expect(advanceTimers).toHaveBeenCalled();
67+
68+
await virtual.stop();
69+
});
5270
});

0 commit comments

Comments
 (0)