onebox

Guide 05 of 27

For your agent: .md · all guides

On this page
  1. What it costs
  2. Steps
  3. Where the values go
  4. Check it works
  5. Common errors

A test loop your coding agent can run

Runs on: your Mac. You set this up once per app. After that, the agent runs the loop on every change with the dev:test-loop skill.

A coding agent that cannot check its own work says “fixed” when it is not. This guide gives it the checks: strict types, a linter, fast unit tests, tests against a real database, a dev database full of edge cases, and click-through scripts it follows in the iOS Simulator. Then one short block in AGENTS.md makes every agent use them.

What it costs

Steps

1. Strict TypeScript

In the app’s tsconfig.json:

{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,   // arr[i] may be undefined
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitReturns": true,
    "types": ["jest", "node"]           // the test runner's globals, and Node's
  }
}

TypeScript 6 no longer loads every @types/* package on its own. List the ones your code uses in types: "jest" for Jest’s test and expect, "node" for fs, path and __dirname in tests and config. With Vitest, import test and expect from vitest and list only "node". Without the list, the first test fails with Cannot find name 'test'. Add each package as a direct dev dependency (step 4). One that is there only through another package can go away on the next install.

noUncheckedIndexedAccess finds the most real bugs, and it also finds the most code to change. Turn it on first, fix what it reports, then add the rest. exactOptionalPropertyTypes is stricter still. Try it, and drop it if library types fight it.

Add a script, so every agent calls the same command:

"scripts": { "typecheck": "tsc --noEmit" }

2. ESLint

From the Expo app folder:

npx expo lint

The first run installs eslint and eslint-config-expo, writes eslint.config.js and adds a lint script to package.json. It can then crash with Cannot find module 'eslint' (from lintAsync.js). Run npx expo lint a second time. The second run works. Then:

3. Strict .NET

In a Directory.Build.props at the backend root, so it applies to every project:

<Project>
  <PropertyGroup>
    <Nullable>enable</Nullable>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <AnalysisLevel>latest-recommended</AnalysisLevel>
  </PropertyGroup>
</Project>

If warnings as errors slow you down locally, remove that line and run dotnet build -warnaserror in CI and in the agent loop instead.

Next to it, an .editorconfig:

# EF Core writes the migrations. Analyzers skip generated code, so a
# composite index (CA1861) does not fail the strict build.
[**/Migrations/*.cs]
generated_code = true

Write log lines as source-generated [LoggerMessage] methods. Under these settings, log.LogInformation(...) fails the build with CA1848. One class holds them all:

namespace MyApp.Api;

// CA1848 refuses the LogInformation(...) extension methods. Never log a token,
// an email or a request body (backend.md, "Protect the API", step 9).
public static partial class Log
{
    [LoggerMessage(Level = LogLevel.Warning, Message = "Apple identity token refused: {Reason}")]
    public static partial void AppleTokenRefused(ILogger log, string reason);

    [LoggerMessage(Level = LogLevel.Error, Message = "Job {JobId} failed")]
    public static partial void JobFailed(ILogger log, Exception error, Guid jobId);
}

Call it as Log.AppleTokenRefused(log, "expired"). An Exception parameter becomes the log entry’s exception, not a placeholder.

4. Unit tests for the app

Pick one runner:

Either way:

5. Tests against a real database

An in-memory database skips SQL translation, constraints and query filters, which are where the bugs are. Test against Postgres, the same major version you deploy.

.NET: add Testcontainers.PostgreSql, Microsoft.AspNetCore.Mvc.Testing and coverlet.collector to the test project. Start one container per test run, and create one database per test fixture, so parallel test classes cannot see each other’s rows:

// One server for the run. Pin the image to the major version you deploy.
static readonly PostgreSqlContainer Server =
    new PostgreSqlBuilder("postgres:17-alpine").Build();

Testcontainers 4.14 and later take the image in the constructor. The empty constructor is obsolete, and with warnings as errors it fails the build. Set MaxPoolSize to a small number (5) in each fixture’s connection string. Twenty fixtures with the default pool size exhaust Postgres’ 100 connections, and the tests fail with errors that look like a deadlock.

Hand each fixture’s connection string to a WebApplicationFactory<Program>, so the tests call the real HTTP endpoints.

Node: @testcontainers/postgresql does the same:

const pg = await new PostgreSqlContainer("postgres:17-alpine").start();
process.env.DATABASE_URL = pg.getConnectionUri();

Start it once per run, in Vitest’s globalSetup, and make one database per test file. The start:new-app skill has the code (references/node-api.md, “Tests”).

Or run a separate Postgres for tests in Docker Compose, on its own port, with its data in memory:

services:
  db-test:
    image: postgres:17-alpine
    environment: { POSTGRES_PASSWORD: test }
    ports: ["127.0.0.1:5439:5432"]
    tmpfs: /var/lib/postgresql/data

When the app has its own backend, write the two tests from backend.md, “Keep each user’s data apart”: one that fails when a table has no filter, and one where user B asks for user A’s row and gets 404. The backend comes in Phase 3 of start-here.md, so come back to this step then. Until then, the test database is enough.

6. A dev database with edge cases

Write a seed that runs only in Development and creates named users for the hard cases: an empty account, very long names, emoji and right-to-left text, many rows for pagination, missing images, an expired subscription, a second user with look-alike data, and dates around midnight and DST. Add a Development-only sign-in, because Sign in with Apple does not work in the Simulator.

The full checklist, and how to grow it from real bugs, is in the skill: plugins/dev/skills/test-loop/references/seed-data.md.

Add db:seed and db:reset scripts.

7. One Metro port per app and per worktree

Each app, and each git worktree of it, needs its own Metro port. Otherwise the simulator quietly runs another worktree’s code, or even another app’s code. Do not leave an app on the default 8081: a second app on the same Mac uses it too. Add the small scripts/metro-port.sh from plugins/dev/skills/test-loop/references/preflight.md. It gives the main checkout a port in 8200-8299 from the repo’s folder name, and each worktree a port in 8100-8199 from its path.

The script goes in the Expo app folder, next to its package.json (apps/mobile/scripts/ in a monorepo), because package.json scripts run in that folder. In a new app, add it after reset-project, which deletes scripts/. Use it in both scripts:

"start": "expo start --dev-client --port $(sh scripts/metro-port.sh)",
"ios": "expo run:ios --port $(sh scripts/metro-port.sh)"

8. A simulator tool for the agent

The agent needs a way to see and tap the simulator. Some agent apps include an iOS Simulator tool (screenshot, tap, swipe, type). If yours does not, add an iOS Simulator MCP server. Without any tool the agent can still take screenshots and open deep links with xcrun simctl, but it cannot tap.

Give the app a URL scheme ("scheme": "myapp" in app.json), so the agent can open a screen directly: xcrun simctl openurl <udid> "myapp://settings".

9. The rules in AGENTS.md

Copy plugins/dev/skills/test-loop/assets/AGENTS.snippet.md into the repo’s AGENTS.md (or CLAUDE.md). Replace the placeholders with your commands. Run the discovery script to find them:

node <path-to>/plugins/dev/skills/test-loop/scripts/discover.mjs .

10. The first flow

Pick the feature that would hurt most if it broke: sign-in, the paywall, the main create action. Write <feature>.flow.md next to its code, with 5 to 15 numbered steps and an Expect: line after each step. The format and a full example are in plugins/dev/skills/test-loop/references/flows.md.

Then ask the agent: “run the flows for ”.

Where the values go

Nothing goes into the onebox config. All of it lives in the app repo:

WhatWhere
Strict flagstsconfig.json, Directory.Build.props
Commandspackage.json scripts (lint, typecheck, test, db:seed, db:reset)
Test databasethe test project (Testcontainers) or docker-compose.yml (db-test)
Seedthe API project, run at startup in Development only
Agent rulesAGENTS.md or CLAUDE.md
Flowssrc/features/<feature>/<feature>.flow.md
Native build marker.expo/dev-loop-fingerprint.json (Expo already git-ignores .expo/)

Check it works

  1. npm run lint, npm run typecheck and npm test (or the pnpm or bun equivalents) each exit 0.
  2. dotnet test passes on a clean checkout with only Docker running. No connection string to set.
  3. Break an owner filter on purpose. The isolation test turns red. Revert.
  4. Start Metro and the app, then run the preflight from the app folder:
    node <path-to>/plugins/dev/skills/test-loop/scripts/preflight.mjs
    It ends with PREFLIGHT OK. Build once with npx expo run:ios, then run it with --mark-built.
  5. Ask the agent to change a label and verify it. Its report names the commands it ran and gives a screenshot path, and the screenshot shows the new label.

Common errors

Wrong or out of date? Fix it on GitHub.