# Test App

> Manually test/QA the Liftosaur app on the iOS simulator — drive the real UI, inspect live app state, and set up test data. Use when asked to test, QA, stress-test, verify a change works, reproduce a bug, or hunt for bugs in the running app.

- Skill: `astashov/test-app` (Agent Skill)
- Install (CLI): `npx skillmds@latest add astashov/test-app`
- Raw SKILL.md: https://api.skillmd.com/api/skills/astashov/test-app/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: astashov (https://skillmd.com/u/astashov)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/astashov/test-app

---


# Test the app on the simulator

Drive the running app on the iOS simulator, and for anything non-trivial **verify against the
live app state**, not just the screen. Many bugs are UI/data desyncs the screenshot won't show.

Three tools do the work:
- **`ios-simulator` MCP** — drive the UI (the app is bare RN, so `testID`s are queryable).
- **`scripts/dump-rn-state.js`** — read the live Redux state (`globalThis.state`).
- **`liftosaur-local` MCP** — set up the environment (programs, exercises, history, settings)
  on the same account the simulator is signed into.

Focus: $ARGUMENTS

## Auth preflight (throwaway test account)

The simulator app and the `liftosaur-local` MCP must be on the SAME account. The MCP's API key
is static (in `~/.claude.json` → this project's `mcpServers.liftosaur-local.headers`), and
`debugLogin` rebinds that key server-side to a freshly created account — one call aligns both.

1. Get the key:
   ```bash
   python3 -c "import json,os;c=json.load(open(os.path.expanduser('~/.claude.json')));print(c['projects']['/Users/anton/projects/liftosaur']['mcpServers']['liftosaur-local']['headers']['Authorization'].split()[-1])"
   ```
2. Check the current account: `node scripts/dump-rn-state.js --eval "globalThis.state.user"`.
   If it's already the `test_` account you're mid-scenario with, skip step 3 — each `debugLogin`
   call starts a NEW empty account.
3. Create a fresh throwaway account (empty storage, active subscription — premium features work)
   and rebind the MCP key to it:
   ```bash
   node scripts/dump-rn-state.js --eval "globalThis.debugLogin('<key>')"
   # → {"userId":"test_xxxxxxxx","email":"test_xxxxxxxx@test.liftosaur.com","apiKeyBound":true}
   ```
4. Verify: `list_programs` via MCP should reflect the same empty account the sim now shows.

Requires the local dev server (`npm run start:server`) — the sign-in bypass and key-rebind
endpoint are `Utils_isLocal()`-gated, and `globalThis.debugLogin` exists only in `__DEV__`
builds. This signs the simulator out of whatever account it was on (that account's local data
stays on-device; a normal sign-in restores it) — leave the sim on the test account when done
and tell the user which account you left it on.

## The core loop

1. **Set up** the scenario via `liftosaur-local` MCP (or by driving the UI).
2. **Act** in the UI via `ios-simulator` MCP — target by `testID`, never pixel-guess.
3. **Verify** with `dump-rn-state.js`: does the live state match what the screen shows and what
   you intended? A mismatch between control ↔ state ↔ intent is a bug.
4. **Reset** between cases (in-app undo/discard, or re-seed via MCP) so each test starts clean.
   Nuclear option: `globalThis.debugLogin('<key>')` again — brand-new empty account, MCP follows.

## Tools

### ios-simulator MCP (drive the UI)
- `get_booted_sim_id` once (bundle id `com.liftosaur.www`).
- `ui_find_element(["<testID>"])` → tap the returned **frame center**. `testID`s surface as
  `AXUniqueId`. Prefer semantic targeting over coordinates.
- `ui_view` — cheap screenshot for a visual/state check.
- `ui_describe_all` — full a11y tree; use sparingly, it can exceed the token limit. If it's
  dumped to a file, pull the ids out:
  `python3 -c "import re;print('\n'.join(dict.fromkeys(re.findall(r'\"AXUniqueId\":\"([^\"]+)\"',open('FILE').read()))))"`
- `ui_type` after tapping a field; `ui_swipe` to scroll; `launch_app(..., terminate_running:true)`
  to relaunch. See main `CLAUDE.md` for idb setup.

### dump-rn-state.js (read live state)
```bash
node scripts/dump-rn-state.js storage.currentProgramId        # dotted path
node scripts/dump-rn-state.js --eval "Object.keys(globalThis.state)"   # arbitrary expr
node scripts/dump-rn-state.js --eval "globalThis.state.storage.settings.units"
```
Hermes' `JSON.stringify` drops `undefined`/functions, so you get clean IState. Useful roots:
`storage` (programs, settings, history, stats), `progress` (in-progress workout),
`editProgramStates` / `editProgramExerciseStates` (edit buffers). Needs Metro connected — a
"Fast Refresh disconnected" banner means it dropped; relaunch the app.

### liftosaur-local MCP (set up the environment)
Same account as the sim. Read the DSL guide first — it is not guessable:
`get_liftoscript_reference`, then `get_liftoscript_examples`.
- Programs: `create_program` / `update_program` (validate with `run_playground` BEFORE saving),
  `list_programs`, `get_program`, `delete_program`.
- Also: custom exercises/equipment/gyms, `create_history_record`, measurements, `set_exercise_data`.

After creating/updating data via MCP, the device won't see it until it syncs — relaunch with
`launch_app(bundle_id, terminate_running:true)` to force a full sync, then switch to it in-app
(Me → Program) if needed.

## Understand the target first

Before driving the UI, know the `testID`s and the handlers behind the feature. Fastest path:
spawn an **Explore agent** to grep `testID=` in the relevant components and report each control,
its handler, and how it serializes/writes state (with `file:line`). Note handler **asymmetries**
(one path preserves a field, a sibling resets it) — those are prime bug candidates.

Native code lives in `src/components/**` (`.tsx` using RN primitives; check for `.native.tsx`
variants). State shapes are in `src/types.ts` and `src/models/state.ts`.

## Gotchas (learned the hard way)

- **Coordinates:** `ui_find_element` frames are in full-scroll coords; taps use screen points.
  The tab bar sits at ~y≥762 and overlaps list rows — scroll the target above it before tapping,
  or you'll hit a footer tab.
- **Inputs commit on blur** — after `ui_type` into a numeric/text field, dismiss the keypad
  (its keyboard-icon) before dumping state, or the change won't be in state yet.
- **CodeMirror / inlined-HTML WebViews** (e.g. the Liftoscript full-text editor) are ONE opaque
  a11y node — you can't tap inside. Set that content via the MCP instead.
- **Reload to sync** MCP-made data: `launch_app(..., terminate_running:true)`.
- **Verify the DATA, not just the screen** — the screen can look right while state is wrong.
- Leave the account as you found it (or tell the user what you changed): switch the active
  program back, delete throwaway programs/history, and confirm nothing unintended was saved.

## Reporting

When bug-hunting, write findings to `lambda/scripts/bugs/<area>-<date>.md`. Per bug: severity,
numbered repro steps, a **before/after state dump** proving it, any UI-vs-state mismatch, and the
`file:line` root cause. Reproduce from a clean state before reporting, and list what you verified
working too.

## Example: program-editor QA

A worked scenario — reuse/repeat mechanics on the Edit Program screens — including a ready-made
fixture program and the exact bugs it surfaced, is in
`lambda/scripts/bugs/program-editor-bugs-2026-07-01.md`. Use it as a model for structuring a run.

