# Testing Guide

Use these canonical test commands for the Oppi monorepo.

## Policy as code

- Gate policy: `server/testing-policy.json`
- Change-aware local gate: `.githooks/pre-push`
- Explicit full non-coverage gate: `cd server && npm run test:gate:pr-fast`
- Full threshold-enforced coverage: local `cd server && npm run test:gate:ci-coverage` (`test:coverage` on the server) and `oppi-workflow.sh release-all`
- Coverage thresholds live in `server/vitest.config.ts` and `clients/apple/scripts/check-coverage.sh`.

The local hook reads the refs Git pushes, classifies changed paths, and runs platform checks concurrently. Server changes run static checks plus Vitest's affected tests; server configuration and protocol changes run the full non-coverage suite. Apple changes compile affected test bundles with the repository simulator pool. Each lane requires its relevant worktree paths to match the pushed commit. Successful lanes are cached by commit, pushed range, lane mode, path set, and toolchain so retries do not repeat completed work.

Full server and Apple unit coverage run on the local workstation. Pre-push keeps compile, static-analysis, architecture, and affected-test failures on the push path. Use `cd server && npm run test:gate:ci-coverage` and `clients/apple/scripts/check-coverage.sh` for threshold-enforced coverage, or `oppi-workflow.sh release-all` for a release cut. `.github/workflows/hygiene.yml` still runs secret and file-size checks for every push and pull request.

## Server

From `server/`:

```bash
npm run check
npm test
```

The server Vitest configuration caps file workers at four. CLI tests launch
child processes, so allowing worker count to scale with host cores can starve
nested subprocesses and contend with the concurrent Apple pre-push lane.

### One-shot Linux validation on macOS

Use Apple `container` copy-in mode for a clean Linux check without exposing the checkout through a host bind mount. The command streams the working tree into the container, including uncommitted files but excluding local build products.

```bash
container system start

./scripts/apple-container-copy-run.sh \
  --source . \
  --workdir /work/server \
  --exclude .git \
  --exclude .pi \
  --exclude .internal \
  --exclude clients \
  --exclude server/node_modules \
  --exclude server/dist \
  --exclude server/coverage \
  -- bash -lc '
    set -euo pipefail
    export DEBIAN_FRONTEND=noninteractive
    apt-get update
    apt-get install -y --no-install-recommends ca-certificates git openssl
    rm -rf /var/lib/apt/lists/*
    npm install -g bun@1.3.11
    npm ci --no-audit --no-fund
    npm run check
    npm test
  '
```

For Apple `container` installations without the `container cp` plugin, including 0.9, the helper uses `tar` over `container exec -i` for copy-in and optional copy-out, then deletes the ephemeral container. It does not pass `--volume` or `--mount`.

Writable host bind mounts in compose files and scripted container runs are rejected by `server/scripts/check-compose-mounts.ts`, which runs as part of `npm run check` (`npm run mounts:check` standalone).

Server E2E coverage is documented in `server/e2e/README.md`. Prefer native mode for local work; `E2E_NATIVE=1` also suppresses Docker cleanup:

```bash
cd server
E2E_NATIVE=1 npm run test:e2e
npm run test:e2e # Docker Compose mode when that environment is explicitly needed
```

## Apple

From `clients/apple/`:

### Regenerate project

`Oppi.xcodeproj` is generated. Change `project.yml`, then run:

```bash
xcodegen generate
```

### Privacy manifests and archive report

Validate the tracked manifest contents and parsed XcodeGen target resource membership before building. Run the regression fixtures when you change the checker:

```bash
cd clients/apple
./scripts/check-privacy-manifests.sh
./scripts/check-privacy-manifests.sh self-test
```

These checks validate declared manifest contents and exact `project.yml` membership. They do not inspect executable API use or replace archive validation.

To inspect the final bundle layout without using distribution credentials, create an unsigned local archive and validate manifest placement and contents in the audited executable bundles:

```bash
cd clients/apple
xcodebuild -project Oppi.xcodeproj -scheme Oppi \
  -configuration Release \
  -destination 'generic/platform=iOS' \
  -archivePath .build/privacy/Oppi.xcarchive \
  CODE_SIGNING_ALLOWED=NO \
  archive

./scripts/check-privacy-manifests.sh \
  --archive .build/privacy/Oppi.xcarchive
```

