Skip to content
Back to skills

Debug Menu And Test Hooks

BSecurity

Gates test-only code behind one literal TEST_ONLY switch: debug menu, debug link, simulated clock and offline. Use when adding a debug screen, a test hook, a debug link or a fake date for E2E or QA. Not for E2E flows (e2e-maestro).

  • 3 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 18, 2026
developmenttypescriptgoshellsqlreactnodeexpressapi

Works with

  • api

Security analysis

B85/100
  • highPerforms destructive filesystem operations

Pro scans all 19 files and shows the line behind each finding

Scanned October 18, 2026

npx -y skills add zakariaf/ReactNative-Skills --skill debug-menu-and-test-hooks --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Debug Menu And Test Hooks?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Debug Menu And Test Hooks
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/zakariaf-debug-menu-and-test-hooks/badge)](https://www.skillsdirectory.com/skills/zakariaf-debug-menu-and-test-hooks)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: debug-menu-and-test-hooks
description: "Gates test-only code behind one literal TEST_ONLY switch: debug menu, debug link, simulated clock and offline. Use when adding a debug screen, a test hook, a debug link or a fake date for E2E or QA. Not for E2E flows (e2e-maestro)."
---

# Debug menu and test hooks

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

1. **One gate, one literal expression.** `src/test-only.ts` is 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 collects `require()` calls; an imported constant, a
   helper, `__DEV__` or `NODE_ENV` ships the code to the store or strips it from preview builds
   (app-variants-and-environment owns that rule). (`gate-file`, `gate-form`)
2. **`TestOnlyApi` is `typeof` the entry, never hand-written.** `import type * as TestOnlyEntry from
   './test-only-entry'`. Why: the gate casts the `require()`, so a hand-written type is never compared
   with the exports (`TEST_ONLY.x is not a function` in a preview build), and knip reported every
   entry export as unused with one (measured). (`api-derived`)
3. **The entry is a manifest with one unique sentinel.** Only `export { ... } from` lines and
   `TEST_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`)
4. **Every export is read through `TEST_ONLY`, from declared test-only folders.** Each member is used
   as `TEST_ONLY?.member` by the startup, the root stack or a debug route, and each folder the entry
   re-exports from is in `contracts/architecture.json` `testOnlyFolders`. Why: an unread export is
   forgotten wiring, and an undeclared folder lets a static import ship it unnoticed.
   (`entry-reachable`, `test-only-folders`)
5. **The startup wraps the real ports once and hands only the wrapped services on.**
   `TEST_ONLY?.createTestHooks({ services: device, ... })` in the app registry's `prepareRootKits`
   (`src/services/create-root-kits.ts`), which AppRoot's `startApp` calls first, before the data layer
   opens, swaps `clock` and, when the services hold it, `connectivity` inside the services; every
   reader gets `testHooks?.services ?? device`. Why: one wrapper reaches every reader (due dates,
   repositories, TanStack Query through `services.connectivity`, banners, ads); a simulated port
   handed out beside the services leaves those readers on the real one. (`hooks-wired`, and the
   createTestHooks test)
6. **The simulated clock changes `today()` only.** It reads the simulated day on every call; `nowMs()`
   and `msUntilNextLocalDay()` call the real clock every time. Why: timers, timestamps and the
   next-day countdown must never jump, and `useToday()` still turns at the real midnight.
   (`simulated-clock`)
