Skip to content

Dev Tools ​

Both starter templates include development tooling for inspecting live data, managing files, and running tests against the real client:

  • Web — a browser overlay provided by the primitiveDevTools Vite plugin, with a Document Explorer, Test Harness, and Blob Explorer.
  • iOS — the Debug Inspector, a local web UI served by the app itself in debug builds, which you open from a browser on your Mac.

Both are excluded from production automatically: the web overlay only renders in development mode, and the iOS inspector is compiled out of release builds entirely.

Web: The Dev Tools Overlay ​

A floating button showing the Primitive logo appears at the edge of your app during development. Click it to open the dev tools overlay. The button is draggable and its position persists across sessions.

typescript
// vite.config.ts
import { primitiveDevTools } from "primitive-app/vite";

export default defineConfig({
  plugins: [
    vue(),
    primitiveDevTools({
      appName: "My App",
      testsDir: "src/tests",
    }),
  ],
});

The overlay has three tabs: Document Explorer, Test Harness, and Blob Explorer.

Document Explorer ​

Inspect and manage documents and data records:

  • Document sidebar — all documents you have access to (owned and shared), with search, pagination, and permission badges. For documents you own: create, rename, delete, and mass-delete.
  • Data table — select a document to see its registered models with record counts; click a model to view records in a filterable, sortable table with full CRUD (type-aware form editors for text, booleans, dates, StringSets).
  • Model info — click the info icon on any model to view its schema: fields, types, indexes, unique constraints, and relationships.

Blob Explorer ​

Browse and manage files stored with your documents: search by filename, filter by content type, upload, preview images, copy download URLs, and delete individually or in batch.

Test Harness ​

Run automated tests against your business logic with the real client — real storage, the Primitive sync layer, and Vue reactivity, no mocks. Tests are written once and run in two contexts: interactively in this panel during development, and headlessly in Node under vitest so CI can gate merges (see Running tests headlessly).

Create test files with the .primitive-test.ts suffix in your tests directory. Each file exports a TestGroup:

typescript
// src/tests/myFeature.primitive-test.ts
import type { TestGroup } from "primitive-app";

const tests: TestGroup = {
  name: "My Feature",
  tests: [
    {
      id: "add-numbers",
      name: "Addition works",
      run: async (log) => {
        log("Testing 2 + 2...");
        if (2 + 2 !== 4) throw new Error("Math is broken");
        return "passed";
      },
    },
  ],
};

export default tests;

Tests that perform model operations (.save(), .query(), .find(), .delete()) need an ephemeral test document. Use createTestDocument() / destroyTestDocument() with a try/finally block:

typescript
import { createTestDocument, destroyTestDocument } from "primitive-app";
import type { TestGroup } from "primitive-app";
import { Task } from "@/models/Task";

const tests: TestGroup = {
  name: "Task CRUD",
  tests: [
    {
      id: "task-save",
      name: "Task save and query",
      run: async (log) => {
        const doc = await createTestDocument();
        try {
          const task = new Task({ title: "Test Task", priority: 1 });
          await task.save();

          const found = await Task.find(task.id);
          if (found?.title !== "Test Task") {
            throw new Error("Task not found or title mismatch");
          }

          log(`Saved and retrieved task ${task.id}`);
          return "passed";
        } finally {
          await destroyTestDocument(doc);
        }
      },
    },
  ],
};

export default tests;

createTestDocument() creates an isolated local-only document and sets it as the default for all model operations; destroyTestDocument() closes and evicts it. Tests that perform no document/model operations — pure logic, validation — skip the document lifecycle and just use the (log) => ... signature.

For a test that performs server-side operations on the document — a blob upload or collection membership, which fail against a local-only document — pass createTestDocument({ networkSync: true }) for a server-resident document; destroyTestDocument() then deletes it server-side so test documents don't pile up.

To run: open the dev tools overlay, select Test Harness, choose tests, and click Run Selected Tests. Results show real-time log output, pass/fail status, and execution time.