The unsigned archive proves manifest placement and contents for that build. It does not prove that every required-reason API has an accurate declaration. Xcode 26.6 does not expose a supported `xcrun` or `xcodebuild` operation for the merged privacy report. Before distribution, an authorized maintainer must create a normally signed archive without exporting or uploading it:

```bash
cd clients/apple
xcodebuild -project Oppi.xcodeproj -scheme Oppi \
  -configuration Release \
  -destination 'generic/platform=iOS' \
  -derivedDataPath .build/privacy/SignedDerivedData \
  -archivePath .build/privacy/Oppi-signed.xcarchive \
  archive

open -a Xcode .build/privacy/Oppi-signed.xcarchive
```

In Xcode Organizer, control-click the archive, choose **Generate Privacy Report**, and save the report under `.internal/reports/privacy/`. Review every app and SDK declaration, including findings from statically linked dependencies, before distribution. The signed Organizer report is a mandatory manual distribution gate. Do not mark it complete without reviewing the generated report, and do not use `-exportArchive` for this check.

Apple’s source for the per-executable bundle rule and required-reason policy is [Describing use of required reason API](https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api). Apple documents target resource membership and bundle locations in [Adding a privacy manifest to your app or third-party SDK](https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk).

### Simulator build

For Oppi maintainer/agent work, use the simulator pool so parallel runs do not collide:

```bash
cd clients/apple
./scripts/sim-pool.sh run -- \
  xcodebuild -project Oppi.xcodeproj -scheme Oppi build
```

Public fallback when the local pool wrapper is unavailable: use a unique `-derivedDataPath`.

```bash
xcodebuild -project Oppi.xcodeproj -scheme Oppi build \
  -destination 'platform=iOS Simulator,name=iPhone 16 Pro' \
  -derivedDataPath .build/derived-data-build
```

### iOS unit tests

Use the dedicated `OppiUnitTests` scheme for `OppiTests`.

From the repo root, `./scripts/sim-pool.sh` and `clients/apple/scripts/sim-pool.sh` both work. An OppiTests-only `-scheme Oppi` run is rewritten to `OppiUnitTests` so agents do not build UI/E2E/perf bundles.

`run` always executes `xcodebuild` in the resolved checkout (`--root`, `OPPI_ROOT`, or this git root), even if you launched the script from another tree's `clients/apple`. Pass a worktree path or that tree's `clients/apple`. Missing worktree `.build/OppiTestsInfo.plist` is created automatically.

```bash
cd clients/apple
./scripts/sim-pool.sh run -- \
  xcodebuild -project Oppi.xcodeproj -scheme OppiUnitTests test -only-testing:OppiTests

# From any cwd, including main, test a worktree:
./scripts/sim-pool.sh run --root /path/to/worktree -- \
  xcodebuild -project Oppi.xcodeproj -scheme OppiUnitTests test -only-testing:OppiTests
```

Public fallback:

```bash
xcodebuild -project Oppi.xcodeproj -scheme OppiUnitTests test \
  -destination 'platform=iOS Simulator,name=iPhone 16 Pro' \
  -derivedDataPath .build/derived-data-tests \
  -only-testing:OppiTests
```

### iOS coverage gate

The repository owns separate local and CI simulator runners. `sim-pool.sh` manages persistent local simulators for parallel development. `ci-simulator.sh` selects an existing device from the ephemeral GitHub runner image and never creates or erases one. First run the focused harness self-tests. They verify path classification, retry result-bundle handling, failure classification, simulator selection, bounded simulator-readiness reboot retries, reuse of an already-booted pool simulator, DerivedData hang-progress, default compiler-index-store disable, simulator daemon slimming, lock-safe idle shutdown, and safe package-cache reset boundaries without launching a simulator. In particular, ordinary `rebuild:` application logs must not appear as linker failures, while Swift, clang, and `ld` diagnostics must remain visible.

```bash
./.githooks/pre-push --self-test

# Run the focused script checks while editing the coverage lane.
./clients/apple/scripts/sim-pool.sh self-test
./clients/apple/scripts/ci-simulator.sh self-test
./clients/apple/scripts/check-coverage.sh self-test
```

