diff --git a/src/test/hookHarness.test.ts b/src/test/hookHarness.test.ts new file mode 100644 index 0000000..d581f38 --- /dev/null +++ b/src/test/hookHarness.test.ts @@ -0,0 +1,286 @@ +// hookHarness — each simulated semantic proved by its own test (#409). +// +// The tests that rely on the harness can only be as true as it is. Here it is +// driven directly: a harness of its own, a fake React built over the real one, +// and small components written for the test. +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { readFileSync } from "node:fs"; +import * as actualReact from "react"; +import { createHookHarness, fakeReact, hookHarness, type HookHarness } from "./hookHarness"; + +let harness: HookHarness; +let R: typeof actualReact; + +beforeEach(() => { + harness = createHookHarness(); + R = fakeReact(actualReact, harness); +}); + +describe("hookHarness — the module itself", () => { + it("imports React only as a type: a value import would load the mock it builds", () => { + // The code only: the header comment shows the pattern of use. + const source = readFileSync(new URL("./hookHarness.ts", import.meta.url), "utf8") + .split("\n") + .filter((line) => !line.trimStart().startsWith("//")) + .join("\n"); + const reactImports = source.split("\n").filter((line) => /from\s+["']react["']/.test(line)); + expect(reactImports.length).toBeGreaterThan(0); + for (const line of reactImports) expect(line).toMatch(/^import type /); + expect(source).not.toMatch(/require\(["']react["']\)/); + expect(source).not.toMatch(/import\(["']react["']\)/); + }); + + it("shares one harness through its accessor", () => { + expect(hookHarness()).toBe(hookHarness()); + }); + + it("leaves the rest of React real", () => { + expect(R.isValidElement).toBe(actualReact.isValidElement); + expect(R.Fragment).toBe(actualReact.Fragment); + expect(R.createElement).toBe(actualReact.createElement); + }); +}); + +describe("hookHarness — useState", () => { + it("calls the initializer once, at the first render", () => { + const init = vi.fn(() => 1); + const Counter = () => R.useState(init)[0]; + expect(harness.render(Counter)).toBe(1); + expect(harness.render(Counter)).toBe(1); + expect(init).toHaveBeenCalledTimes(1); + }); + + it("keeps a stable setter, applies functional updates, and does not re-render", () => { + let renders = 0; + const Counter = () => { + renders++; + return R.useState(0); + }; + const [, set] = harness.render(Counter); + set(5); + set((n: number) => n + 1); + expect(renders).toBe(1); + const [value, again] = harness.render(Counter); + expect(value).toBe(6); + expect(again).toBe(set); + }); + + it("keeps each slot apart, by call order", () => { + const Pair = () => [R.useState("a")[0], R.useState("b")[0]]; + expect(harness.render(Pair)).toEqual(["a", "b"]); + }); + + it("reads a slot the test preset before the first render", () => { + harness.slots[0] = "preset"; + expect(harness.render(() => R.useState("init")[0])).toBe("preset"); + }); +}); + +describe("hookHarness — useReducer", () => { + const reducer = (state: number, action: { by: number }) => state + action.by; + + it("applies the reducer, keeps a stable dispatch, and records every action in the spy", () => { + const Sum = () => R.useReducer(reducer, 10); + const [state, dispatch] = harness.render(Sum); + expect(state).toBe(10); + dispatch({ by: 2 }); + dispatch({ by: 3 }); + const [next, again] = harness.render(Sum); + expect(next).toBe(15); + expect(again).toBe(dispatch); + expect(harness.dispatch.mock.calls.map((c) => c[0])).toEqual([{ by: 2 }, { by: 3 }]); + }); + + it("calls the init function once", () => { + const init = vi.fn((n: number) => n * 2); + const Sum = () => R.useReducer(reducer, 4, init)[0]; + expect(harness.render(Sum)).toBe(8); + expect(harness.render(Sum)).toBe(8); + expect(init).toHaveBeenCalledTimes(1); + }); +}); + +describe("hookHarness — useRef", () => { + it("keeps one object from render to render", () => { + const Ref = () => R.useRef(0); + const first = harness.render(Ref); + first.current = 7; + const second = harness.render(Ref); + expect(second).toBe(first); + expect(second.current).toBe(7); + }); +}); + +describe("hookHarness — useMemo and useCallback", () => { + it("useMemo keeps its value while the dependencies are equal, recomputes when one changes", () => { + const compute = vi.fn((n: number) => ({ n })); + let dep = 1; + const Memo = () => R.useMemo(() => compute(dep), [dep]); + const first = harness.render(Memo); + expect(harness.render(Memo)).toBe(first); + expect(compute).toHaveBeenCalledTimes(1); + dep = 2; + const second = harness.render(Memo); + expect(second).not.toBe(first); + expect(second).toEqual({ n: 2 }); + expect(compute).toHaveBeenCalledTimes(2); + }); + + it("compares the dependencies with Object.is", () => { + let dep: unknown = NaN; + const compute = vi.fn(() => ({})); + const Memo = () => R.useMemo(compute, [dep]); + harness.render(Memo); + harness.render(Memo); + expect(compute).toHaveBeenCalledTimes(1); + dep = { same: true }; + harness.render(Memo); + dep = { same: true }; + harness.render(Memo); + expect(compute).toHaveBeenCalledTimes(3); + }); + + it("useMemo without dependencies recomputes at every render", () => { + const compute = vi.fn(() => ({})); + const Memo = () => R.useMemo(compute, undefined as unknown as []); + harness.render(Memo); + harness.render(Memo); + expect(compute).toHaveBeenCalledTimes(2); + }); + + it("useCallback keeps the first function while the dependencies are equal", () => { + let dep = "a"; + const Callback = () => R.useCallback(() => dep, [dep]); + const first = harness.render(Callback); + expect(harness.render(Callback)).toBe(first); + dep = "b"; + const second = harness.render(Callback); + expect(second).not.toBe(first); + expect(second()).toBe("b"); + }); +}); + +describe("hookHarness — useEffect", () => { + const log: string[] = []; + let dep = 1; + const Effects = () => { + const d = dep; + R.useEffect(() => { + log.push(`run ${d}`); + return () => { + log.push(`cleanup ${d}`); + }; + }, [d]); + R.useEffect(() => { + log.push("once"); + }, []); + }; + + beforeEach(() => { + log.length = 0; + dep = 1; + }); + + it("captures the effects during the render, without running them", () => { + harness.render(Effects); + expect(log).toEqual([]); + }); + + it("commit runs them all at the first commit, then only those whose dependencies changed", () => { + harness.render(Effects); + harness.commit(); + expect(log).toEqual(["run 1", "once"]); + + harness.render(Effects); + harness.commit(); + expect(log).toEqual(["run 1", "once"]); + + dep = 2; + harness.render(Effects); + harness.commit(); + // The cleanup of the previous run, with its own closure, before the new run. + expect(log).toEqual(["run 1", "once", "cleanup 1", "run 2"]); + }); + + it("commits a render once", () => { + const fn = vi.fn(); + const Always = () => R.useEffect(fn); + harness.render(Always); + harness.commit(); + harness.commit(); + expect(fn).toHaveBeenCalledTimes(1); + harness.render(Always); + harness.commit(); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("runs every cleanup before any effect of the same commit", () => { + const order: string[] = []; + let n = 0; + const Two = () => { + const k = n; + R.useEffect(() => { + order.push(`a${k}`); + return () => { + order.push(`~a${k}`); + }; + }, [k]); + R.useEffect(() => { + order.push(`b${k}`); + return () => { + order.push(`~b${k}`); + }; + }, [k]); + }; + harness.render(Two); + harness.commit(); + n = 1; + harness.render(Two); + harness.commit(); + expect(order).toEqual(["a0", "b0", "~a0", "~b0", "a1", "b1"]); + }); + + it("replayEffects plays StrictMode's double run: every cleanup, then every effect again", () => { + harness.render(Effects); + harness.commit(); + harness.replayEffects(); + expect(log).toEqual(["run 1", "once", "cleanup 1", "run 1", "once"]); + }); + + it("unmount runs every cleanup, once", () => { + harness.render(Effects); + harness.commit(); + harness.unmount(); + harness.unmount(); + expect(log).toEqual(["run 1", "once", "cleanup 1"]); + }); + + it("clear starts a fresh mount", () => { + harness.render(Effects); + harness.commit(); + harness.dispatch("x"); + harness.clear(); + expect(harness.slots).toEqual([]); + expect(harness.dispatch).not.toHaveBeenCalled(); + harness.render(Effects); + harness.commit(); + expect(log).toEqual(["run 1", "once", "run 1", "once"]); + }); +}); + +describe("hookHarness — hooks it does not simulate", () => { + it.each(["useContext", "useLayoutEffect", "useSyncExternalStore", "useId", "useTransition", "use"])( + "%s throws instead of reaching the real React", + (name) => { + const hook = (R as unknown as Record unknown>)[name]; + expect(hook).toBeTypeOf("function"); + expect(() => hook()).toThrow(`hookHarness: ${name} is not simulated`); + }, + ); + + it("on the default export too", () => { + const fake = R as unknown as { default: Record unknown> }; + expect(() => fake.default.useContext()).toThrow("hookHarness: useContext is not simulated"); + expect(fake.default.useState).toBe((R as unknown as Record).useState); + }); +}); diff --git a/src/test/hookHarness.ts b/src/test/hookHarness.ts new file mode 100644 index 0000000..58e6193 --- /dev/null +++ b/src/test/hookHarness.ts @@ -0,0 +1,265 @@ +// hookHarness — React's hooks with their semantics, for tests without jsdom (#409). +// +// The repository has no jsdom: tests call hooks and components as plain +// functions, under `vi.mock("react")`. A fake hook that drops React's +// semantics (an initializer called at every render, a memo recomputed every +// time) leaves one-line mutations green. This module is the one harness those +// tests share, so the semantics are written, and proved, once +// (hookHarness.test.ts). +// +// USE — one pattern, at the head of the test module, before the imports of +// the code under test (the factory of `vi.mock` is hoisted, hence the dynamic +// import inside it): +// +// vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig())); +// import { hookHarness } from "../test/hookHarness"; +// const harness = hookHarness(); +// +// then, per test: `harness.clear()` in `beforeEach` (a fresh mount), +// `harness.render(() => Component(props))` for one render of the mounted +// component, `harness.commit()` to run its effects. Every test module gets its +// own copy of this module, so the singleton is shared by the mock and the test +// of one file, never across files. Values the test changes between renders +// (a pathname, a context value) live in a `vi.hoisted` object of the test. +// +// SEMANTICS +// - Slots by call order, as React: `render()` resets the cursor, so a +// component must call its hooks in the same order at every render. A test +// may preset the slot of a `useState` or `useReducer` (its value) before the +// first render. +// - `useState`: initializer called once (at the first render), stable setter, +// functional updates applied to the latest value. A setter does NOT +// re-render: the test renders again when it wants to read the new state. +// - `useReducer`: the reducer IS applied (the slot holds the new state for the +// next render), its `dispatch` is stable, and every action is also recorded +// by `harness.dispatch`, a `vi.fn()` spy shared by all the reducers. +// - `useRef`: one object per slot, kept from render to render. +// - `useMemo` / `useCallback`: recomputed only when a dependency changed +// (`Object.is`, as React); without a dependency array, at every render. +// - `useEffect`: captured during the render, never run by it. `commit()` runs +// the effects of the last render whose dependencies changed since their last +// run (all of them at the first commit, every time without an array): first +// the cleanups of those effects, then the effects, in call order, as React. +// A render is committed once. `replayEffects()` plays StrictMode's double +// run at mount (every cleanup, then every effect again); `unmount()` runs +// every cleanup. +// - Any other hook of React (`useContext`, `useLayoutEffect`, +// `useSyncExternalStore`, `useId`, `use`…) THROWS: it would otherwise reach +// the real React outside a render ("Invalid hook call"), and a test would go +// red by accident rather than on its assertion. Mock the module that calls +// it, or cover the hook here with its own test. +// - Everything else (`isValidElement`, `Fragment`, `createElement`…) is the +// real React, on the named exports and on the default export. +// +// NOT SIMULATED: re-rendering (on a setter, a dispatch, a context change), +// children (a component in the returned tree is an element, never called), +// the DOM, concurrent features, the bail-out of a setter called with the same +// value, the render-phase update loop React runs for a dispatch during render +// (the next `render()` reads it). +// +// React is imported here only as a type: a value import, from inside the +// factory that builds the mock of "react", would load that very mock. +import type * as React from "react"; +import { vi, type Mock } from "vitest"; + +type Deps = readonly unknown[] | undefined; +type EffectFn = () => void | (() => void); + +const EFFECT = Symbol("hookHarness.effect"); + +interface EffectSlot { + [EFFECT]: true; + /** Dependencies at the last run, `null` before the first one. */ + ran: Deps | null; + cleanup: (() => void) | undefined; +} + +interface PendingEffect { + index: number; + fn: EffectFn; + deps: Deps; +} + +export interface HookHarness { + /** The slots of the mounted component, by call order; a test may preset a state slot before the first render. */ + slots: unknown[]; + /** Every action dispatched to any `useReducer` of the component, in order. */ + dispatch: Mock<(action: unknown) => void>; + /** One render of the mounted component: resets the cursor and the captured effects, then calls `fn`. */ + render(fn: () => T): T; + /** Starts a render by hand (cursor at 0, no captured effect), for a test that calls the component itself. */ + reset(): void; + /** Runs the effects of the last render whose dependencies changed, cleanups first; once per render. */ + commit(): void; + /** StrictMode at mount: every cleanup of the last render's effects, then every effect again. */ + replayEffects(): void; + /** Runs every cleanup, as React does when the component goes away. */ + unmount(): void; + /** A fresh mount: no slot, no effect, no recorded action. */ + clear(): void; +} + +type Internal = HookHarness & { + writers: Array<((arg: unknown) => void) | undefined>; + next(): number; + capture(effect: PendingEffect): void; +}; + +function depsChanged(previous: Deps | null, next: Deps): boolean { + if (previous === null || previous === undefined || next === undefined) return true; + if (previous.length !== next.length) return true; + return next.some((dep, k) => !Object.is(dep, previous[k])); +} + +const isEffectSlot = (slot: unknown): slot is EffectSlot => + typeof slot === "object" && slot !== null && EFFECT in slot; + +/** A harness of its own, for the harness's tests; the mock uses the shared one (`hookHarness()`). */ +export function createHookHarness(): HookHarness { + let cursor = 0; + let pending: PendingEffect[] = []; + /** A render is committed once, as React does. */ + let committed = false; + + const run = (effects: PendingEffect[]) => { + for (const { index } of effects) { + const slot = harness.slots[index] as EffectSlot; + const cleanup = slot.cleanup; + slot.cleanup = undefined; + cleanup?.(); + } + for (const { index, fn, deps } of effects) { + const slot = harness.slots[index] as EffectSlot; + slot.ran = deps; + const cleanup = fn(); + slot.cleanup = typeof cleanup === "function" ? cleanup : undefined; + } + }; + + const harness: Internal = { + slots: [], + writers: [], + dispatch: vi.fn(), + render(fn) { + harness.reset(); + return fn(); + }, + reset() { + cursor = 0; + pending = []; + committed = false; + }, + commit() { + if (committed) return; + committed = true; + run(pending.filter(({ index, deps }) => depsChanged((harness.slots[index] as EffectSlot).ran, deps))); + }, + replayEffects() { + run(pending); + }, + unmount() { + for (const slot of harness.slots) { + if (!isEffectSlot(slot)) continue; + const cleanup = slot.cleanup; + slot.cleanup = undefined; + cleanup?.(); + } + }, + clear() { + harness.slots = []; + harness.writers = []; + harness.dispatch.mockReset(); + harness.reset(); + }, + next: () => cursor++, + capture: (effect) => { + pending.push(effect); + }, + }; + return harness; +} + +const shared = createHookHarness(); + +/** The harness the mock of "react" writes to, shared with the test of the same module. */ +export function hookHarness(): HookHarness { + return shared; +} + +const COVERED = new Set(["useState", "useReducer", "useRef", "useMemo", "useCallback", "useEffect"]); + +/** + * The module that replaces "react": the real one, with the covered hooks + * backed by `harness` (the shared one by default) and every other hook + * throwing. + */ +export function fakeReact(actual: typeof React, harness: HookHarness = shared): typeof React { + const h = harness as Internal; + + /** Index of this hook's slot, filled by `init` at the first render only. */ + const slot = (init: () => unknown): number => { + const index = h.next(); + if (!(index in h.slots)) h.slots[index] = init(); + return index; + }; + + const memo = (compute: () => unknown, deps: Deps): unknown => { + const index = slot(() => ({ deps: null as Deps | null, value: undefined as unknown })); + const entry = h.slots[index] as { deps: Deps | null; value: unknown }; + if (depsChanged(entry.deps, deps)) { + entry.value = compute(); + entry.deps = deps; + } + return entry.value; + }; + + /** Stable setter or dispatch of a state slot, created at its first use. */ + const writer = (index: number, make: () => (arg: unknown) => void) => (h.writers[index] ??= make()); + + const covered = { + useState: (init: unknown) => { + const index = slot(() => (typeof init === "function" ? (init as () => unknown)() : init)); + const set = writer(index, () => (next: unknown) => { + h.slots[index] = + typeof next === "function" ? (next as (previous: unknown) => unknown)(h.slots[index]) : next; + }); + return [h.slots[index], set]; + }, + useReducer: ( + reducer: (state: unknown, action: unknown) => unknown, + initialArg: unknown, + init?: (arg: unknown) => unknown, + ) => { + const index = slot(() => (init ? init(initialArg) : initialArg)); + const dispatch = writer(index, () => (action: unknown) => { + h.dispatch(action); + h.slots[index] = reducer(h.slots[index], action); + }); + return [h.slots[index], dispatch]; + }, + useRef: (init: unknown) => h.slots[slot(() => ({ current: init }))], + useMemo: (compute: () => unknown, deps: Deps) => memo(compute, deps), + useCallback: (fn: unknown, deps: Deps) => memo(() => fn, deps), + useEffect: (fn: EffectFn, deps?: Deps) => { + const index = slot((): EffectSlot => ({ [EFFECT]: true, ran: null, cleanup: undefined })); + h.capture({ index, fn, deps }); + }, + }; + + const uncovered: Record = {}; + for (const key of Object.keys(actual)) { + if ((key === "use" || /^use[A-Z]/.test(key)) && !COVERED.has(key)) { + uncovered[key] = () => { + throw new Error( + `hookHarness: ${key} is not simulated; mock the module that calls it, or cover it in src/test/hookHarness.ts`, + ); + }; + } + } + + const fake: Record = { ...actual, ...uncovered, ...covered }; + // `import React from "react"` then `React.useState`: the default export too. + const original = (actual as { default?: unknown }).default; + if (original && typeof original === "object") fake.default = { ...original, ...uncovered, ...covered }; + return fake as unknown as typeof React; +}