diff --git a/src/Virtual.ts b/src/Virtual.ts index 051de4a..12874d4 100644 --- a/src/Virtual.ts +++ b/src/Virtual.ts @@ -216,6 +216,7 @@ export class Virtual { #disconnectDOMObserver: (() => void) | null = null; #boundHandleFocusChange: ((event: Event) => Promise) | null = null; #userEvent: UserEvent | null = null; + #userEventOptions: Parameters[0] | null = null; #checkContainer() { if (!this.#container) { @@ -223,6 +224,19 @@ export class Virtual { } } + /** + * Lazily create the userEvent session on first use so that `start()` and + * pure navigation do not prepare the document / focused element, which can + * interfere with a consumer's own (separate) userEvent session. + */ + #getUserEvent() { + if (!this.#userEvent) { + this.#userEvent = userEvent.setup(this.#userEventOptions!); + } + + return this.#userEvent; + } + #createCursor(root: Root | undefined) { if (!root?.document) { return; @@ -611,11 +625,12 @@ export class Virtual { }, ); - this.#userEvent = userEvent.setup({ + this.#userEvent = null; + this.#userEventOptions = { ...defaultUserEventOptions, document: container.ownerDocument ?? globalThis.document, ...(advanceTimers ? { advanceTimers } : {}), - }); + }; const tree = this.#getAccessibilityTree(); @@ -672,6 +687,7 @@ export class Virtual { this.#spokenPhraseLog = []; this.#boundHandleFocusChange = null; this.#userEvent = null; + this.#userEventOptions = null; return; } @@ -802,7 +818,7 @@ export class Virtual { * * REF: https://www.w3.org/TR/core-aam-1.2/#mapping_actions */ - await this.#userEvent!.click(target); + await this.#getUserEvent().click(target); return; } @@ -914,7 +930,7 @@ export class Virtual { ].join(""); this.#focusActiveElement(); - await this.#userEvent!.keyboard(keyboardCommand); + await this.#getUserEvent().keyboard(keyboardCommand); await this.#refreshState(true); return; @@ -954,7 +970,7 @@ export class Virtual { } const target = getElementNode(this.#activeNode); - await this.#userEvent!.type(target, text); + await this.#getUserEvent().type(target, text); await this.#refreshState(true); return; @@ -1059,7 +1075,7 @@ export class Virtual { const keys = key.repeat(clickCount); const target = getElementNode(this.#activeNode); - await this.#userEvent!.pointer([{ target }, { keys, target }]); + await this.#getUserEvent().pointer([{ target }, { keys, target }]); return; } diff --git a/test/int/press.int.test.ts b/test/int/press.int.test.ts index a9bcb5b..c611c73 100644 --- a/test/int/press.int.test.ts +++ b/test/int/press.int.test.ts @@ -86,6 +86,31 @@ describe("press", () => { await virtual.stop(); }); + it("should use the latest options when started again without stopping", async () => { + const firstAdvanceTimers = jest.fn(); + const secondAdvanceTimers = jest.fn(); + const container = document.body; + + await virtual.start({ container, advanceTimers: firstAdvanceTimers }); + + await virtual.next(); + await virtual.next(); + await virtual.press("a"); + + expect(firstAdvanceTimers).toHaveBeenCalled(); + + await virtual.start({ container, advanceTimers: secondAdvanceTimers }); + + await virtual.next(); + await virtual.next(); + await virtual.press("b"); + + expect(getByRole(container, "textbox")).toHaveValue("ab"); + expect(secondAdvanceTimers).toHaveBeenCalled(); + + await virtual.stop(); + }); + it("should capitalise single character keys when Shift is pressed", async () => { const container = document.body; diff --git a/test/int/userEventIsolation.int.test.tsx b/test/int/userEventIsolation.int.test.tsx new file mode 100644 index 0000000..b07ba33 --- /dev/null +++ b/test/int/userEventIsolation.int.test.tsx @@ -0,0 +1,72 @@ +import { act, render, screen } from "@testing-library/react"; +import React, { useState } from "react"; +import { virtual } from "../../src/index.js"; + +/** + * Consumers commonly end up with their own copy of `@testing-library/user-event` + * (e.g. bundled by Storybook) alongside the one used by the Virtual Screen + * Reader. Load a separate module instance to simulate this. + */ +function loadSeparateUserEvent(): typeof import("@testing-library/user-event") { + let separateUserEvent: typeof import("@testing-library/user-event"); + + jest.isolateModules(() => { + separateUserEvent = jest.requireActual("@testing-library/user-event"); + }); + + return separateUserEvent!; +} + +/** + * The separate copy is not configured by `@testing-library/react`, so its + * interactions are wrapped in `act()` explicitly. + */ +const { userEvent: separateUserEvent } = loadSeparateUserEvent(); + +function ControlledInput() { + const [value, setValue] = useState(""); + + return ( + <> + + setValue(event.target.value)} + /> +

Current value: {value || "empty"}

+ + ); +} + +describe("userEvent isolation", () => { + afterEach(async () => { + await virtual.stop(); + }); + + it("should not interfere with a separate userEvent session when only navigating", async () => { + const user = separateUserEvent.setup(); + + render(); + + const input = screen.getByLabelText("Choose a fruit"); + + await act(() => user.type(input, "lu")); + + expect(input).toHaveValue("lu"); + expect(screen.getByText("Current value: lu")).toBeInTheDocument(); + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + await virtual.stop(); + + await act(() => user.clear(input)); + + expect(input).toHaveValue(""); + expect(screen.getByText("Current value: empty")).toBeInTheDocument(); + }); +});