Run the full unit-test coverage gate locally with the simulator pool:

```bash
cd clients/apple
./scripts/check-coverage.sh
```

The single-device CI simulator runner is optional local coverage, not a GitHub job:

```bash
cd clients/apple
OPPI_SIMULATOR_RUNNER=ci ./scripts/check-coverage.sh
```

Do not run this command concurrently. It deliberately has no local lock and uses one existing device with one CI DerivedData path. Normal local builds and tests must use `sim-pool.sh`.

`check-coverage.sh` returns `2` only when collected coverage is below an enforced logic-layer threshold. Invalid or unavailable simulator-runner configuration returns `8`. Test, simulator, result-bundle, `xccov`, and report-analysis failures use other nonzero statuses. Swift package resolution returns `7` after both the restored-cache attempt and an empty-cache retry fail. A failed collection is not a coverage shortfall. The script prints package-resolution, build/test, report, and analysis wall times.

The local pool gives a simulator boot two 120-second readiness waits by default. For a new simulator, the second wait continues the same first-boot data migration instead of erasing and restarting it. Set `OPPI_SIM_POOL_BOOT_TIMEOUT` to change each wait or `OPPI_SIM_POOL_BOOT_RETRIES` to change the number of additional waits. An already-booted pool simulator is reused instead of shutdown/boot. After boot, `sim-pool.sh`, `ci-simulator.sh`, and `sim-lab.sh` disable unused background daemons (Siri extras, Spotlight, iCloud, PosterBoard, and similar) unless `OPPI_SIM_SLIM=0`. Those overrides persist across reboot and are reapplied after hang-recovery erase. Live Activities, speech, Photos, push, and universal links stay enabled. CI never creates or erases a simulator; it still slims the existing runner device. Pool simulators stay booted after a run; set `OPPI_SIM_POOL_KEEP_BOOTED=0` to shut them down, `OPPI_SIM_POOL_FORCE_CLEAN_BOOT=1` to recycle before xcodebuild, or `./scripts/sim-pool.sh shutdown-idle` to stop unused pool devices. Slot exclusion is Darwin `flock(2)` on a stable `$OPPI_SIM_POOL_LOCK_DIR/slot-N.lock` inode (never unlinked by the runner). Creation is not ownership. Persist `in-flight` / `uncertain` / `reusable` under flock before mutation. A dead wrapper PID does not authorize reuse by itself. Leased commands start behind a stdin gate and do not exec until the child is registered and its PGID is persisted (`flock-v2` / `gated-v1`). Parent-pipe EOF or publication failure prevents execution. Next acquire may reclaim a gated `in-flight` or `uncertain` lease when recorded groups exist and are idle, or when the ledger is proven empty (`publishedCount` 0 and not `publishing`). Legacy `flock-v1` `in-flight` records stay fail-closed, including idle pgids. Empty `flock-v1` pgids stay fail-closed. `uncertain` `flock-v1` reclaims only when recorded groups exist and are idle. Summary/`xcresulttool` failures do not quarantine a slot. Legacy `$LOCK_DIR/slot-N/` directories are skipped, not reaped. `shutdown-idle` and `prune-cache` follow the same prevent-first lease as `run`: they persist `in-flight` under flock, record child process groups, and do not leave a gated slot permanently quarantined after a proven-empty ledger. `shutdown-idle` rechecks `Booted` as device state and holds the lease through `simctl shutdown`. Killing `xcrun` does not mean CoreSimulator finished. Failed list/recheck/shutdown is reported. Hang detection treats DerivedData directory mtime as progress, not only log growth. Pool `xcodebuild` injects `COMPILER_INDEX_STORE_ENABLE=NO` unless the command already sets that setting or `OPPI_SIM_POOL_INDEX_STORE=1`. A matching `Oppi-Pool-N` must use the configured runtime and device type. `run` prefers a matching slot; if it leases a mismatched `Oppi-Pool-N`, it deletes that simulator under the slot lock and recreates it. The default pool is six iPhone slots (`OPPI_SIM_POOL_COUNT`, slots 0-5). Dedicated iPad lanes should start at slot 8 or higher (`OPPI_SIM_POOL_SLOT_START`).

