mirror of
https://github.com/kamranahmedse/driver.js.git
synced 2026-08-31 01:12:43 +08:00
Add a jsdom test suite for the public API
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
}
|
||||
Reference in New Issue
Block a user