test(history): jsdom/renderHook lane + useBackToExit back-ladder tests
First DOM-capable vitest lane for the repo: default env stays node (pure lib helpers), a test opts into jsdom per-file with a // @vitest-environment jsdom docblock; plugin-react wired for JSX/hook tests. Adds jsdom + @testing-library/react devDeps and 3 back-ladder tests (LIFO peel, stacks over useBackToClose, inactive layer is transparent) driven by direct popstate dispatch — deterministic, no dependency on jsdom's async back(). Closes the deferred R2 component-test gap (useBackToExit).
This commit is contained in:
Generated
+881
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9.39.1",
|
"@eslint/js": "9.39.1",
|
||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.61.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.1.1",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"eslint-plugin-react-hooks": "5.2.0",
|
"eslint-plugin-react-hooks": "5.2.0",
|
||||||
"eslint-plugin-react-refresh": "0.4.24",
|
"eslint-plugin-react-refresh": "0.4.24",
|
||||||
"globals": "16.5.0",
|
"globals": "16.5.0",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"knip": "6.27.0",
|
"knip": "6.27.0",
|
||||||
"postcss": "^8.4.39",
|
"postcss": "^8.4.39",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// Component-test lane pilot: the useBackToExit "back-ladder". This is the first jsdom/renderHook
|
||||||
|
// test in the repo (the pure-node lane can't touch window.history / popstate). It exercises the
|
||||||
|
// module-level overlay stack in history.ts through its only observable effect — which onExit fires.
|
||||||
|
//
|
||||||
|
// A GENUINE user Back is simulated by dispatching `popstate` directly: the real-Back branch in
|
||||||
|
// history.ts does NOT call history.back() (it only decrements its own historyDepth to match the
|
||||||
|
// entry the browser already dropped), so a raw popstate reproduces it deterministically without
|
||||||
|
// depending on jsdom's async back() semantics. Each test closes its layers via genuine Backs, so
|
||||||
|
// no reconcile-driven history.back() runs and the shared module state stays clean between tests.
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import { useBackToClose, useBackToExit } from "./history";
|
||||||
|
|
||||||
|
// Let the mount effect's coalesced reconcile microtask settle.
|
||||||
|
const settle = () => act(async () => { await Promise.resolve(); });
|
||||||
|
// A genuine user Back (browser already dropped the entry; popstate arrives with no pending back()).
|
||||||
|
const userBack = () =>
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useBackToExit back-ladder", () => {
|
||||||
|
it("nested active layers peel off one genuine Back at a time, topmost first (LIFO)", async () => {
|
||||||
|
const outer = vi.fn();
|
||||||
|
const inner = vi.fn();
|
||||||
|
|
||||||
|
const ro = renderHook(() => useBackToExit(true, outer));
|
||||||
|
await settle();
|
||||||
|
const ri = renderHook(() => useBackToExit(true, inner));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
await userBack(); // peels the topmost (inner) only
|
||||||
|
expect(inner).toHaveBeenCalledTimes(1);
|
||||||
|
expect(outer).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await userBack(); // now peels the outer
|
||||||
|
expect(outer).toHaveBeenCalledTimes(1);
|
||||||
|
expect(inner).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
ro.unmount();
|
||||||
|
ri.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacks above a useBackToClose layer — Back closes the exit layer before the close layer", async () => {
|
||||||
|
const close = vi.fn();
|
||||||
|
const exit = vi.fn();
|
||||||
|
|
||||||
|
const rc = renderHook(() => useBackToClose(close));
|
||||||
|
await settle();
|
||||||
|
const re = renderHook(() => useBackToExit(true, exit));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
await userBack();
|
||||||
|
expect(exit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(close).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await userBack();
|
||||||
|
expect(close).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
rc.unmount();
|
||||||
|
re.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an inactive layer registers nothing — Back falls straight through to the layer below it", async () => {
|
||||||
|
const below = vi.fn();
|
||||||
|
const gated = vi.fn();
|
||||||
|
|
||||||
|
const rb = renderHook(() => useBackToExit(true, below));
|
||||||
|
await settle();
|
||||||
|
const rg = renderHook(() => useBackToExit(false, gated)); // gated: not active → no layer
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
await userBack();
|
||||||
|
expect(gated).not.toHaveBeenCalled();
|
||||||
|
expect(below).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
rb.unmount();
|
||||||
|
rg.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
// Unit tests for the pure helpers in src/lib — deliberately separate from vite.config.ts so the
|
// Unit + component/hook tests, deliberately separate from vite.config.ts so the production build
|
||||||
// production build never has to load vitest. Plain node env (no jsdom): these tests stub the
|
// never has to load vitest. The DEFAULT environment is node (the pure src/lib helpers — fast, no
|
||||||
// modules that touch the DOM. Browser flows stay in e2e/ under Playwright (`siftlode e2e`).
|
// DOM). A test that needs the DOM (component render or renderHook) opts INTO jsdom per-file with a
|
||||||
// Being its own file means vite.config.ts is NOT merged in — a future test that renders a .tsx
|
// // @vitest-environment jsdom
|
||||||
// component has to wire @vitejs/plugin-react (and a DOM environment) here itself.
|
// docblock on its first line. plugin-react is wired here so those tests get JSX + a Fast-Refresh-
|
||||||
|
// free React transform; it is a no-op for the plain .ts node tests (they carry no JSX). jsdom is a
|
||||||
|
// devDependency referenced only via that docblock env string — knip's vitest plugin still sees it.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
test: {
|
test: {
|
||||||
// .tsx included on purpose: a test file the glob misses does not fail, it is simply never
|
// .tsx included on purpose: a test file the glob misses does not fail, it is simply never
|
||||||
// collected, and the gate still prints green. (Such a test must still wire plugin-react +
|
// collected, and the gate still prints green.
|
||||||
// a DOM environment here — see above — but at least it will run and say so.)
|
|
||||||
include: ["src/**/*.test.{ts,tsx}"],
|
include: ["src/**/*.test.{ts,tsx}"],
|
||||||
environment: "node",
|
environment: "node",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user