Parked worktrees keep Apple DerivedData in `clients/apple/.build/pool-<digits>` until the tree is removed. Do not copy or clone `.build` between checkouts; each tree needs its own cache. Reclaim idle cache without deleting logs, videos, or the stable Mac/CI paths:

```bash
./clients/apple/scripts/sim-pool.sh prune-cache
./clients/apple/scripts/sim-pool.sh prune-cache --apply
# Keep warm pool-0..5 caches on the active workstation checkout:
./clients/apple/scripts/sim-pool.sh prune-cache --apply --keep-slots 0-5
```

Dry-run is the default. Classification still lists numeric `pool-*` directories, `derived-data-*`, and one-off `mac-*` experiment dirs. It keeps `logs`, `videos`, `mac-tests`, `mac-debug`, `pre-push-mac`, `ci`, `privacy`, and `oppi-dev`. `--apply` deletes `pool-*` only after acquiring that slot's flock lease. `derived-data-*` and `mac-*` are skipped on apply (no exclusive lease shared with raw `xcodebuild`). Busy, live, and legacy slot locks are skipped. Gated `in-flight` and `uncertain` reclaim when recorded groups are idle or the ledger is proven empty; flock-v1 `in-flight` stays fail-closed. A failed pool-dir delete does not permanently quarantine the slot. `--keep-slots 0-5` retains the default warm iPhone pool caches. The next simulator build in a deleted cache recompiles from scratch (typically minutes per slot, about 2 GB/slot). This does not remove the worktree. Run it in a parked tree to reclaim that tree only.

The tracked pre-push hook is `.githooks/pre-push`. It does not collect coverage; it runs the faster local checks described above. Install it into a clone's configured hook directory after reviewing any existing local hook:

```bash
install -m 755 .githooks/pre-push "$(git rev-parse --git-path hooks)/pre-push"
```

### Swift Testing filters

`xcodebuild` strips one trailing `()` from Swift Testing identifiers. Use double parentheses for function-level filters.

```bash
# Suite
-only-testing:OppiTests/MySuiteStruct

# Function
-only-testing:'OppiTests/MySuiteStruct/myTestFunc()()'
```

### Swift Testing conventions

- Use Swift Testing for unit tests: `import Testing`, `@Test`, `#expect`.
- Use XCTest only for UI tests that require `XCUIApplication`.
- Group related tests with `@Suite`.
- Put `@MainActor` on the suite when all tests need main actor isolation.
- Use `Issue.record()` instead of `XCTFail()`.

### iOS E2E tests

Use the Oppi workflow wrapper. It starts a paired E2E server, writes invite/device-token files under `/tmp`, launches XCUITests, and cleans up the server.

```bash
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-test

# Faster local iteration, no Docker
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-test --native

# Focus one E2E test
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-test --native \
  --only-testing OppiE2ETests/WebSocketLifecycleE2ETests/testNavigationKeepsWorkspaceListOnHTTPAndUsesBoundSessionStreams

# Preferred release gate: focused chat/composer/ask/attachment/session coverage
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-test --group gate

# Slower extended coverage batch for pre-release or nightly coverage
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-test --group extended
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-test --group recovery
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-test --group history
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-test --group quick-session
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-test --group extension-attention

# Broader/lab follow-up groups
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-test --group regression
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-test --group media --record-video=always
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-test --group screenshot
```

`sim-test` writes E2E run artifacts under `.internal/reports/e2e-runs/<timestamp>/` by default. Set `E2E_ARTIFACT_DIR` or `E2E_DOCKER_LOG_DIR` to an absolute or repo-relative path when a release lane needs repeatable artifact collection. `OPPI_E2E_GROUP` and the release wrapper's `OPPI_RELEASE_E2E_GROUP` select the same groups. `gate`/`release-gate`/`smoke` run `ReleaseGateE2ETests` as the preferred blocking lane. `extended` runs the gate plus recovery, history, quick-session, and extension-attention batches for deeper pre-release coverage. `all` still means the historical broad `OppiE2ETests` suite, and `full-regression` keeps broad coverage for explicit slower checks.

