Development and preview builds carry a debug menu, one strict debug deep link and a simulated clock
and network that reach every reader, all through one literal gate; production builds contain none of
it, and a checker proves the gate, the entry, the wiring, the registry and the simulations on the
app's own code. Verified on 2026-10-10 with Expo SDK 57 (expo 57.0.26), React Native 0.86.3,
expo-router 57.0.24 and knip 6.38.0, in Jest and with back-to-back expo export bundles (iOS);
re-verified on 2026-10-17 wired through the root slots in the library's reference app, with
development and production exports for iOS and Android passing check-variant-bundle; not yet
driven on a simulator or an emulator.
Rules that must hold
- One gate, one literal expression.
src/test-only.tsis the only way into test-only code:export const TEST_ONLY: TestOnlyApi | null = process.env.EXPO_PUBLIC_APP_VARIANT === 'production' ? null : (require('./test-only-entry') as TestOnlyApi);and nothing else runs there. Why: Metro folds only that literal comparison before it collectsrequire()calls; an imported constant, a helper,__DEV__orNODE_ENVships the code to the store or strips it from preview builds (app-variants-and-environment owns that rule). (gate-file,gate-form) TestOnlyApiistypeofthe entry, never hand-written.import type * as TestOnlyEntry from './test-only-entry'. Why: the gate casts therequire(), so a hand-written type is never compared with the exports (TEST_ONLY.x is not a functionin a preview build), and knip reported every entry export as unused with one (measured). (api-derived)- The entry is a manifest with one unique sentinel. Only
export { ... } fromlines andTEST_BUILD_SENTINEL = '<APP>_TEST_BUILD_ONLY', a string no other app file contains. Why: the bundle and build checks prove the strip by that string, and a copy in shipped code fails every production proof. (entry-manifest,sentinel) - Every export is read through
TEST_ONLY, from declared test-only folders. Each member is used asTEST_ONLY?.memberby the startup, the root stack or a debug route, and each folder the entry re-exports from is incontracts/architecture.jsontestOnlyFolders. Why: an unread export is forgotten wiring, and an undeclared folder lets a static import ship it unnoticed. (entry-reachable,test-only-folders) - The startup wraps the real ports once and hands only the wrapped services on.
TEST_ONLY?.createTestHooks({ services: device, ... })in the app registry'sprepareRootKits(src/services/create-root-kits.ts), which AppRoot'sstartAppcalls first, before the data layer opens, swapsclockand, when the services hold it,connectivityinside the services; every reader getstestHooks?.services ?? device. Why: one wrapper reaches every reader (due dates, repositories, TanStack Query throughservices.connectivity, banners, ads); a simulated port handed out beside the services leaves those readers on the real one. (hooks-wired, and the createTestHooks test) - The simulated clock changes
today()only. It reads the simulated day on every call;nowMs()andmsUntilNextLocalDay()call the real clock every time. Why: timers, timestamps and the next-day countdown must never jump, anduseToday()still turns at the real midnight. (simulated-clock) - Simulated offline is a full
ConnectivityPorton the reachability hub.nonewhile simulating, the real reachability otherwise,isOnlinederived from it, unchanged reports dropped,stop()releases the real adapter. Why: every consumer then follows the switch exactly as it follows a real outage. (simulated-connectivity) - One debug deep link: an Expo Router route, one registry, one contract.
<scheme>://debug/setup?...is the routesrc/app/(debug)/debug/setup.tsx, which rendersTEST_ONLY?.DebugSetupRootwithuseLocalSearchParams()and redirects home in production (the canonical AppStack keeps the(debug)group behindStack.Protecteduntil the debug kit opens it);DEBUG_LINK_PARAMSandcontracts/debug-links.jsonlist the same parameters and values. Why: Expo Router delivers cold-start and running links once the navigator is ready, and flows read the contract, not the TypeScript. (debug-registry,link-route) - A bad link refuses everything and says so. An unknown, repeated or bad parameter returns a
typed
debug-link-*failure, applies nothing, is logged and shown asdebug.setup-error. Why: a typo that is ignored makes a flow test the wrong state and pass. (registry-strict) - Parse, plan, apply, replace. The switches first, then saved settings (through the app's
DebugSettingsTarget; refused when there is none), thenrouter.replaceto the screen; a direction restart ends the run. Every step sets a value, so a link applied twice changes nothing more. Why: reloads re-deliver the launch link and React's development effects run twice. - The switches are one store, persisted apart from the app's data.
src/stores/debug-overrides/(reducer, cases, persist before publish) inexpo-sqlite/kv-storeunderdebug.overrides; the link and the menu write it through the same actions. Why: a reload or a kill keeps them, Maestro'sclearStatewipes them, and the menu always shows what a link set. - Motion and probes follow the same switches, through the app's own seams. The debug kit
(
TEST_ONLY?.createDebugKit, registered in the app's root-kit registry) fills the root's motion slot:TestHooksRoottakesMotionConfig's place and passespreference="on"whilemotion=still; canvas probes render only whileprobe=1. Why: screenshots and E2E rest on the one reduce-motion path, never a second switch, and screenshots never show a probe. - The strip is proven on the artifact. After any change to the gate, the entry or variants,
export development and production back to back without
--clearand run app-variants-and-environment'scheck-variant-bundleon both. Why: only the bundle shows that the gate, the cache key and the variables worked together.
Workflow
- Read references/gate-and-entry.md (always), then for the task: the link references/debug-deep-link.md; the clock, the network or the switches references/simulated-clock-and-connectivity.md; wiring, the menu or another skill's hook references/wiring-and-menu.md; a checker line references/checker.md.
- Check the prerequisites: the composition root and clock (architecture-and-boundaries), the
ConnectivityPortand its hub (networking-and-server-state),src/core/result.ts,date-key.ts,languages.ts, the settings store if links change settings, and app-variants-and-environment's variants and Metro cache key. - Copy the templates to the paths their first lines name:
templates/src/test-only.tsandtest-only-entry.ts(renameTASKSin the sentinel),templates/src/services/test-hooks/,templates/src/services/debug-storage/,templates/src/stores/debug-overrides/,templates/src/features/debug/(the debug kitdebug-root-kit.tsxincluded),templates/src/app/(debug)/, thedebug.*keys oftemplates/src/i18n/catalogs/, andtemplates/contracts/debug-links.jsonwith its schema. The test-hook kit (the gate, the hooks, the switches store, the storage,TestHooksRootand the debug kit) is canonical in the library (its shared templates): the copies here are synced, never edited here. - Declare the folders: add
src/features/debug/,src/services/test-hooks/,src/services/debug-storage/andsrc/stores/debug-overrides/tocontracts/architecture.jsontestOnlyFolders; with a second persisting store, add state-management-zustand'scontracts/stores.json. - Wire it through the root slots (references/wiring-and-menu.md):
two edits to the app's root-kit registry, never to the canonical root files
(examples/create-root-kits.ts):
prepareRootKitsmakes the hooks and returns their services, andcreateRootKitsregistersTEST_ONLY?.createDebugKit(hooks)(the motion slot and the(debug)group); a settings target (examples/settings-target.ts) once the app saves language and theme. - Change a parameter or a switch test-first: the registry test, the plan test, the reducer cases,
then the code, then
contracts/debug-links.json. - Run the tests:
npx jest src/features/debug src/services/test-hooks src/services/debug-storage src/stores/debug-overrides --ci --maxWorkers=2andnpm run typecheck,npx eslint . --max-warnings 0,npx knip. - Prove the strip (rule 13): the two exports, then app-variants-and-environment's
check-variant-bundleon each;rm -rf dist/variant-proofafter. - Run the checker:
node ${CLAUDE_SKILL_DIR}/scripts/check-test-hooks.mjs .; fix eachFAILline (file, rule, fix) and rerun untilRESULT: PASS.
Definition of done
-
src/test-only.tsis the template's expression with the derivedTestOnlyApi; nothing elserequire()s code. -
src/test-only-entry.tsholds re-exports and one unique<APP>_TEST_BUILD_ONLYsentinel; every export is read throughTEST_ONLY?.outside the test-only folders. - The registry's
prepareRootKitsreturnstestHooks?.services ?? deviceandcreateRootKitsregistersTEST_ONLY?.createDebugKit(TestHooksRoot in the motion slot, the(debug)group opened); the root files are the canonical ones, unedited; the group layout is a plainSlotand both leaf routes redirect in production. -
contracts/debug-links.jsonequals the registry; a bad link showsdebug.setup-error. - The test-only folders are in
testOnlyFolders; tests, typecheck, ESLint and knip pass. - A development and a production export made back to back pass app-variants-and-environment's
check-variant-bundle(the sentinel in development, absent in production). -
node ${CLAUDE_SKILL_DIR}/scripts/check-test-hooks.mjs .printsRESULT: PASS. Allowed:SKIP contracts/architecture.json [test-only-folders] not yet created (architecture-and-boundaries creates it),NOT APPLICABLE: the app has no test-only code: ...before this skill is adopted, and thenote: link-routeline on React Navigation.
Anti-patterns
const IS_TEST_BUILD = ...in a config file, thenIS_TEST_BUILD ? require(...) : null. Metro keeps the module in production. The literal comparison, in the gate.if (__DEV__) showDebugMenu(). Strips the menu from the preview builds testers use, and keeps nothing out of the bundle. ReadTEST_ONLY?.DebugMenuRoot.- A debug route that imports the debug screen. Expo Router bundles every route, so the screen and
its fakes ship. Render it through
TEST_ONLYand redirect when it is undefined. - A hand-written
TestOnlyApiwith@publictags or knip ignores to quiet it. Derive the type. - A
Linking.addEventListener('url')handler for debug links. It misses the launch link and duplicates the router; let Expo Router open the route. - A parser that skips unknown parameters, or
offline=true. Refuse the link; booleans are0/1. setDate()that replacesnowMs(), or a "simulate offline" that patches one screen. Wrap the port once, in the startup.- Debug switches in React state or in the app's preferences. They vanish on the direction reload or leak into user data. The test-only kv store, through the switches store.
- A second gate for another skill's hooks. Re-export them from the one entry.
- A simulated connectivity port handed out next to the services.
services.connectivitystays the device adapter and TanStack Query never hears the switch; letcreateTestHooksswap it inside. xcrun simctl openurlfor a debug link, oradb -s <serial> shell am start -dwith the URL quoted once. iOS waits on an "Open in …?" alert nobody accepts; the device shell cuts the URL at&. Use the Maestro subflow with the guarded accept step (references/debug-deep-link.md).- A
<Redirect>in the(debug)group layout. navigation-and-routing'sflow-by-guardfails it, andStack.Protectedplus the leaf routes' redirects already keep production out.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
references/gate-and-entry.md |
Owners, the gate, the derived type (knip measurement), the manifest, the sentinel, test-only folders, the measured strip proof, pitfalls | Workflow step 1, always |
references/debug-deep-link.md |
The URL, Expo Router delivery on iOS and Android, the link screen's order, strict parsing, idempotence, tool commands, adding a parameter | Workflow steps 1 and 6 |
references/simulated-clock-and-connectivity.md |
createTestHooks, the clock and network wrappers, the switches store, the kv persistence, tests, pitfalls |
Workflow steps 1 and 6 |
references/wiring-and-menu.md |
The startup order, the four edits, the debug routes, the menu, other skills' hooks, what is left out | Workflow steps 4 and 5 |
references/checker.md |
Every rule, the SKIP, NOT APPLICABLE and note lines, configuration keys, multi-package, the self-test | Workflow step 9, a FAIL line |
templates/src/test-only.ts |
The gate | Workflow step 3 |
templates/src/test-only-entry.ts |
The entry: re-exports and the sentinel | Workflow step 3 |
templates/src/services/test-hooks/ |
createTestHooks, the simulated clock and connectivity, DebugSettingsTarget (+ tests) |
Workflow step 3 |
templates/src/services/debug-storage/ |
The storage port, the kv-store adapter and the fake (+ tests) | Workflow step 3 |
templates/src/stores/debug-overrides/ |
The switches: codec, reducer, cases, store, selectors (+ tests) | Workflow steps 3 and 6 |
templates/src/features/debug/ |
The registry and plan, the link screen and the menu (model, view, root), TestHooksRoot and the debug kit debug-root-kit.tsx (canonical, synced), the model, gate and route tests |
Workflow steps 3 and 6 |
templates/src/app/(debug)/ |
The group layout, /debug and /debug/setup routes |
Workflow step 3 |
templates/src/i18n/catalogs/en.json |
The debug.* keys in the source language (merged into the app's catalog) |
Workflow step 3 |
templates/src/i18n/catalogs/ar.json |
The same keys in the RTL example locale | Workflow step 3 |
templates/contracts/debug-links.json |
The link contract (mirrors the registry; a synced copy of the canonical file other skills' checkers read, edited only at its canonical home) | Workflow steps 3 and 6 |
templates/contracts/debug-links.schema.json |
Its JSON schema (synced copy) | Workflow step 3 |
examples/create-root-kits.ts |
The app's root-kit registry with the test build's lines: prepareRootKits (the hooks, their services) and the debug kit (also the self-test's base) |
Workflow step 5 |
examples/settings-target.ts |
A DebugSettingsTarget over the settings store and the direction restart |
Workflow step 5 |
examples/debug-setup-flow.yaml |
A Maestro subflow that opens the link, accepts iOS's "Open in …?" alert (guarded) and proves the state | Writing flows (hand to e2e-maestro) |
scripts/check-test-hooks.mjs |
The checker (13 rules) | Workflow step 9 |
scripts/lib/hook-probes.mjs |
Its behaviour probes (registry, clock, connectivity) | Never by hand |
scripts/selftest.mjs |
Proves the checker against tests/fixtures/ |
After changing the checker |
scripts/check-lib.mjs |
Synced checker library (do not edit here) | Never by hand |
scripts/lib/app-modules.mjs |
Synced: loads the app's pure TypeScript for the probes | Never by hand |
scripts/lib/source-scan.mjs |
Synced: import resolution and test-file detection | Never by hand |
tests/fixtures/ |
Overlay cases: good, one bad per rule, pass and error cases | After changing the checker |
tests/bases/ |
The minimal app the cases start from (with synced canonical files) | After changing the checker |
assets/shared.json |
The synced-file manifest | When a shared file is added |
Related skills
app-startup-and-bootstrap- the root slots the debug kit fills (prepareRootKits,motion,opensDebugRoutes).app-variants-and-environment- the literal-comparison rule, the Metro cache key and the bundle proof.architecture-and-boundaries- the import side of the gate,testOnlyFolders, the composition root.navigation-and-routing-Stack.Protectedfor the debug group and the startup skeleton.e2e-maestro- flows that open the debug link and wait on testIDs.ios-simulator-build- the sentinel in a built.appand the ready marker.dates-streaks-and-statistics-useToday()and what a simulated day must keep real.networking-and-server-state- theConnectivityPortand its hub.state-management-zustand- the store rules the switches follow.motion-and-animation,skia-canvas-rendering,audio-and-haptics-feedback- motion held still, canvas probes, recorded feedback.