test: shared React hook harness for the jsdom-less tests (#409) #434
No reviewers
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/Simpl-Resultat#434
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-409-hook-harness"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Generated autonomously by /autopilot run of 2026-10-12
Issue #409, stacked on PR #433 (#408). Base:
issue-408-db-strict-lock.What
A shared React hook harness,
src/test/hookHarness.ts, for the tests that call hooks and components as plain functions (no jsdom), and the migration of six test files to it. No production code changes, no CHANGELOG entry (tests only).createHookHarness()andfakeReact(actual, harness = shared), singleton read byhookHarness(). One pattern at the head of each migrated file:vi.mock("react", async (orig) => (await import("<path>/test/hookHarness")).fakeReact(await orig()));src/test/hookHarness.test.ts(28 tests):useStateinit once + stable setter + functional updates, no auto re-render;useReducerapplies the reducer, stable dispatch, every action recorded by theharness.dispatchspy;useRefkept;useMemo/useCallbackrecomputed only on a changed dep (Object.is);useEffectcaptured, run only bycommit()when a dep changed, cleanups first, a render committed once;replayEffects()(StrictMode at mount) andunmount(); every uncovered hook (useContext,useLayoutEffect,useSyncExternalStore,useId,useTransition,use) throws, on the named and the default export. The module imports React only asimport type, checked on its source.Files
src/test/hookHarness.ts,src/test/hookHarness.test.ts(new)src/hooks/useOnboardingChecklist.wiring.test.ts: on the harness; effects driven bycommit()(the route dependency is now proved by behaviour); 3 new tests for RM3, RM4, RM12src/pages/settings/UsersSettingsPage.test.tsx: effects committed; « never reads the preference itself, neither while rendering nor in an effect »src/components/budget/BudgetTable.wiring.test.ts,src/pages/BudgetPage.wiring.test.ts,src/components/layout/AppShell.slot.test.tsx,src/components/onboarding/OnboardingStepCard.test.tsx: local slot copies removed. i18n mocks left as they are (#389).docs/architecture.md: new## Tests (vitest sans jsdom)section, just before CI/CD.Out of scope (decision of 2026-10-10):
useImportWizard.mount.test.ts,useImportWizard.executeImport.test.ts.Proof: mutation pass on production code
One mutation at a time,
npx vitest run <file>, restored bygit checkout. Every red is on anAssertionError, none on « Invalid hook call ».useOnboardingChecklist.ts: loader out ofuseState(const refresh = createOnboardingLoader(...))state.skippedremoved from the deps ofchecklistmoderemoved from the deps of the shared value'none'kept)UsersSettingsPage.tsx:useEffect(() => { void getPreference(...) }, [])getPreferencecalled 1 time)BudgetTable.tsx:targetKindremoved from the report effect's depsBudgetPage.tsx:useStateofgridTargetreplaced by a local variableAppShell.tsx:useStateoflistOpenreplaced by a local variableOnboardingStepCard.tsx:location.keyremoved from the effect's depsOnboardingStepCard.tsx: thefocusedForguard removedhookHarness.ts:import type * as Reactturned into a value importTest count: base 115 files / 1978 tests, this branch 116 files / 2009 tests (+28 harness, +3 onboarding).
npm run buildandnpm testgreen in the worktree.Decisions (autopilot)
fakeReact(actual, harness = shared)rather thanfakeReact(harness, actual): the planned patternfakeReact(await orig())takes one argument, the harness is a module singleton read byhookHarness(); the optional second argument lets the harness's own tests usecreateHookHarness().vi.hoistedctxobject; the shared harness carries React's state only.harness.slots[0] = state(mountWith, itsuseReduceris its first React hook), as the oldharness.state = …did.commit()once per render, cleanups before effects;replayEffects()for StrictMode,unmount()for cleanups; default export patched too.For the reviewer
mountWithrelies on the reducer being the provider's first React hook; a reorder would make the preset land on another slot (the tests would then fail on their status/next-step assertions, not silently).harness.dispatchrecords actions, the reducer is applied by the per-slot dispatch the component receives:mockReset()on the spy no longer silences the reducer.🤖 Generated with Claude Code
Self-review (autopilot worker, not independent) — verdict: APPROVE
Diff read in full against
issue-408-db-strict-lock(9 files, +853/−325).src/test/hookHarness.tswithcreateHookHarness()andfakeReact; single pattern at the head of the module and in the 6 migrated files; harness read throughhookHarness().import type, checked byhookHarness.test.tson the source (mutation H1 red).useReducerapplies the reducer and records every action inharness.dispatch; the existingharness.dispatch.mock.calls/invocationCallOrderassertions are unchanged and green.commit()with cleanups first, uncovered hooks throw (6 hooks + default export tested).location.keydep) was green on the base.useState/useRefslot copy left in the migrated files (grep).npm test116 files / 2009 tests (base 115 / 1978);npm run buildgreen.docs/architecture.md:## Tests (vitest sans jsdom)created just before CI/CD.useImportWizard.mount/executeImportuntouched (out of scope); i18n mocks untouched for #389.Points for the independent review:
mountWithpresets slot 0 (the provider'suseReducer), andfakeReact's argument order differs from the analysis text (the planned one-argument pattern wins, see decisions in the PR body).CI lue par l’orchestrateur de l’autopilot (2026-10-12) sur la tête
79ba1ed:PR Check — Frontendrun 530 vert.RM12 relancée par l’orchestrateur sur la version finale du harnais (le worker avait réécrit
hookHarness.tsaprès uncheckoutaccidentel) :moderetiré des dépendances de la valeur partagée → 1 test rouge sur uneAssertionError(« a change of mode alone reaches the consumers »), code 1, fichier restauré.Revue adversariale — PR #434 (#409), tête
79ba1ed, contreissue-408-db-strict-lockVerdict : APPROVE
Le harnais partagé tient ses promesses : la sémantique écrite en tête de
hookHarness.tsest celle que les tests prouvent, les six fichiers migrés gardent leurs assertions (plusieurs se renforcent : la dépendance de route est prouvée par comportement, le reducer est réellement appliqué,S1rougit enfin), et aucun code de production n'est touché.Ce que le relecteur a exécuté
Worktree détaché sur
79ba1ed,npm cipropre :npx vitest run: 116 fichiers, 2009 tests verts ;tsc --noEmitvert. CI de la tête :PR Check — Frontendensuccess.git checkout), sur les fichiers du harnais + les six fichiers migrés (69 tests) :useOnboardingChecklist.ts:pathnameajouté aux deps du suiveur (followDataChanges)commit()joue tous les effets sans comparer les depsuseCallbackignore ses depsuseReducern'applique plus le reducercommittedretiréedefaultnon patchéprevious.length !== next.lengthdedepsChangedretiréeuseMemorecalcule à chaque renduSuggestions (non bloquantes)
useOnboardingChecklist.wiring.test.ts, test « re-reads 150 ms after a burst… » : le commentaire dit « A route change re-runs the read, not the follower: one subscription only », mais l'ancienne assertionexpect(follow.deps).not.toContain(pathname)a disparu et rien ne la remplace : avecpathnamedans les deps du suiveur, le harnais nettoie puis réabonne, il reste un seul abonnement, le test reste vert. Effet réel de la mutation : un changement de route annule une relecture différée en cours. Épingle possible par comportement :notifyDataChanged(), changement de route à t+100 ms,advanceTimersByTime(150)→ attendre deuxLOAD_START(route + relecture différée) ; la mutation en donne un seul. Sinon, retirer l'affirmation du commentaire.depsChangedn'est épinglée par aucun test. Cas sans usage réel (React avertit sur un tableau de taille variable) ; un test d'une ligne ou le retrait de la branche.dispatchd'unuseReducercapture le reducer du premier rendu (writercréé une fois), alors que React applique le reducer courant. Sans effet sur les six fichiers (reducers de module), mais à ajouter à la liste « NOT SIMULATED » de l'en-tête et dedocs/architecture.md.Points vérifiés
.only/.skip, aucun code de production modifié, pas d'entrée CHANGELOG (tests seuls, conforme à la règle).isolatepar défaut, le singleton est bien par fichier comme l'annonce l'en-tête.test:,docs(architecture):).ref_action: none. Seul le message du commit86fd496porteResolves #409: l'issue se fermera quand ce commit atteindramain(merge ff de la pile), pas au merge de cette PR dansissue-408-db-strict-lock. À vérifier après le merge de la pile.Suivis de cette revue appliqués en tête de pile, PR #436, commit
a1c31a1(détail dans le commentaire 23535 de la PR #436, non relu).