All rules in this file are non-negotiable. Violating them causes the Orchestrator to reject your work and re-launch you.
- XcodeGen Pitfalls
- Code Signing
- SwiftUI Pitfalls
- XCUITest Pitfalls
- Never Simulate App Features
- Inspector: ANSI Garbage
- Architecture Guide
- Architecture Reference
- Role: Builder (macOS)
- Role: Tester (macOS)
- Role: Inspector (macOS)
- Role: Orchestrator (macOS)
Manual edits to Xcode project settings get lost or cause conflicts.
ALL build settings must go through project.yml:
# Edit project.yml, then:
xcodegen generateWhen using XcodeGen, .xcodeproj is a generated artifact. Running xcodegen generate overwrites all manual changes. Never use Xcode GUI to change Build Settings, never use sed/awk on .pbxproj files.
codesign fails with "bundle format unrecognized, invalid, or unsuitable" pointing at .../PlugIns/Tests.xctest.
Override both settings to empty in project.yml for UI test targets:
MyAppUITests:
type: bundle.ui-testing
dependencies:
- target: MyApp
settings:
base:
BUNDLE_LOADER: ""
TEST_HOST: ""
CODE_SIGN_IDENTITY: "MyApp Dev"
CODE_SIGNING_ALLOWED: "YES"
CODE_SIGN_STYLE: "Manual"Then: xcodegen generate and clean DerivedData if stale:
rm -rf ~/Library/Developer/Xcode/DerivedData/MyApp-*
XcodeGen auto-adds BUNDLE_LOADER = "$(TEST_HOST)" to UI test targets (bundle.ui-testing) when they depend on an app target. This is WRONG — BUNDLE_LOADER/TEST_HOST are for unit tests only.
macOS revokes Screen Recording, Microphone, and Accessibility permissions after every rebuild.
CERT_NAME="MyApp Dev"
if ! security find-identity -v -p codesigning 2>/dev/null | grep -q "$CERT_NAME"; then
cat > /tmp/cert.cfg <<EOF
[ req ]
distinguished_name = req_dn
[ req_dn ]
CN = $CERT_NAME
[ extensions ]
keyUsage = digitalSignature
extendedKeyUsage = codeSigning
EOF
openssl req -x509 -newkey rsa:2048 \
-keyout /tmp/dev.key -out /tmp/dev.crt \
-days 3650 -nodes \
-config /tmp/cert.cfg -extensions extensions \
-subj "/CN=$CERT_NAME" 2>/dev/null
security import /tmp/dev.crt -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign 2>/dev/null
security import /tmp/dev.key -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign 2>/dev/null
security add-trusted-cert -d -r trustRoot -k ~/Library/Keychains/login.keychain-db /tmp/dev.crt 2>/dev/null
rm -f /tmp/cert.cfg /tmp/dev.key /tmp/dev.crt
fisettings:
base:
CODE_SIGN_IDENTITY: "MyApp Dev"
CODE_SIGNING_ALLOWED: "YES"
CODE_SIGN_STYLE: "Manual"
ENABLE_HARDENED_RUNTIME: "NO"Then: xcodegen generate. Launch app once, grant permissions — they persist across rebuilds.
Ad-hoc signing produces a different signature hash on every build. A persistent self-signed certificate keeps the same identity. Hardened runtime enforces strict validation that self-signed certs cannot satisfy.
XCUITest cannot reliably click views using .onTapGesture. Wrap every tappable element in a Button with .buttonStyle(.plain).
SwiftUI propagates a container's identifier to ALL children, replacing their individual identifiers. Only add .accessibilityIdentifier() to LEAF elements: Text, Button, Image, TextField, Toggle.
| Element Type | Pattern | Example |
|---|---|---|
| TextField | {purpose}TextField |
terminalInputTextField |
| Button | {action}Button |
startRecordingButton |
| Toggle | {feature}Toggle |
autoSaveToggle |
| Preview | {content}Preview |
videoPreview |
Use app.descendants(matching: .any)["myId"] for element lookup.
waitForExistence is BANNED from test code. Zero occurrences — no exceptions. Use waitAndSnap(element, timeout:, "FAIL message") for ALL waits. The Orchestrator's Step 9 greps for waitForExistence and auto-rejects any match.
When content loads async, waitAndSnap on the container succeeds instantly but children aren't loaded. Use waitAndSnap on the first child element instead.
Use waitAndSnap() ONLY once per view transition, then .exists (synchronous) for subsequent checks in the same view.
macOS SwiftUI apps may not open a window when launched by XCUITest. JourneyTestCase handles this in setUpWithError().
Add to project.yml: ENABLE_APP_SANDBOX: "NO".
// FORBIDDEN — silently skips
if recordingBanner.exists {
snap("096-recording-banner-active")
}
// REQUIRED — test FAILS with diagnostics
waitAndSnap(recordingBanner, "FAIL('Recording banner must appear')")
snap("096-recording-banner-active")JourneyTestCase includes a watchdog that auto-captures if >10s pass since last snap(). No configuration needed.
Use app.alerts.firstMatch / app.sheets.firstMatch instead.
Never create Simulated{Feature}Repository, Fake{Feature}, or Mock{Feature} in production code. Every service must use real framework APIs:
- Window enumeration → ScreenCaptureKit (not hardcoded window list)
- Model inference → real ML framework (not
Thread.sleep()+ canned output) - Media playback → AVPlayer (not a static image)
If a real API requires permissions or hardware that blocks progress, use /attack-blocker. The only acceptable test doubles are in unit tests (never in the running app).
Scan for Simulated*, Fake*, Mock* (outside test targets) as a red flag.
For any screenshot containing terminal/console output:
- Look for patterns like
[0m,[1m,[27m,[K,[?2004h,[38;5; - If found: FAIL — "Terminal output contains raw escape codes."
- Automatic FAIL regardless of acceptance criteria
When reviewing screenshots, ask "does this look like something a user would ship?" — not just "does this satisfy the checklist."
| Category | When to Use | Examples |
|---|---|---|
| OpenAPI Repositories | Data that may sync with a remote server | Notes, Folders, Users |
| Direct CoreData Repositories | Local-only data | Settings, Cache, Drafts |
| Type | @MainActor? | async throws? |
|---|---|---|
| ViewModels | YES | Methods use Task { } |
| DependencyContainer | YES | N/A |
| Use Cases | NO | YES |
| Repositories | NO | YES |
Presentation → Data → Domain → Entities
Inner layers MUST NOT import outer layers. All boundaries are Swift protocols.
Presentation (SwiftUI Views, ViewModels, Coordinators)
↓
Data (Repositories, Network, Database, DTOs)
↓
Domain (Use Cases, Repository Protocols, Domain Services)
↓
Entities (Business Objects, Value Objects, Pure Swift)
- Single Responsibility: Each Use Case = one operation. Each ViewModel = one screen.
- Open-Closed: Extend through new conformances, not modification.
- Interface Segregation: Small, focused protocols.
- Dependency Inversion: High-level modules depend on abstractions.
| API | iOS | macOS |
|---|---|---|
NavigationStack |
16.0 | 13.0 |
navigationDestination(item:) |
17.0 | 14.0 |
@Observable |
17.0 | 14.0 |
TextEditor |
14.0 | 11.0 |
.searchable |
15.0 | 12.0 |
Inspector |
17.0 | 14.0 |
ContentUnavailableView |
17.0 | 14.0 |
Add JourneyTester as an SPM dependency for the UI test target. See the JourneyTester README for project.yml config and setup steps.
- SPM — preferred for Swift libraries (including JourneyTester)
- Carthage — for frameworks without SPM
- Vendored — for C libraries (whisper.cpp, etc.)
Always verify with xcodebuild build.
# Audio must be non-trivial (>1KB)
find ~/<AppName> -name "audio.m4a" -size +1k 2>/dev/null | head -3
# Transcript must have content
find ~/<AppName> -name "transcript.jsonl" ! -empty 2>/dev/null | head -3
# Video must have content
find ~/<AppName> -name "video.mp4" -size +10k 2>/dev/null | head -3Use os_log with %{public}@, never print().
Import JourneyTester and subclass JourneyTestCase. See the JourneyTester README for full API (snap(), step(), waitAndSnap(), assertExists(), watchdog config).
import JourneyTester
import XCTest
final class Journey009Tests: JourneyTestCase {
override var journeyName: String { "009-window-picker" }
override var appBundleID: String? { "com.example.myapp" }
func testWindowPicker() {
step("open picker") {
app.buttons["pickWindowButton"].click()
snap("picker-opened")
}
step("verify content") {
waitAndSnap(app.tables.firstMatch, timeout: 10, "Window list must appear")
}
}
}Artifacts are written to .journeytester/journeys/{name}/artifacts/. After tests, symlink the xctrunner sandbox to the project root: ln -sfn ~/Library/Containers/.xctrunner/Data/.journeytester <project-root>/.journeytester
FORBIDDEN (silently skip):
if element.exists { XCTAssertTrue(...) }
guard let result = action() else { return }
if let dir = findDirectory() { XCTAssertTrue(...) }ALLOWED (fail loudly):
waitAndSnap(element, "FAIL('Element must appear')")
guard let result = action() else { XCTFail("..."); return }
let dir = findDirectory()
XCTAssertFalse(dir.isEmpty, "FAIL('Directory must exist')")let outputBefore = (app.descendants(matching: .any)["terminalOutputArea"].value as? String) ?? ""
summarizeBtn.click()
waitAndSnap(app.descendants(matching: .any)["terminalOutputArea"].firstMatch, timeout: 3, "FAIL('Output area must exist')")
let outputAfter = (app.descendants(matching: .any)["terminalOutputArea"].value as? String) ?? ""
XCTAssertNotEqual(outputBefore, outputAfter, "AC2: must change")
XCTAssertTrue(outputAfter.contains("\n") || outputAfter.count > outputBefore.count + 50, "AC2: must be substantial")| Feature | How |
|---|---|
| Audio recording | say "test content" & before recording |
| Transcription | say known text → record → assert contains |
BANNED: -generateTestTranscript, -useTestDownloads, -useFakeData. Only state config flags allowed (e.g., -hasCompletedSetup YES).
When adding a new .swift file to a test target, run xcodegen generate to regenerate the .xcodeproj. The sources: [PercevTests] directive in project.yml auto-discovers all .swift files in that directory, but only after regeneration.
echo "=== Empty audio files ==="
find ~/<AppName> -name "audio.m4a" -size -1k 2>/dev/null
echo "=== Empty transcripts ==="
find ~/<AppName> -name "transcript.jsonl" -empty 2>/dev/null
echo "=== Empty video files ==="
find ~/<AppName> -name "video.mp4" -size -10k 2>/dev/nullANY result = FAIL.
grep -rn "generateTestTranscript\|useTestDownloads\|useFakeData" *UITests/ --include="*.swift"grep -rn 'return ""$\|return \[\]$' */ --include="*.swift" | grep -v "UITests\|Tests\|guard\|else\|catch\|//"grep -rn "XCTAssertTrue.*||" *UITests/ --include="*.swift"Watch for ANSI escape codes, SwiftUI rendering artifacts, macOS permission dialogs.
echo "=== Bypass flags in tests ==="
grep -rn "generateTestTranscript\|useTestDownloads\|useFakeData" *UITests/ --include="*.swift" || echo "CLEAN"
echo "=== Stub functions in production ==="
grep -rn 'return ""$\|return \[\]$' */ --include="*.swift" | grep -v "UITests\|Tests\|test\|guard\|else\|catch" || echo "CLEAN"
echo "=== Test data generators in production ==="
grep -rn "testSentences\|generateTest\|hardcodedSegments" */ --include="*.swift" | grep -v "UITests\|Tests" || echo "CLEAN"After xcodebuild test completes, symlink the xctrunner sandbox to the project root so artifacts are accessible:
ln -sfn ~/Library/Containers/.xctrunner/Data/.journeytester <project-root>/.journeytesterArtifacts are then at .journeytester/journeys/{name}/artifacts/ — PNGs for screenshots, .txt for accessibility trees.
TEST_FILE="<AppName>UITests/<JourneyTestFile>.swift"
echo "=== Silent Skips ==="
grep -n 'if.*\.exists.*{' "$TEST_FILE" | grep -v "// optional\|cleanup\|Cleanup\|delete\|Delete" || echo "CLEAN"
echo "=== waitForExistence (BANNED — zero occurrences allowed) ==="
grep -n 'waitForExistence' "$TEST_FILE" || echo "CLEAN"
echo "=== Tautological Assertions ==="
grep -n 'XCTAssert.*||' "$TEST_FILE" || echo "CLEAN"
echo "=== Architecture Claims ==="
grep -n 'architectur' "$TEST_FILE" || echo "CLEAN"| Generic concept | macOS/XCUITest equivalent |
|---|---|
FAIL(message) |
XCTFail(message) |
| Content assertion | XCTAssertTrue(value.contains(...)) |
| Change detection | XCTAssertNotEqual(before, after) |
| Test file extension | .swift |
| Test directory pattern | *UITests/ |
JourneyTestCase is provided by the JourneyTester SPM package. Add it as a dependency — do NOT copy the class manually.
See the JourneyTester README for:
- SPM setup and
project.ymlconfiguration - Full API reference (
snap(),step(),waitAndSnap(),assertExists(),axQuery()) - Override points (
journeyName,appBundleID,axTreeDepth,watchdogTimeout) - Artifact output format (screenshots + compact accessibility trees)
- Self-signed certificate and Accessibility permission setup