Best practices:

  • Keep business logic in src/lib/ (not embedded in components) so it's easy to test
  • Test real behavior — don't mock browser APIs
  • Only create test documents when your test actually needs document/model operations, and always clean them up in a finally block
  • Scope queries to the test document. createTestDocument() sets its document as the default, but a model query spans every open document — so an unscoped Task.query({ ... }) can pick up records from other documents open in the session and return more than the test created. Pass { documents: doc.docId } (a single id or an array) so the assertion sees only its own data:
ts
const doc = await createTestDocument();
try {
  await new Task({ title: "High priority", priority: 2 }).save();
  const highPriority = await Task.query({ priority: 2 }, { documents: doc.docId });
  // highPriority sees only this test's records
} finally {
  await destroyTestDocument(doc);
}
  • Assert only on rows you created. The test document isolates writes — every .save() lands there — but it gives queries no isolation, since a query spans every open document. When a test can't scope by document (for example it checks an aggregate that legitimately spans documents), give each row a run-unique field value, filter on that value, assert only on the rows the test created, and delete them in the finally. Don't assert on absolute totals like (await Task.query({})).data.length — another open document's rows inflate the count and the test fails only when that document is non-empty.

Environment-scoped tests ​

Most tests run in both contexts. When a test depends on a browser API — canvas, MediaRecorder — or on something Node-only, scope it with environment on the test or the whole group (a test-level value overrides the group's):

typescript
const tests: TestGroup = {
  name: "Waveform rendering",
  environment: "browser", // every test in this group
  tests: [
    {
      id: "wave-render",
      name: "Waveform renders to canvas",
      run: async (log) => {
        /* ... */
        return "passed";
      },
    },
  ],
};

Each context reports the other's tests as skipped: the panel shows node-only tests as skipped, and the headless run skips browser-only tests without failing CI.

Running tests headlessly (CI) ​

The same registered tests run in Node under vitest run, so pnpm test can gate merges — the client runs the full document and model lifecycle natively in Node. The template ships the wiring: a spec entry that registers every .primitive-test.ts file with the harness adapter, a vitest.config.ts that merges your app's Vite config so aliases and codegen resolve exactly as in the browser, and the vitest and ws dev dependencies.

typescript
// src/tests/primitive-tests.spec.ts
import { registerPrimitiveTests } from "primitive-app/testing";
import { allModels } from "@/models";

await registerPrimitiveTests({
  models: allModels,
  testModules: import.meta.glob("./**/*.primitive-test.ts"),
  appId: import.meta.env.VITE_APP_ID,
  apiUrl: import.meta.env.VITE_API_URL,
  wsUrl: import.meta.env.VITE_WS_URL,
});

The run signs in as a real user through the test-account OTP bypass: whitelist a base address once, then point the run at a bypass address:

bash
# Add the base address to testAccountBaseEmails in app.toml, then apply it
primitive config push --only app
PRIMITIVE_TEST_EMAIL="you+primitivetest-ci@yourdomain.com" pnpm test

PRIMITIVE_TEST_EMAIL must be a +primitivetest derivative of the whitelisted base, like the example above — the bare base address (you@yourdomain.com) is never a test account and always fails sign-in. Use a stable suffix per CI project so the run reuses one test user instead of provisioning a fresh account every time. For CI report ingestion, vitest's standard reporters apply: pnpm vitest run --reporter=junit --outputFile=test-results.xml.

The app id and server URLs come from the Primitive environment in primitive/config.json: vitest merges the app's Vite config, so the primitiveEnv() plugin resolves the same environment a pnpm dev would — whichever one primitive env use selected. To point a single run at a different backend, name the Primitive environment for that run:

bash
PRIMITIVE_TEST_EMAIL="you+primitivetest-ci@yourdomain.com" PRIMITIVE_ENV=alpha pnpm test

vitest's --mode is the other axis. It selects the .env.<mode> file of app-behavior keys (VITE_LOG_LEVEL and friends) and never the backend — the app id and server URLs are typed once, in primitive/config.json, and no .env file repeats them.

That independence has a sharp edge: PRIMITIVE_ENV=dev pnpm test --mode alpha signs in and writes test data against dev while the app behaves as alpha — a real combination, and a wrong one when a mode's keys are coupled to a single backend. Have such a mode declare the environment it belongs to:

dotenv
# .env.alpha
VITE_EXPECTED_PRIMITIVE_ENV=alpha

A mismatched run then fails at config time, before any test is collected and before anything signs in. It is opt-in, and a non-empty VITE_EXPECTED_PRIMITIVE_ENV in the shell overrides the file — so a deliberate cross-wired run states itself: VITE_EXPECTED_PRIMITIVE_ENV=dev PRIMITIVE_ENV=dev pnpm test --mode alpha. See Pinning a mode to a Primitive environment.

Don't put a -- before the flag

pnpm test -- --mode staging looks equivalent and isn't: vitest discards every argument after a bare --, so the mode flag — and any positional test filter — is dropped, and the run silently uses the default mode. Pass --mode directly. The plugin prints the Primitive environment it resolved (name, apiUrl, appId, and the config file it came from) at the start of every run, so which backend a run used is never a guess.

A few behaviors differ from the panel:

  • A scored result below full marks ("7/10 (70%)") fails the run — the panel displays the score without failing.
  • A test file that fails to load surfaces as a failing test, never a silent skip.
  • Each test gets a 60-second timeout.
  • Leftover test documents are cleaned up after the run, and the bypass token lives about 30 minutes — split very long suites.

iOS: The Debug Inspector ​

The Swift client ships a Debug Inspector — a small HTTP server that runs inside your app in DEBUG builds and serves a live dashboard to any browser on the same network. When PrimitiveAppState.initialize() runs, the console prints a banner:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[PrimitiveInspector] listening on:
  http://localhost:9999
  http://192.168.1.42:9999
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Open one of those URLs in a browser on your Mac. The simulator shares your Mac's loopback interface, so localhost:9999 works directly; a physical iPhone needs to be on the same Wi-Fi (iOS will show a one-time Local Network permission prompt).

The inspector covers the same ground as the web overlay, plus iOS-specific storage views:

TabWhat it shows
OverviewConnection state, current user, document summary, recent events
DocumentsDocument list + per-model records browser with CRUD (the iOS counterpart of the Document Explorer)
TestsRegistered in-app tests with streaming output (the counterpart of the Test Harness)
CollectionsCollection CRUD, membership, and access
BlobsPer-document file browser with upload, preview, and download (the counterpart of the Blob Explorer)
PerformanceA live timeline of document load/sync events — what data loaded, from where, in how long
SQLite / Memory SQLThe client's on-disk store and the in-memory SQL projection that backs queries, browsable directly
Events / LogsThe live client event stream and the inspector's own log tail

Your models appear in the Documents tab automatically — the inspector reads the client's shared cross-document store, so every model registered with the client (client.registerModels([...])) or read/written once through its codegen'd facade is listed, per open document. To register in-app tests, conform your app state to InspectorTestHost:

swift
extension MyAppState: InspectorTestHost {
  var inspectorTests: [InspectorTest] {
    [
      InspectorTest(group: "Client", name: "is connected") { [weak self] ctx in
        guard let self, let client = self.client else { throw TestFailure(message: "no client") }
        try ctx.check(client.isConnected, "client is not connected")
      },
    ]
  }
}

Tests run on the main actor and can call real client methods — they're the fastest way to build a repro for a bug.

A few knobs: set PRIMITIVE_DEBUG_INSPECTOR=0 in the environment to disable the inspector for a run, or PRIMITIVE_DEBUG_INSPECTOR_PORT=9998 to pin a different port.

Dev networks only

The inspector has no authentication — any HTTP client on the same LAN can reach it. It only exists in DEBUG builds, but don't run debug builds on networks you don't trust.

iOS: Driving the UI with idb ​

The Debug Inspector asserts state — the data and connection your client holds. It can't drive the UI: tapping a button, typing in a field. xcrun simctl has no tap or text input, and macOS accessibility can't see inside a SwiftUI screen, so to script the app end-to-end you need idb (Facebook's iOS Debug Bridge). The two work together: idb drives the UI, the inspector confirms the state that resulted.

The Swift starter template ships this ready to run. scripts/smoke-test.sh has a ui_signin scenario that boots the simulator, launches the app, signs in end-to-end, and asserts the post-login screen renders:

bash
bash scripts/smoke-test.sh ui_signin   # the idb-driven UI sign-in test
bash scripts/smoke-test.sh --list      # launch_survive + ui_signin
bash scripts/smoke-test.sh             # default run — no idb needed

ui_signin is opt-in, so the default run stays zero-dependency. It signs in through the +primitivetest OTP bypass (see Test User Sign-In) — whitelist a base email, keep email sign-in enabled, and set PRIMITIVE_SMOKE_TEST_EMAIL to a derived address.

The bypass replaces the emailed code, not the app's signup gate, so the test address also has to be one the app admits. A freshly scaffolded app is mode = "invite-only", where an uninvited address is rejected at OTP request ("This app is invite-only. You've been added to the waitlist."). Either set mode = "public", or stay invite-only and admit the exact derived address first — Invite-Only Apps covers how — and set PRIMITIVE_SMOKE_TEST_EMAIL_INVITED=1 so the preflight knows. The preflight checks all three prerequisites before the boot and build, and prints the fix if any is missing — including when primitive apps get itself fails, which reports that the settings couldn't be read rather than failing with no explanation. Run PRIMITIVE_SMOKE_PREFLIGHT_ONLY=1 bash scripts/smoke-test.sh ui_signin to check the prerequisites on their own, without the boot and build.

On a current Xcode (27) idb finds SimulatorKit only under a DEVELOPER_DIR the template resolves for it: ui_signin resolves one itself before the boot, and its preflight names an Xcode it cannot resolve. Start an ad-hoc companion the same way — DEVELOPER_DIR="$(bash scripts/idb-developer-dir.sh)" idb_companion …; the script keeps a symlink mirror under ~/.local/share/primitive/xcode-hid-shim (override with PRIMITIVE_XCODE_SHIM_DIR) and writes nothing inside Xcode.app.

Installing idb takes two pieces — the native companion and the Python client — and the template installs both for you:

bash
bash scripts/setup-idb.sh   # idempotent: a no-op if idb already works

It runs brew install facebook/fb/idb-companion and installs fb-idb into a Python 3.12 venv (~/.local/share/primitive/idb-venv, shared across your apps), then links it into ~/.local/bin. The venv isn't ceremony: fb-idb doesn't run on 3.14 (it uses asyncio.get_event_loop, removed in that release), and Homebrew's Python refuses a plain pip install as PEP 668 externally-managed. smoke-test.sh finds that client on its own, so ui_signin needs no PATH export; for ad-hoc idb commands, put ~/.local/bin on your PATH. Coordinates in idb are in points, the same units idb ui describe-all reports. Like the launch smoke test, ui_signin is macOS-only and isn't part of the Linux test suite. The DevTools agent guide (primitive guides get devtools --language swift) has the full command reference.

Server Timing ​

Every REST response from the platform carries a Server-Timing: total;dur=<milliseconds> header, so your browser's Network panel (or any HTTP tooling) can attribute slow requests to server-side handler work.

Response Caching ​

Every app API response carries Cache-Control: no-store unless the endpoint sets its own directive, so no HTTP cache keeps a copy of an authenticated response. The one endpoint that opts out is GET /avatars/:userId, which serves world-readable bytes with public, max-age=31536000, immutable. Blob downloads still send an ETag and still answer a conditional If-None-Match request with 304 Not Modified — no-store stops a cache from storing the body, not your app from revalidating.

The iOS client enforces the same rule on its side, so it holds even against an older server: every URLSession it builds disables URLCache and ignores any locally cached response, and nothing it fetches lands in the shared, disk-backed URLCache.shared.

Production Behavior ​

Dev tools never reach your users: the web overlay and floating button are excluded when import.meta.env.DEV is false, and the entire iOS inspector module is inside an #if DEBUG guard — release builds compile it out and never open the port.

Next Steps ​

Documentation validated against js-bao-wss-client 3.4.0 · js-bao 0.11.0 · primitive-admin 1.0.62 · primitive-app 3.1.0 — 2026-09-30