From 054839d1584d47bff2f77f7b64f99e095de1c2a4 Mon Sep 17 00:00:00 2001 From: Will McKenzie Date: Mon, 7 Sep 2026 12:02:42 +0100 Subject: [PATCH 1/3] feat: allow custom advanceTimers method to be passed to start - Reworks userEvent to use a common user event session across all commands. --- src/Virtual.ts | 73 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 20 deletions(-) diff --git a/src/Virtual.ts b/src/Virtual.ts index e5b13a4..8193d84 100644 --- a/src/Virtual.ts +++ b/src/Virtual.ts @@ -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"; /** @@ -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; } const defaultUserEventOptions = { @@ -206,6 +215,7 @@ export class Virtual { #treeCache: AccessibilityNode[] | null = null; #disconnectDOMObserver: (() => void) | null = null; #boundHandleFocusChange: ((event: Event) => Promise) | null = null; + #userEvent: UserEvent | null = null; #checkContainer() { if (!this.#container) { @@ -213,6 +223,12 @@ export class Virtual { } } + #checkUserEvent() { + if (!this.#userEvent) { + throw new Error(ERR_VIRTUAL_NOT_STARTED); + } + } + #createCursor(root: Root | undefined) { if (!root?.document) { return; @@ -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, ); } @@ -330,7 +346,7 @@ export class Virtual { getLiveSpokenPhrase({ container, mutation, - }) + }), ) .filter(Boolean) .forEach((spokenPhrase) => { @@ -342,7 +358,7 @@ export class Virtual { return this.#spokenPhraseLog.filter( (spokenPhrase) => !spokenPhrase.startsWith(LIVE.ASSERTIVE) && - !spokenPhrase.startsWith(LIVE.POLITE) + !spokenPhrase.startsWith(LIVE.POLITE), ); } @@ -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); @@ -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, ); } @@ -490,8 +506,8 @@ export class Virtual { get commands() { return Object.fromEntries( (Object.keys(commands) as (keyof VirtualCommands)[]).map( - (command: keyof VirtualCommands) => [command, command] - ) + (command: keyof VirtualCommands) => [command, command], + ), ) as { [K in keyof VirtualCommands]: K }; } @@ -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); @@ -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) { @@ -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) { @@ -647,6 +677,7 @@ export class Virtual { this.#itemTextLog = []; this.#spokenPhraseLog = []; this.#boundHandleFocusChange = null; + this.#userEvent = null; return; } @@ -762,6 +793,8 @@ export class Virtual { */ async act() { this.#checkContainer(); + this.#checkUserEvent(); + await tick(); if (!this.#activeNode) { @@ -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; } @@ -850,6 +883,7 @@ export class Virtual { */ async press(key: string) { this.#checkContainer(); + this.#checkUserEvent(); await tick(); if (!this.#activeNode) { @@ -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; @@ -911,6 +945,7 @@ export class Virtual { */ async type(text: string) { this.#checkContainer(); + this.#checkUserEvent(); await tick(); if (!this.#activeNode) { @@ -918,7 +953,7 @@ export class Virtual { } const target = getElementNode(this.#activeNode); - await userEvent.type(target, text, defaultUserEventOptions); + await this.#userEvent?.type(target, text); await this.#refreshState(true); return; @@ -949,7 +984,7 @@ export class Virtual { */ async perform< T extends keyof VirtualCommands, - K extends Omit[0], keyof VirtualCommandArgs> + K extends Omit[0], keyof VirtualCommandArgs>, >(command: T, options?: { [L in keyof K]: K[L] }) { this.#checkContainer(); await tick(); @@ -1013,6 +1048,7 @@ export class Virtual { */ async click({ button = "left", clickCount = 1 } = {}) { this.#checkContainer(); + this.#checkUserEvent(); await tick(); if (!this.#activeNode) { @@ -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; } From 19b4ccf4aed5752f74b5215ad6ed95f216ee8f50 Mon Sep 17 00:00:00 2001 From: Will McKenzie Date: Mon, 7 Sep 2026 12:10:37 +0100 Subject: [PATCH 2/3] test: add tests for custom advanceTimers --- test/int/act.int.test.ts | 33 ++++++++++++++++++++++++++++----- test/int/click.int.test.ts | 35 +++++++++++++++++++++++++++++------ test/int/press.int.test.ts | 21 ++++++++++++++++++++- test/int/type.int.test.ts | 20 +++++++++++++++++++- 4 files changed, 96 insertions(+), 13 deletions(-) diff --git a/test/int/act.int.test.ts b/test/int/act.int.test.ts index 6d596fb..67540be 100644 --- a/test/int/act.int.test.ts +++ b/test/int/act.int.test.ts @@ -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"; @@ -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 }); @@ -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(); + }); }); diff --git a/test/int/click.int.test.ts b/test/int/click.int.test.ts index ce3aa39..b94145c 100644 --- a/test/int/click.int.test.ts +++ b/test/int/click.int.test.ts @@ -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"; @@ -21,7 +19,6 @@ function setupButtonPage() { document.body.appendChild(button); document.body.addEventListener("contextmenu", () => { - document.getElementById("status")!.innerHTML = "Right Clicked"; }); } @@ -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 }); @@ -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(); + }); }); diff --git a/test/int/press.int.test.ts b/test/int/press.int.test.ts index c3f7089..44e6b40 100644 --- a/test/int/press.int.test.ts +++ b/test/int/press.int.test.ts @@ -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 }); @@ -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(); + }); }); diff --git a/test/int/type.int.test.ts b/test/int/type.int.test.ts index c7ac6cf..01bbc86 100644 --- a/test/int/type.int.test.ts +++ b/test/int/type.int.test.ts @@ -37,7 +37,6 @@ describe("type", () => { }); it("should handle requests to type on hidden container gracefully", async () => { - const container = document.querySelector("#hidden")!; await virtual.start({ container }); @@ -49,4 +48,23 @@ describe("type", () => { 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.type("Hello World!"); + expect(getByRole(container, "textbox")).toHaveValue("Hello World!"); + expect(await virtual.itemText()).toEqual("Input Some Text, Hello World!"); + expect(advanceTimers).toHaveBeenCalled(); + + await virtual.stop(); + }); }); From f4d960a30367d01076aca3db77f0d33fe61b94ec Mon Sep 17 00:00:00 2001 From: Will McKenzie Date: Mon, 7 Sep 2026 13:50:50 +0100 Subject: [PATCH 3/3] feat: convert tick to microtask approach with Promise.resolve --- src/tick.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tick.ts b/src/tick.ts index c6961e2..3b32bbd 100644 --- a/src/tick.ts +++ b/src/tick.ts @@ -1,3 +1,3 @@ export async function tick() { - return await new Promise((resolve) => setTimeout(() => resolve())); + return await Promise.resolve(); }