From 4fd1a55297ed4ad550f0b34f75cce74da7431aa9 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed Date: Tue, 23 Jun 2026 04:08:33 +0100 Subject: [PATCH] Add a jsdom test suite for the public API --- package.json | 2 + tests/config.test.ts | 49 ++++++++++++++++++++ tests/hooks.test.ts | 60 ++++++++++++++++++++++++ tests/interactions.test.ts | 68 ++++++++++++++++++++++++++++ tests/keyboard.test.ts | 49 ++++++++++++++++++++ tests/lifecycle.test.ts | 51 +++++++++++++++++++++ tests/navigation.test.ts | 63 ++++++++++++++++++++++++++ tests/popover.test.ts | 93 ++++++++++++++++++++++++++++++++++++++ tests/sum.test.ts | 7 --- tests/utils.ts | 55 ++++++++++++++++++++++ 10 files changed, 490 insertions(+), 7 deletions(-) create mode 100644 tests/config.test.ts create mode 100644 tests/hooks.test.ts create mode 100644 tests/interactions.test.ts create mode 100644 tests/keyboard.test.ts create mode 100644 tests/lifecycle.test.ts create mode 100644 tests/navigation.test.ts create mode 100644 tests/popover.test.ts delete mode 100644 tests/sum.test.ts create mode 100644 tests/utils.ts diff --git a/package.json b/package.json index ed17949..efadbbf 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..8aa7ae6 --- /dev/null +++ b/tests/config.test.ts @@ -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); + }); +}); diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts new file mode 100644 index 0000000..8773371 --- /dev/null +++ b/tests/hooks.test.ts @@ -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); + }); +}); diff --git a/tests/interactions.test.ts b/tests/interactions.test.ts new file mode 100644 index 0000000..da7ff16 --- /dev/null +++ b/tests/interactions.test.ts @@ -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); + }); +}); diff --git a/tests/keyboard.test.ts b/tests/keyboard.test.ts new file mode 100644 index 0000000..a90f5b9 --- /dev/null +++ b/tests/keyboard.test.ts @@ -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); + }); +}); diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts new file mode 100644 index 0000000..8487dcc --- /dev/null +++ b/tests/lifecycle.test.ts @@ -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(); + }); +}); diff --git a/tests/navigation.test.ts b/tests/navigation.test.ts new file mode 100644 index 0000000..00ad214 --- /dev/null +++ b/tests/navigation.test.ts @@ -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")); + }); +}); diff --git a/tests/popover.test.ts b/tests/popover.test.ts new file mode 100644 index 0000000..f7cfb1f --- /dev/null +++ b/tests/popover.test.ts @@ -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(".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(); + }); +}); diff --git a/tests/sum.test.ts b/tests/sum.test.ts deleted file mode 100644 index 2227efc..0000000 --- a/tests/sum.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 0000000..c158c49 --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach } from "vitest"; +import { driver, type DriveStep, type Driver } from "../src/driver"; + +type DriverConfig = Parameters[0]; + +// A small, stable DOM that the tests highlight against. +export const DEMO_HTML = ` + +

Intro paragraph

+ +
  • One
+`; + +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 { + 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(`.driver-popover-${which}-btn`); + +export function pressKey(key: string): void { + window.dispatchEvent(new KeyboardEvent("keyup", { key })); +}