7. **Simulated offline is a full `ConnectivityPort` on the reachability hub.** `none` while
   simulating, the real reachability otherwise, `isOnline` derived 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`)
8. **One debug deep link: an Expo Router route, one registry, one contract.**
   `<scheme>://debug/setup?...` is the route `src/app/(debug)/debug/setup.tsx`, which renders
   `TEST_ONLY?.DebugSetupRoot` with `useLocalSearchParams()` and redirects home in production (the
   canonical AppStack keeps the `(debug)` group behind `Stack.Protected` until the debug kit opens it);
   `DEBUG_LINK_PARAMS` and `contracts/debug-links.json` list 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`)
9. **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 as `debug.setup-error`. Why: a
   typo that is ignored makes a flow test the wrong state and pass. (`registry-strict`)
10. **Parse, plan, apply, replace.** The switches first, then saved settings (through the app's
    `DebugSettingsTarget`; refused when there is none), then `router.replace` to 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.
11. **The switches are one store, persisted apart from the app's data.** `src/stores/debug-overrides/`
    (reducer, cases, persist before publish) in `expo-sqlite/kv-store` under `debug.overrides`; the
    link and the menu write it through the same actions. Why: a reload or a kill keeps them, Maestro's
    `clearState` wipes them, and the menu always shows what a link set.
12. **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: `TestHooksRoot` takes `MotionConfig`'s place and passes `preference="on"` while
    `motion=still`; canvas probes
    render only while `probe=1`. Why: screenshots and E2E rest on the one reduce-motion path, never a
    second switch, and screenshots never show a probe.
13. **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 `--clear` and run
    app-variants-and-environment's `check-variant-bundle` on both. Why: only the bundle shows that the
    gate, the cache key and the variables worked together.

## Workflow

1. **Read** [references/gate-and-entry.md](references/gate-and-entry.md) (always), then for the task:
   the link [references/debug-deep-link.md](references/debug-deep-link.md); the clock, the network or
   the switches [references/simulated-clock-and-connectivity.md](references/simulated-clock-and-connectivity.md);
   wiring, the menu or another skill's hook [references/wiring-and-menu.md](references/wiring-and-menu.md);
   a checker line [references/checker.md](references/checker.md).
2. **Check the prerequisites**: the composition root and clock (architecture-and-boundaries), the
   `ConnectivityPort` and 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.
3. **Copy the templates** to the paths their first lines name: `templates/src/test-only.ts` and
   `test-only-entry.ts` (rename `TASKS` in the sentinel), `templates/src/services/test-hooks/`,
   `templates/src/services/debug-storage/`, `templates/src/stores/debug-overrides/`,
   `templates/src/features/debug/` (the debug kit `debug-root-kit.tsx` included),
   `templates/src/app/(debug)/`, the `debug.*` keys of `templates/src/i18n/catalogs/`, and
   `templates/contracts/debug-links.json` with its schema. The test-hook kit (the gate, the hooks,
   the switches store, the storage, `TestHooksRoot` and the debug kit) is canonical in the library
   (its shared templates): the copies here are synced, never edited here.
4. **Declare the folders**: add `src/features/debug/`, `src/services/test-hooks/`,
   `src/services/debug-storage/` and `src/stores/debug-overrides/` to `contracts/architecture.json`
   `testOnlyFolders`; with a second persisting store, add state-management-zustand's
   `contracts/stores.json`.
5. **Wire it through the root slots** ([references/wiring-and-menu.md](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](examples/create-root-kits.ts)): `prepareRootKits` makes the hooks
   and returns their services, and `createRootKits` registers `TEST_ONLY?.createDebugKit(hooks)` (the
   motion slot and the `(debug)` group); a settings target
   ([examples/settings-target.ts](examples/settings-target.ts)) once the app saves language and theme.
6. **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`.
7. **Run the tests**: `npx jest src/features/debug src/services/test-hooks src/services/debug-storage src/stores/debug-overrides --ci --maxWorkers=2`
   and `npm run typecheck`, `npx eslint . --max-warnings 0`, `npx knip`.
8. **Prove the strip** (rule 13): the two exports, then app-variants-and-environment's
   `check-variant-bundle` on each; `rm -rf dist/variant-proof` after.
9. **Run the checker**: `node ${CLAUDE_SKILL_DIR}/scripts/check-test-hooks.mjs .`; fix each `FAIL`
   line (file, rule, fix) and rerun until `RESULT: PASS`.

## Definition of done

- [ ] `src/test-only.ts` is the template's expression with the derived `TestOnlyApi`; nothing else
      `require()`s code.
- [ ] `src/test-only-entry.ts` holds re-exports and one unique `<APP>_TEST_BUILD_ONLY` sentinel; every
      export is read through `TEST_ONLY?.` outside the test-only folders.
- [ ] The registry's `prepareRootKits` returns `testHooks?.services ?? device` and `createRootKits`
      registers `TEST_ONLY?.createDebugKit` (TestHooksRoot in the motion slot, the `(debug)` group
      opened); the root files are the canonical ones, unedited; the group layout is a plain `Slot`
      and both leaf routes redirect in production.
- [ ] `contracts/debug-links.json` equals the registry; a bad link shows `debug.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 .` prints `RESULT: 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 the
      `note: link-route` line on React Navigation.

## Anti-patterns

- **`const IS_TEST_BUILD = ...` in a config file, then `IS_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. Read `TEST_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_ONLY` and redirect when it is undefined.
- **A hand-written `TestOnlyApi` with `@public` tags 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 are `0`/`1`.
- **`setDate()` that replaces `nowMs()`**, 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.connectivity` stays
  the device adapter and TanStack Query never hears the switch; let `createTestHooks` swap it inside.
- **`xcrun simctl openurl` for a debug link, or `adb -s <serial> shell am start -d` with 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's `flow-by-guard` fails it,
  and `Stack.Protected` plus 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.Protected` for 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 `.app` and the ready marker.
- `dates-streaks-and-statistics` - `useToday()` and what a simulated day must keep real.
- `networking-and-server-state` - the `ConnectivityPort` and 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.

Files in this skill

  • SKILL.md17.5 KB
  • assets/shared.json2 KB
  • examples/create-root-kits.ts2.8 KB
  • examples/debug-setup-flow.yaml1.5 KB
  • examples/settings-target.ts2.3 KB
  • references/checker.md6.6 KB
  • references/debug-deep-link.md12.6 KB
  • references/gate-and-entry.md11 KB
  • references/simulated-clock-and-connectivity.md8.7 KB
  • references/wiring-and-menu.md14 KB
  • scripts/check-test-hooks.mjs28.5 KB
  • scripts/lib/app-modules.mjs8.7 KB
  • scripts/lib/hook-probes.mjs7.4 KB
  • scripts/lib/source-scan.mjs35.1 KB
  • scripts/selftest.mjs663 B
  • templates/contracts/debug-links.json1.1 KB
  • templates/contracts/debug-links.schema.json2 KB
  • templates/src/app/(debug)/_layout.tsx617 B
  • templates/src/app/(debug)/debug/index.tsx438 B

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.