Two test targets:
- ZenttyLogicTests — no app host, parallel-safe. Pure logic + detached AppKit component tests (~380 tests).
- ZenttyTests — hosted in Zentty.app, serial. Tests that need real windows or app lifecycle (~24 tests).
Run the full all-target gate only when you explicitly need Logic + hosted app + integration in one command. Use the virtual-display harness so AppKit windows are created on the test display:
ZENTTY_TEST_DISPLAY_PROVIDER=betterdisplay scripts/test-on-virtual-display
For normal local verification of hosted window/AppKit behavior, use the hosted virtual-display wrapper instead of running ZenttyTests through plain xcodebuild:
scripts/test-hosted-on-virtual-display
The virtual-display harness is the expected local path for tests that can create AppKit windows. It creates or reuses a display named ZenttyTests, sets ZENTTY_TEST_SCREEN_NAME=ZenttyTests, and runs the scheme's testables by default. It supports ZENTTY_TEST_DISPLAY_PROVIDER=auto, betterdisplay, or simpledisplay; auto prefers BetterDisplay when available. This moves prepared test windows off the active display, but AppKit tests still run in the same Aqua session and can still steal focus.
The virtual-display scripts set TEST_RUNNER_SWIFT_BACKTRACE=enable=no. If you bypass them and call xcodebuild directly, always prefix with TEST_RUNNER_SWIFT_BACKTRACE=enable=no. On macOS 26 (Tahoe) the Swift backtrace handler shows an interactive "Press space to interact" prompt on crash, which hangs xcodebuild indefinitely until a 30s timeout. The TEST_RUNNER_ prefix forwards env vars from xcodebuild to the xctest subprocess (a plain SWIFT_BACKTRACE=... on xcodebuild does NOT propagate).
Multiple agents often run in parallel in this repo.
Guidelines:
- New tests go in
ZenttyLogicTestsunless they callshowWindow(),makeKeyAndOrderFront(), or accessNSApplifecycle. - Use
scripts/test-on-virtual-display -only-testing:ZenttyLogicTestsfor localZenttyLogicTestsruns that may create real AppKit windows. - Use
scripts/test-hosted-on-virtual-displayfor localZenttyTestsruns that open real AppKit windows. - Use
scripts/test-modern-shell-integrationsbefore release after changing shell integration; it requires localfish,nu, and/usr/bin/expect, and fails if the fish/nu resource files are not tracked. - Tests that create windows must close them in
tearDownoraddTeardownBlock. - Use XCTest expectations instead of
RunLoop.current.run(until:). - The app host runs inert during tests (no main window,
.prohibitedactivation policy) viaXCTestConfigurationFilePathdetection inmain.swift. - Do not pass
-derivedDataPathto xcodebuild. Use the default DerivedData location. Creating per-agent directories under/tmp/wastes gigabytes of disk and they never get cleaned up. - Do not use
./runner.
- Do not add design docs, specs, or plans to git unless Peter explicitly asks.
- It is fine to create them locally for discussion or planning, but keep them untracked and ignored by default.
Zentty.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolvedis committed and is the source of truth for dependency versions. Commit it whenever a bump changes it; do not ignore it again.- Release builds pass
-disableAutomaticPackageResolution, so a range change inproject.ymlwithout a matching resolved-file update fails the release instead of silently picking a new version. - The release lane builds
generate_appcastfrom the Sparkle revision inPackage.resolved, never from whatever checkout is newest in DerivedData.
- Treat
project.ymlas the source of truth for Xcode project structure and generated build scripts. - Do not make manual edits directly in
Zentty.xcodeproj/project.pbxprojunless Peter explicitly asks. - When project configuration changes are needed, update
project.ymlfirst and regenerate the project.
scripts/agent-bench/ drives each supported agent CLI through the Zentty wrapper and asserts the hook events the integration depends on. It is the regression backstop for the Antigravity (agy) integration in particular, where the hook pipeline has historically been fragile.
For the agy integration, the gate is scripts/test-agy-bench. It builds Zentty (skip with --no-build) and runs the full agy scenario sweep — smoke,session_capture,approval,tools,restore_launch,restore_launch_with_id — in --strict mode (auth-skip / binary-skip become failures).
Re-run after:
- any agy CLI version bump (it is the upstream side of the contract),
- any change to
AgyHooksInstaller,agyAdapter, orAgyCanonicalReEmitter, - any change to
scripts/agent-bench/profiles/agy.json.
For the devin integration, the gate is scripts/test-devin-bench. It runs the full devin scenario sweep — smoke,session_capture,tools,approval,restore_launch,subagents,subagents_async — in --strict mode and covers the --config overlay contract plus subagent lifecycle inferred from run_subagent / sidekick hooks.
Re-run after:
- any devin CLI version bump,
- any change to
AgentLaunchBootstrap.devinPlanorDevinEventAdapter, - any change to
scripts/agent-bench/profiles/devin.json.
For the muse (Meta Muse Code) integration, the gate is scripts/test-muse-bench. It runs smoke,session_capture,tools,tasks,approval,user_input,restore_launch,subagents,greeting_first_turn,multi_turn in --strict mode and covers the TBH_MANAGED_HOOKS_PATH overlay, the per-run -c managed_hooks_env_vars allowlist (Muse scrubs the hook env), and root-thread filtering. greeting_first_turn and multi_turn drive the interactive TUI and pin the title spinner (⠋ repo while busy) that Zentty ends Muse turns on: a trivial first turn fires no Stop, and a normal Stop lands while the goal/verify reminders are still running. smoke and session_capture use Muse's free echo provider; the model scenarios pin the cheapest catalog model, muse-spark-1.2 at --reasoning-effort minimal. Keep new muse scenarios on that model.
Re-run after:
- any muse CLI version bump,
- any change to
AgentLaunchBootstrap.musePlanorMuseEventAdapter, - any change to
scripts/agent-bench/profiles/muse.json.
For the kilo integration, the gate is scripts/test-kilo-bench. It runs smoke,session_capture,approval,restore_launch,manual_compact through the manifest-driven opencode-plugin path.
Re-run after:
- any kilo CLI version bump,
- any change to the shared opencode-family plan (
openCodeFamilyPlan) or the shared pluginzentty-opencode-zentty.js, - any change to
ZenttyResources/agents/kilo.jsonorscripts/agent-bench/profiles/kilo.json.
For the opencode integration, the gate is scripts/test-opencode-bench. OpenCode ships two incompatible CLIs as opencode: v1 (npm opencode-ai, profile opencode) and v2 (npm @opencode/cli, profile opencode-v2). Each profile picks its binary by probing --version. v2 runs plugins in a shared background service by default, so Zentty launches it --standalone with the plugin from ZenttyResources/opencode/v2-plugins/. Both profiles pin a free model (ZENTTY_BENCH_OPENCODE_MODEL overrides), so results don't depend on your last TUI model. The v2 bench also runs isolated (overlay data/state/cache), and the gate installs @opencode/cli into a temp prefix when no v2 is on PATH.
Re-run after:
- any opencode v1 or v2 CLI version bump,
- any change to
openCodeFamilyPlan,OpenCodeGeneration, or either opencode plugin, - any change to
scripts/agent-bench/profiles/opencode*.json.
For the generic manifest seam, the gate is scripts/test-generic-agent-bench. It runs smoke,session_capture,approval,restore_launch against the generic-canonical fixture agent — a bash script that emits canonical events with no model auth required.
Re-run after:
- any change to
AgentManifest*,AgentManifestRegistry, orAgentManifestWrapperMaterializer, - any change to
canonicalManifestPlan, - any change to the shared
zentty-agent-wrapperscript, - any change to shell-integration manifest lookup (
ZENTTY_AGENT_MANIFEST_TABLE).
Profile-level Python tests (scripts/agent-bench/tests/test_agent_bench.py) run via python3 -m unittest discover scripts/agent-bench/tests and catch bad profile shapes before the harness is even invoked.
Two-tier strategy based on execution context:
- Fatal/exit — CLI tools (ClaudeHookBridge, AgentStatusHelper). When the process IS the error reporter, log to stderr and exit with a non-zero status code.
- Log and continue — In-app observers (AgentStatusCenter, PRArtifactResolver, GhosttyThemeResolver, WorklaneReviewStateResolver). Best-effort detection should never crash the app. Log via
os.Loggerand fall back to a safe default.
Exception: LibghosttyRuntime initialization uses fatalError — the terminal engine is an essential dependency with no meaningful fallback.