Files
driver.js/tests/events.test.ts
T
2026-06-25 14:44:50 +01:00

235 lines
7.2 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { createDriver, navButton, nextFrame, SAMPLE_STEPS, useDriverHarness } from "./utils";
useDriverHarness();
afterEach(() => {
vi.restoreAllMocks();
});
const DRIVER_EVENT_TYPES = ["click", "pointerdown", "mousedown", "pointerup", "mouseup"];
type ListenerRecord = {
type: string;
listener: EventListenerOrEventListenerObject;
capture: boolean;
};
function normalizeCapture(options?: boolean | AddEventListenerOptions | EventListenerOptions): boolean {
if (typeof options === "boolean") {
return options;
}
return !!options?.capture;
}
function trackDocumentListeners() {
const added: ListenerRecord[] = [];
const removed: ListenerRecord[] = [];
const originalAdd = document.addEventListener.bind(document);
const originalRemove = document.removeEventListener.bind(document);
vi.spyOn(document, "addEventListener").mockImplementation((type, listener, options) => {
if (DRIVER_EVENT_TYPES.includes(type)) {
added.push({
type,
listener: listener as EventListenerOrEventListenerObject,
capture: normalizeCapture(options),
});
}
return originalAdd(type, listener as EventListener, options);
});
vi.spyOn(document, "removeEventListener").mockImplementation((type, listener, options) => {
if (DRIVER_EVENT_TYPES.includes(type)) {
removed.push({
type,
listener: listener as EventListenerOrEventListenerObject,
capture: normalizeCapture(options),
});
}
return originalRemove(type, listener as EventListener, options);
});
function liveCount(): number {
const outstanding = [...removed];
let live = 0;
for (const record of added) {
const idx = outstanding.findIndex(
candidate =>
candidate.type === record.type &&
candidate.listener === record.listener &&
candidate.capture === record.capture
);
if (idx === -1) {
live++;
} else {
outstanding.splice(idx, 1);
}
}
return live;
}
return { liveCount };
}
describe("document listener cleanup", () => {
it("attaches document click listeners while the tour is active", () => {
const tracker = trackDocumentListeners();
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
expect(tracker.liveCount()).toBeGreaterThan(0);
});
it("removes every document click listener once the tour is destroyed", () => {
const tracker = trackDocumentListeners();
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
d.destroy();
expect(tracker.liveCount()).toBe(0);
});
it("does not accumulate document listeners as the tour moves between steps", () => {
const tracker = trackDocumentListeners();
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
const afterFirstStep = tracker.liveCount();
navButton("next")?.click();
navButton("next")?.click();
expect(tracker.liveCount()).toBe(afterFirstStep);
});
it("removes the keydown focus-trap listener when destroyed", () => {
const removeSpy = vi.spyOn(window, "removeEventListener");
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
d.destroy();
expect(removeSpy.mock.calls.some(([type]) => type === "keydown")).toBe(true);
});
});
describe("keyboard focus trap", () => {
function pressTab(opts: { shift?: boolean } = {}): KeyboardEvent {
const event = new KeyboardEvent("keydown", { key: "Tab", shiftKey: !!opts.shift, cancelable: true });
window.dispatchEvent(event);
return event;
}
// jsdom doesn't lay elements out, so the focusable buttons read as invisible
// and focus never actually moves. We assert the trap swallows the Tab (the
// observable effect) rather than where focus lands.
it("traps Tab inside the popover while a tour is active", () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
expect(pressTab().defaultPrevented).toBe(true);
});
it("cycles backwards on Shift+Tab", () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
expect(pressTab({ shift: true }).defaultPrevented).toBe(true);
});
it("does not trap Tab once the tour is destroyed", () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
d.destroy();
expect(pressTab().defaultPrevented).toBe(false);
});
it("ignores non-Tab keys", () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
const event = new KeyboardEvent("keydown", { key: "a", cancelable: true });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
});
});
describe("refresh on viewport changes", () => {
const rect = (over: Partial<DOMRect>): DOMRect =>
({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0, toJSON() {}, ...over }) as DOMRect;
const overlayD = () => document.querySelector<SVGPathElement>(".driver-overlay path")?.getAttribute("d");
it("re-tracks the active element when the window resizes", async () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
await nextFrame();
// Move the highlighted element, then resize: the overlay cutout should
// follow it. With default padding 10 / radius 5 the cutout's leading corner
// sits at (x - 10 + 5, y - 10) = (95, 190).
document.querySelector<HTMLElement>("#intro")!.getBoundingClientRect = () =>
rect({ x: 100, y: 200, top: 200, left: 100, width: 50, height: 60 });
window.dispatchEvent(new Event("resize"));
await nextFrame();
expect(overlayD()).toContain("M95,190");
});
it("re-tracks the active element on scroll", async () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
await nextFrame();
document.querySelector<HTMLElement>("#intro")!.getBoundingClientRect = () =>
rect({ x: 300, y: 400, top: 400, left: 300, width: 50, height: 60 });
window.dispatchEvent(new Event("scroll"));
await nextFrame();
expect(overlayD()).toContain("M295,390");
});
});
describe("overlay pointer suppression", () => {
it("prevents default on pointer events over the overlay to block page interaction", async () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
await nextFrame();
const path = document.querySelector(".driver-overlay path");
expect(path).not.toBeNull();
for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup"]) {
const event = new MouseEvent(type, { bubbles: true, cancelable: true });
path!.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
}
});
it("still routes overlay clicks to the click handler", async () => {
const onNextClick = vi.fn();
const d = createDriver({
animate: false,
overlayClickBehavior: "nextStep",
steps: SAMPLE_STEPS,
onNextClick,
});
d.drive();
await nextFrame();
document.querySelector(".driver-overlay path")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onNextClick).toHaveBeenCalledTimes(1);
});
});