Focused checks for the sessions-first root and direct share-extension send:

```bash
# All Sessions root, workspace drawer, workspace deep-link intake, scoped controls, and back navigation
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-test --native \
  --only-testing OppiE2ETests/IPhoneSessionsFirstScreenshotE2ETests

# Safari share sheet → in-extension workspace selection and direct session send
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-test --native \
  --only-testing OppiE2ETests/ShareSheetQuickSessionE2ETests

# Main-app Quick Session workspace/model/send flow
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-test --group quick-session
```

Focused unit coverage for navigation routing, Shortcuts text/image intake, direct share sending, credential migration, MetricKit previous-process context, and the main-thread watchdog:

```bash
cd clients/apple
./scripts/sim-pool.sh run -- \
  xcodebuild -project Oppi.xcodeproj -scheme OppiUnitTests test \
  -only-testing:OppiTests/AppNavigationShellRoutingTests \
  -only-testing:OppiTests/WorkspaceDeepLinkTests \
  -only-testing:OppiTests/QuickSessionTriggerTests \
  -only-testing:OppiTests/StartQuickSessionIntentTests \
  -only-testing:OppiTests/ShareQuickSessionSenderTests \
  -only-testing:OppiTests/KeychainServiceTests \
  -only-testing:OppiTests/MetricKitSerializerTests \
  -only-testing:OppiTests/MainThreadLagWatchdogTests
```

Prerequisites:

- oMLX/OpenAI-compatible model endpoint on `http://localhost:8400`
- a usable non-ASR model; the harness prefers `Qwen3.6*`

### Paired-server simulator labs

Use paired-server simulator labs when the UI depends on pairing, server toolbar state, workspace catalog refresh, session counts, auth, model-backed sessions, or any real server state. Use `IPhoneSessionsFirstScreenshotE2ETests` for the All Sessions root and workspace drawer. The existing `workspace-home/*` lab scenarios open one workspace's scoped detail. Use `--screenshot-preview` only for isolated mock component visuals.

The lab wrapper can run one-shot XCUITest scenarios, record simulator video, or boot a persistent simulator/server pair for manual driving:

```bash
# List scenarios
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-lab list

# One-shot scenario with screenshots + video + manifest
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-lab run \
  --scenario workspace-home/wrapping --native --record-video

# Persistent manual lab, hooked to local model/server; stop with sim-lab teardown
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-lab boot \
  --scenario workspace-home/dense-counts --record-video --replace

# Manual capture while persistent lab is running
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-lab screenshot --name after-row-tweak
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-lab teardown
```

Workspace-scoped lab files:

- Lab wrapper: `~/.pi/agent/skills/oppi-dev/scripts/apple/sim-lab.sh`
- Shared lab fixture/API/screenshot helpers: `clients/apple/OppiE2ETests/E2ELabFixtures.swift`
- Workspace-home scenarios: `clients/apple/OppiE2ETests/WorkspaceHomeScreenshotLabE2ETests.swift`
- One-shot run artifacts: `.pi/e2e-lab/runs/<timestamp>-<scenario>/manifest.json`
- XCTest screenshots: `/tmp/oppi-screenshots/*.png`

Run the current workspace-home scenarios directly through XCUITest when you do not need manifest/video collection:

```bash
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-test --native \
  --only-testing OppiE2ETests/WorkspaceHomeScreenshotLabE2ETests/testWorkspaceHomeWrappingScreenshotLab

~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh sim-test --native \
  --only-testing OppiE2ETests/WorkspaceHomeScreenshotLabE2ETests/testWorkspaceHomeDenseCountsScreenshotLab
```

To add a scenario:

1. Add a case to `WorkspaceHomeLabScenario`.
2. Map the XCTest name in `currentScenario`.
3. Declare `fixtures`, `anchorWorkspaceName`, and `screenshotName`.
4. Add one focused XCTest method that calls `runWorkspaceHomeLab(...)`.
5. Prefer `E2ELabWorkspaceFixture` for normal workspace/session-count state; use `e2eLabAPIJSON(...)` only for custom server setup.

### Screenshot preview UI tests

