Add a jsdom test suite for the public API

This commit is contained in:
Kamran Ahmed
2026-06-23 04:08:33 +01:00
parent 664af5330d
commit 4fd1a55297
10 changed files with 490 additions and 7 deletions
+2
View File
@@ -29,6 +29,8 @@
"playground:install": "pnpm --dir playground install",
"build": "tsc && vite build && dts-bundle-generator --config ./dts-bundle-generator.config.ts",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"format": "prettier . --write"
},
"files": [
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { createDriver, popoverTitle, SAMPLE_STEPS, useDriverHarness } from "./utils";
useDriverHarness();
describe("configuration & state", () => {
it("returns the active configuration via getConfig", () => {
const d = createDriver({ animate: false, stagePadding: 12 });
expect(d.getConfig().animate).toBe(false);
expect(d.getConfig().stagePadding).toBe(12);
});
it("updates configuration via setConfig", () => {
const d = createDriver({ animate: false });
d.setConfig({ animate: false, stagePadding: 25 });
expect(d.getConfig().stagePadding).toBe(25);
});
it("replaces the steps via setSteps", () => {
const d = createDriver({ animate: false });
d.setSteps([{ element: "#card-1", popover: { title: "Replaced" } }]);
d.drive();
expect(popoverTitle()).toBe("Replaced");
expect(d.isLastStep()).toBe(true);
});
it("exposes the documented state shape via getState", () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
expect(d.getState("isInitialized")).toBe(true);
expect(d.getState("activeIndex")).toBe(0);
const state = d.getState();
expect(state.activeStep?.popover?.title).toBe("Step 1");
expect(state.activeElement).toBe(document.querySelector("#intro"));
});
it("refreshes an active highlight without throwing", () => {
const d = createDriver({ animate: false });
d.highlight({ element: "#intro", popover: { title: "Intro" } });
expect(() => d.refresh()).not.toThrow();
expect(d.isActive()).toBe(true);
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
import { createDriver, nextFrame, navButton, SAMPLE_STEPS, useDriverHarness } from "./utils";
useDriverHarness();
describe("lifecycle hooks", () => {
it("fires onHighlightStarted synchronously with the element, step and options", () => {
const onHighlightStarted = vi.fn();
const d = createDriver({ animate: false, onHighlightStarted });
d.highlight({ element: "#intro", popover: { title: "Intro" } });
expect(onHighlightStarted).toHaveBeenCalledTimes(1);
const [element, step, options] = onHighlightStarted.mock.calls[0];
expect(element).toBe(document.querySelector("#intro"));
expect(step.popover?.title).toBe("Intro");
expect(options).toMatchObject({ config: expect.any(Object), state: expect.any(Object) });
expect(options.driver).toBe(d);
});
it("fires onHighlighted once the highlight settles", async () => {
const onHighlighted = vi.fn();
const d = createDriver({ animate: false, onHighlighted });
d.highlight({ element: "#intro", popover: { title: "Intro" } });
await nextFrame();
expect(onHighlighted).toHaveBeenCalledTimes(1);
});
it("fires onDeselected and onDestroyed on destroy", async () => {
const onDeselected = vi.fn();
const onDestroyed = vi.fn();
const d = createDriver({ animate: false, onDeselected, onDestroyed });
d.highlight({ element: "#intro", popover: { title: "Intro" } });
// The active element/step are committed in the rAF loop; let it run.
await nextFrame();
d.destroy();
expect(onDeselected).toHaveBeenCalledTimes(1);
expect(onDestroyed).toHaveBeenCalledTimes(1);
});
it("fires onDestroyStarted when closing, leaving teardown to the hook", () => {
const onDestroyStarted = vi.fn();
const d = createDriver({ animate: false, steps: SAMPLE_STEPS, onDestroyStarted });
d.drive();
navButton("close")?.click();
expect(onDestroyStarted).toHaveBeenCalledTimes(1);
// The hook didn't call destroy(), so the tour stays active.
expect(d.isActive()).toBe(true);
});
it("supports step-level hooks", () => {
const onHighlightStarted = vi.fn();
const d = createDriver({ animate: false });
d.highlight({ element: "#intro", popover: { title: "Intro" }, onHighlightStarted });
expect(onHighlightStarted).toHaveBeenCalledTimes(1);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from "vitest";
import { createDriver, navButton, popoverTitle, SAMPLE_STEPS, useDriverHarness } from "./utils";
useDriverHarness();
describe("button interactions", () => {
it("advances when the next button is clicked", () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
navButton("next")?.click();
expect(d.getActiveIndex()).toBe(1);
expect(popoverTitle()).toBe("Step 2");
});
it("goes back when the previous button is clicked", () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive(1);
navButton("prev")?.click();
expect(d.getActiveIndex()).toBe(0);
expect(popoverTitle()).toBe("Step 1");
});
it("closes the tour when the close button is clicked", () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
navButton("close")?.click();
expect(d.isActive()).toBe(false);
});
it("runs onNextClick instead of auto-advancing when provided", () => {
const onNextClick = vi.fn();
const d = createDriver({ animate: false, steps: SAMPLE_STEPS, onNextClick });
d.drive();
navButton("next")?.click();
expect(onNextClick).toHaveBeenCalledTimes(1);
expect(d.getActiveIndex()).toBe(0);
});
it("runs onPrevClick instead of going back when provided", () => {
const onPrevClick = vi.fn();
const d = createDriver({ animate: false, steps: SAMPLE_STEPS, onPrevClick });
d.drive(1);
navButton("prev")?.click();
expect(onPrevClick).toHaveBeenCalledTimes(1);
expect(d.getActiveIndex()).toBe(1);
});
it("supports a step-level onNextClick override", () => {
const onNextClick = vi.fn();
const d = createDriver({
animate: false,
steps: [
{ element: "#intro", popover: { title: "Step 1", onNextClick } },
{ element: "#card-1", popover: { title: "Step 2" } },
],
});
d.drive();
navButton("next")?.click();
expect(onNextClick).toHaveBeenCalledTimes(1);
expect(d.getActiveIndex()).toBe(0);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { createDriver, nextFrame, popoverTitle, pressKey, SAMPLE_STEPS, useDriverHarness } from "./utils";
useDriverHarness();
describe("keyboard control", () => {
it("closes the tour when Escape is pressed", () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
pressKey("Escape");
expect(d.isActive()).toBe(false);
});
it("ignores Escape when allowClose is false", () => {
const d = createDriver({ animate: false, allowClose: false, steps: SAMPLE_STEPS });
d.drive();
pressKey("Escape");
expect(d.isActive()).toBe(true);
});
it("navigates with the arrow keys", async () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
// Arrow handlers no-op mid-transition, so let the highlight settle first.
await nextFrame();
pressKey("ArrowRight");
await nextFrame();
expect(d.getActiveIndex()).toBe(1);
expect(popoverTitle()).toBe("Step 2");
pressKey("ArrowLeft");
await nextFrame();
expect(d.getActiveIndex()).toBe(0);
});
it("ignores keys when allowKeyboardControl is false", () => {
const d = createDriver({ animate: false, allowKeyboardControl: false, steps: SAMPLE_STEPS });
d.drive();
pressKey("ArrowRight");
expect(d.getActiveIndex()).toBe(0);
pressKey("Escape");
expect(d.isActive()).toBe(true);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { createDriver, popoverDescription, popoverEl, popoverTitle, useDriverHarness } from "./utils";
useDriverHarness();
describe("lifecycle", () => {
it("is inactive before anything is highlighted", () => {
const d = createDriver();
expect(d.isActive()).toBe(false);
expect(popoverEl()).toBeNull();
});
it("activates and renders a popover when highlighting an element", () => {
const d = createDriver({ animate: false });
d.highlight({ element: "#intro", popover: { title: "Intro", description: "The intro paragraph" } });
expect(d.isActive()).toBe(true);
expect(document.body.classList.contains("driver-active")).toBe(true);
expect(popoverTitle()).toBe("Intro");
expect(popoverDescription()).toBe("The intro paragraph");
});
it("marks the highlighted element as active and exposes it", () => {
const d = createDriver({ animate: false });
d.highlight({ element: "#intro", popover: { title: "Intro" } });
expect(document.querySelector("#intro")?.classList.contains("driver-active-element")).toBe(true);
expect(d.getActiveElement()).toBe(document.querySelector("#intro"));
});
it("supports element-less modal popovers via a dummy element", () => {
const d = createDriver({ animate: false });
d.highlight({ popover: { title: "Modal", description: "No element" } });
expect(d.isActive()).toBe(true);
expect(popoverTitle()).toBe("Modal");
expect(document.getElementById("driver-dummy-element")).not.toBeNull();
});
it("tears the DOM and state down on destroy", () => {
const d = createDriver({ animate: false });
d.highlight({ element: "#intro", popover: { title: "Intro" } });
d.destroy();
expect(d.isActive()).toBe(false);
expect(popoverEl()).toBeNull();
expect(document.body.classList.contains("driver-active")).toBe(false);
expect(document.querySelector(".driver-active-element")).toBeNull();
expect(d.getActiveIndex()).toBeUndefined();
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { createDriver, nextFrame, popoverTitle, SAMPLE_STEPS, useDriverHarness } from "./utils";
useDriverHarness();
describe("tour navigation", () => {
it("starts at the first step", () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
expect(d.getActiveIndex()).toBe(0);
expect(d.isFirstStep()).toBe(true);
expect(d.isLastStep()).toBe(false);
expect(d.hasPreviousStep()).toBeFalsy();
expect(d.hasNextStep()).toBeTruthy();
expect(popoverTitle()).toBe("Step 1");
});
it("can start at a given index", () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive(1);
expect(d.getActiveIndex()).toBe(1);
expect(popoverTitle()).toBe("Step 2");
});
it("moves to the next and previous steps", () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
d.moveNext();
expect(d.getActiveIndex()).toBe(1);
expect(popoverTitle()).toBe("Step 2");
d.movePrevious();
expect(d.getActiveIndex()).toBe(0);
expect(popoverTitle()).toBe("Step 1");
});
it("detects the last step", () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
d.moveTo(2);
expect(d.getActiveIndex()).toBe(2);
expect(d.isLastStep()).toBe(true);
expect(d.hasNextStep()).toBeFalsy();
expect(popoverTitle()).toBe("Step 3");
});
it("exposes the active and previous step and element", async () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
await nextFrame();
d.moveNext();
await nextFrame();
expect(d.getActiveStep()?.popover?.title).toBe("Step 2");
expect(d.getPreviousStep()?.popover?.title).toBe("Step 1");
expect(d.getActiveElement()).toBe(document.querySelector("#card-1"));
expect(d.getPreviousElement()).toBe(document.querySelector("#intro"));
});
});
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { createDriver, navButton, popoverEl, progressText, SAMPLE_STEPS, useDriverHarness } from "./utils";
useDriverHarness();
describe("popover rendering", () => {
it("shows no buttons for a bare highlight", () => {
const d = createDriver({ animate: false });
d.highlight({ element: "#intro", popover: { title: "Intro" } });
expect(navButton("close")?.style.display).toBe("none");
expect(document.querySelector<HTMLElement>(".driver-popover-footer")?.style.display).toBe("none");
});
it("renders the navigation buttons for a tour", () => {
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
d.drive();
expect(navButton("next")?.style.display).toBe("block");
expect(navButton("prev")?.style.display).toBe("block");
expect(navButton("close")?.style.display).toBe("block");
});
it("honours an explicit showButtons list", () => {
const d = createDriver({ animate: false });
d.highlight({ element: "#intro", popover: { title: "Intro", showButtons: ["close"] } });
expect(navButton("close")?.style.display).toBe("block");
expect(navButton("next")?.style.display).not.toBe("block");
});
it("disables buttons listed in disableButtons", () => {
const d = createDriver({ animate: false });
d.highlight({
element: "#intro",
popover: { title: "Intro", showButtons: ["next", "close"], disableButtons: ["next"] },
});
expect(navButton("next")?.disabled).toBe(true);
expect(navButton("next")?.classList.contains("driver-popover-btn-disabled")).toBe(true);
});
it("uses custom button text", () => {
const d = createDriver({ animate: false });
d.highlight({
element: "#intro",
popover: { title: "Intro", showButtons: ["next", "previous"], nextBtnText: "Onward", prevBtnText: "Back" },
});
expect(navButton("next")?.innerHTML).toBe("Onward");
expect(navButton("prev")?.innerHTML).toBe("Back");
});
it("renders progress text when enabled", () => {
const d = createDriver({ animate: false, showProgress: true, steps: SAMPLE_STEPS });
d.drive();
expect(progressText()).toBe("1 of 3");
});
it("formats a custom progress template", () => {
const d = createDriver({
animate: false,
showProgress: true,
progressText: "{{current}}/{{total}}",
steps: SAMPLE_STEPS,
});
d.drive();
expect(progressText()).toBe("1/3");
});
it("applies a custom popover class", () => {
const d = createDriver({ animate: false, popoverClass: "my-custom-popover" });
d.highlight({ element: "#intro", popover: { title: "Intro" } });
expect(popoverEl()?.classList.contains("my-custom-popover")).toBe(true);
});
it("allows mutating the popover from onPopoverRender", () => {
const d = createDriver({
animate: false,
onPopoverRender: popover => {
const extra = document.createElement("button");
extra.classList.add("my-extra-btn");
popover.footerButtons.appendChild(extra);
},
});
d.highlight({ element: "#intro", popover: { title: "Intro" } });
expect(document.querySelector(".driver-popover .my-extra-btn")).not.toBeNull();
});
});
-7
View File
@@ -1,7 +0,0 @@
import { describe, expect, it } from "vitest";
describe("add", () => {
it("should sum of 2 and 3 equals to 5", () => {
expect(5).toEqual(5);
});
});
+55
View File
@@ -0,0 +1,55 @@
import { afterEach, beforeEach } from "vitest";
import { driver, type DriveStep, type Driver } from "../src/driver";
type DriverConfig = Parameters<typeof driver>[0];
// A small, stable DOM that the tests highlight against.
export const DEMO_HTML = `
<header class="page-header"><h1>Title</h1></header>
<p id="intro">Intro paragraph</p>
<button id="card-1" type="button">Card One</button>
<ul class="feature-list"><li>One</li></ul>
`;
export const SAMPLE_STEPS: DriveStep[] = [
{ element: "#intro", popover: { title: "Step 1", description: "First" } },
{ element: "#card-1", popover: { title: "Step 2", description: "Second" } },
{ element: ".feature-list", popover: { title: "Step 3", description: "Third" } },
];
let active: Driver | undefined;
export function createDriver(config?: DriverConfig): Driver {
active = driver(config);
return active;
}
// Resets the DOM before each test and tears the driver down after, so the
// library's module-level state never leaks between tests. Call once per file.
export function useDriverHarness(): void {
beforeEach(() => {
document.body.innerHTML = DEMO_HTML;
});
afterEach(() => {
active?.destroy();
active = undefined;
document.body.innerHTML = "";
});
}
// Waits a single animation frame — needed for hooks that fire in the rAF loop.
export function nextFrame(): Promise<void> {
return new Promise(resolve => requestAnimationFrame(() => resolve()));
}
export const popoverEl = () => document.querySelector(".driver-popover");
export const popoverTitle = () => document.querySelector(".driver-popover-title")?.textContent?.trim();
export const popoverDescription = () => document.querySelector(".driver-popover-description")?.textContent?.trim();
export const progressText = () => document.querySelector(".driver-popover-progress-text")?.textContent?.trim();
export const navButton = (which: "next" | "prev" | "close") =>
document.querySelector<HTMLButtonElement>(`.driver-popover-${which}-btn`);
export function pressKey(key: string): void {
window.dispatchEvent(new KeyboardEvent("keyup", { key }));
}