Mock screenshot-preview surfaces launch the app with `--screenshot-preview` for isolated visual capture (`ui-validate` and manual QA), not for paired-server workspace behavior.

Post-run UI checks belong here or on a `sim-lab` scenario, not in a new XCUITest. Agent procedure lives in `.pi/skills/oppi-dev/references/local-build.md`. This file owns the launch command and artifact paths.

```bash
# Isolated surface dump: accessibility tree + audit + one PNG
~/.pi/agent/skills/oppi-dev/scripts/oppi-workflow.sh ui-validate \
  --screen mermaid-rendering
```

Artifacts: `.pi/ui-validate/<timestamp>-<screen>/{tree.md,audit.md,screen.png,manifest.json}` from `XCUIElement.snapshot()` and `performAccessibilityAudit`. Review those files. Do not add another preview test method just to look at a screen; add a fixture to an existing named preview instead.

### Duplication and Apple guardrail check

Run after Apple UI or rendering changes. From the repo root:

```bash
bun scripts/duplication-scan.ts
```


## Mac

Mac is an Apple client target (`OppiMac` / `OppiMacTests`). Use this section for repository-owned Mac build, test, architecture, and visual-evidence commands. The local debug-app product loop lives in the `oppi-dev` skill; do not copy that command catalog here.

Do not use `sim-pool.sh` for Mac. Reuse `.build/mac-tests` for unit tests and `.build/mac-debug` for the debug app. Do not create a new `mac-*` or `derived-data-*` path per experiment. Local unsigned Debug builds set `CODE_SIGNING_ALLOWED=NO`.

### Architecture

```bash
cd server
bun scripts/check-architecture-boundaries.ts --scope mac
```

`--scope all` includes this lane. The Mac pre-push gate runs `--scope mac` before `OppiMac` `build-for-testing`.

### Build and unit tests

From `clients/apple/` after `xcodegen generate` when `project.yml` changed:

```bash
xcodebuild -project Oppi.xcodeproj -scheme OppiMac \
  -destination 'platform=macOS' \
  -derivedDataPath .build/mac-tests \
  CODE_SIGNING_ALLOWED=NO \
  build

xcodebuild -project Oppi.xcodeproj -scheme OppiMac \
  -destination 'platform=macOS' \
  -derivedDataPath .build/mac-tests \
  CODE_SIGNING_ALLOWED=NO \
  test
```

Focused Swift Testing / XCTest filters use the same `-only-testing:OppiMacTests/...` shape as iOS. The tracked pre-push Mac lane is compile-only (`build-for-testing`); it does not run `OppiMacTests`. Run the test command above when Mac behavior changed.

`server/testing-policy.json` currently owns server and iOS-simulator Apple steps. It has no Mac platform key; do not treat those gates as Mac coverage.

### Visual evidence

In-process visual gates live in `OppiMacTests`:

- `MacComposerVisualGateTests`
- `MacSessionShellVisualGateTests`
- `MacToolMetadataVisualGateTests`

They host SwiftUI in an offscreen `NSWindow`, assert layout geometry, and keep structural `XCTAttachment` images. They prove one hosted paint of the current test binary. They do not prove a running `/Applications/Oppi.app` or debug-app identity, Liquid Glass, send/stop/reconnect, LaunchAgent attach, or release readiness. For chrome/material claims that need a live window, record commit, dirty paths, the exact `.app` path, and a running-window artifact separately.

## Protocol checks

Protocol changes must update and test both sides:

- Server contracts: `server/src/types.ts` and related `server/src/types/*`
- Apple models: `clients/apple/OppiCore/Models/*Message.swift`
- Protocol snapshots: `protocol/*.json` when the wire shape changes
- Protocol/model tests in both `server/tests` and `clients/apple/OppiTests`

The canonical protocol-change checklist lives in [Server architecture](../architecture-server.md#protocol-boundary).

## Failure investigation

- Do not pipe `sim-pool.sh` output through `grep`, `tail`, or `head`; the summary includes the log and artifact paths.
- When a wrapper prints a `.summary.json` or full log path, inspect that path before rerunning.
- E2E native failures preserve the temporary data dir for debugging.
- For failed XCUITests, inspect the `.xcresult` and the app UI hierarchy attachment when element visibility is unclear.
