Artifact downloads consumed the inventory entry only from the source
stream's 'end' / response 'finish' handlers. But the response carries an
explicit content-length, so a client's fetch completes as soon as that
many body bytes arrive - which happens right after the final chunk is
written to the socket, while the server's read stream still needs one
more threadpool read to detect EOF and emit 'end'. Under CPU contention
(e.g. the instrumented Coverage CI job) that threadpool completion can
land after the client's follow-up GET /artifacts, which then still lists
the consumed artifact.
Consume the entry as soon as the stream has emitted content-length
bytes, before pipe() writes the final chunk to the client. Any client
that observed a complete download is now guaranteed to observe the
entry as consumed. The 'end'/'finish'/'close' handlers stay as backstops
(idempotent via didCleanupArtifact).
* fix: surface stderr excerpts from hand-rolled exec failure wraps
normalizeError only replaces a generic COMMAND_FAILED message with the
first meaningful stderr line when details.processExitError is true, but
only createExitError in utils/exec.ts set that flag. Hand-rolled wrap
sites (throw new AppError('COMMAND_FAILED', msg, {stdout, stderr,
exitCode}) after an allowFailure run) missed it, so users saw messages
like "xcrun exited with code 22" with a generic hint instead of the
actual stderr excerpt.
Add execFailureDetails(result, extra?) to utils/exec.ts returning the
stdout/stderr/exitCode spread plus processExitError: true (extras spread
after the base keys so call sites can keep truncation or String()
normalization), and convert ~42 exit-guarded wrap sites across the apple
platform (apps launch/terminate paths, devicectl, simulator boot, perf,
perf-xctrace, runner modules, dsym/symbolication, macOS host provider)
and android/web (app-helpers, logcat, devices, perf, settings,
snapshot helpers, app-lifecycle, device-input-state, multitouch-helper,
agent-browser provider).
Sites reachable at exit 0 (parse/stdout checks), failure aggregations,
timeout/forced-kill errors, and sites that already build richer messages
(macos helper JSON, runner-contract early-exit, maestro run-script) are
deliberately left unflagged so stderr enrichment never replaces a more
specific message.
* fix: append stderr excerpts to curated wrap messages instead of replacing them
Spreading processExitError to curated call sites made normalizeError
replace specific messages like 'uiautomator dump did not return XML'
with the bare stderr line, losing the operation context (caught by the
provider-failures integration test). Keep wholesale replacement only for
the information-free generic '<tool> exited with code N' wraps; curated
messages now keep their specific reason and gain the stderr excerpt as a
': <excerpt>' suffix (skipped when already present).
Separate commit rather than amend so the semantics change to
maybeEnrichCommandFailedMessage stays individually reviewable on the PR.
* feat(snapshot): dedup inherited labels/identifiers in client output; promote diff snapshot
Deep RN/AX trees repeat the same accessibility label across a whole parent
chain (ScrollView -> Other -> Button -> inner Button), so agents pay for the
same string 3-4x in one snapshot. When a node's label (or, separately,
identifier) string-equals its nearest ancestor's, the CLI/JSON client output
now omits it and sets an `inheritsLabel`/`inheritsIdentifier: true` marker
instead; the human text renderer shows "[same label as parent]" in its place.
This is output-only: it runs in snapshotCliOutput (src/commands/capture/output.ts),
which feeds both the default text and --json presentations, right before they
are handed to the caller. The in-daemon session tree that selector building
(buildSelectorChainForNode), wait/is/get matching (selectors-match.ts), and
replay/replay-heal read from is never touched, since none of those consumers
go through this serializer -- they all read session.snapshot.nodes directly.
--raw and non-default MCP responseLevels (e.g. digest, which can omit `nodes`
entirely) bypass dedup and stay byte-for-byte.
Also promotes `diff snapshot` as the named post-action verification step in
the core-loop help text, replacing the generic "verify" step: it diffs the
rendered snapshot lines against the previous snapshot in the session and
prints only what changed, which is the actual token-cheap way to confirm a
mutation today (discovery, not new behavior).
* fix: dedup only labels long enough to pay for the marker
Review finding: dedup fired on any string-equal duplicate, but the
replacement markers cost ~21 chars in JSON and ~23 in text — short
duplicated labels (tab bars: Home, Settings) made output BIGGER, not
smaller. Only values of 24+ chars dedup now, which is net-positive on
both surfaces and keeps short labels readable inline. Fixtures updated
to threshold length; added a short-label regression test.
* build: run SWC minifier compress with 3 passes
Measured -9.7 kB raw / -7.0 kB npm tarball on the emitted JS at no
runtime cost. splitChunks minSize tuning was also evaluated for the
tiny-chunk overhead but measured as a byte-identical no-op (the sub-kB
files are dynamic-import boundaries, not split products), so it is
intentionally not included.
* perf(cli): keep help text, replay reporting, and diff runtime off the eager command path
Every CLI invocation eagerly parsed 599 kB of JS. Three static imports
dragged in code that most commands never run:
- cli.js -> cli-help.js (75 kB of help text) via the usage()/
usageForCommand() builders in parser/args.ts, which are only needed
on help and usage-error paths. They now lazy-import cli-help.ts.
- cli.ts/generic.ts -> replay/test/reporting.ts (~17 kB), only needed
by the test command. Now imported at the call sites.
- cli/commands/screenshot.ts -> createAgentDevice (68 kB client-side
command runtime chunk incl. screenshot pixel diffing), only needed
by the diff screenshot branch. Now imported inside diffCommand.
Eager closure of cli.js drops 599 kB -> 431 kB (-28%), ~2.5 ms median
module-load per command invocation. --help/--version fast paths in
bin.ts are unchanged. Package size is unchanged by design (the code
moves to lazy chunks; it does not disappear).
Add a "Guarantees" section to `agent-device help workflow`: statements of
fact for agents to reason from instead of probing behavior with trial
commands. Covers selector ambiguity (folds/extends the existing #1040
paragraph instead of duplicating it), hittability/targetHittable, open
idempotent-foreground vs --relaunch's single simctl launch
--terminate-running-process call, close's runner retention policy plus the
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS idle-stop window, ref lifetime
(cleared by open/relaunch), diff snapshot's comparison baseline, and wait's
polling model. Each statement is traceable to source, noted in the PR body.
Adds three doc-assertion tests in cli-help.test.ts mirroring the #1040
pattern.
* refactor: centralize known cli command checks
* feat: emit drift diagnostic for registered-but-unhandled commands
Folds #1055's telemetry into this branch: known-command fall-through now
emits cli_known_command_unhandled at error level alongside the distinct
user-facing message, so catalog/dispatch drift is visible in diagnostics
as well as to the user who hits it.
* feat: accept tap as an alias of press
* test: restore #1036 ordering regression coverage, assert tap dispatches press
Making tap a real alias deleted the only tests covering
unknown-command-before-flag-validation ordering (they used tap as the
unknown command). Restore them with a genuinely unknown command, and
strengthen the alias test to assert the dispatched call records press
with positionals preserved instead of merely not-erroring.
* test: cover tap alias composing with the bare-ref hint
Adopted from #1052 (credit: @vku2018): tap e3 must normalize to press
and then surface the @e3 suggestion, proving the alias and the bare-ref
hint compose instead of masking each other.
* fix: report unknown command before flag-support validation
When an unknown command is provided with flags, the parser was
validating flag support before checking if the command exists,
resulting in misleading "Flags are not supported for command X"
errors instead of "Unknown command: X".
This fix reorders the validation to check for unknown commands
first, reporting more helpful error messages. Additionally, when
the unknown command is 'tap', the error now includes a hint
suggesting 'press or click' as alternatives.
Closes#1036
* refactor: drop as-any casts and note the cli.ts sync requirement
Review follow-ups: widen the command lists to readonly string[] instead
of narrowing the value, and document that isCommandKnown must stay in
sync with the cli.ts dispatch fall-through.
When a resolved press/fill/click selector or ref target lands on a node
that reports hittable: false (after any hittable-ancestor promotion), the
success response now carries targetHittable: false and a hint suggesting
the interaction may have had no visible effect. Resolution semantics are
unchanged: no failing, no filtering, no strict-unique matching — iOS AX
hittable flags are unreliable on deep RN trees, and react-navigation's
maestro suite depends on today's auto-disambiguation behavior (#1037).
Also documents the selector disambiguation policy (deepest node, then
smallest area, exact ties fail) in `help workflow`, including that
replay/replay-heal apply the same policy for touch/fill/get-text.
When a user forgets the @ prefix on a snapshot ref (e.g., 'click e3' instead
of 'click @e3'), the positional parser would attempt to parse it as
coordinates, resulting in a confusing error message "Expected x to be a
finite number."
Add a check in readInteractionTargetFromPositionals and readFillTargetFromPositionals
to detect bare snapshot refs matching /^e\d+$/ and throw INVALID_ARGS with
a helpful hint message suggesting the correct @-prefixed form.
Closes#1035
* feat: warm the iOS runner build cache from doctor and boot
Cold-cold startup (first run ever on a machine) pays a ~10s xcodebuild
build-without-testing before the first open can even start the runner.
The background cache prewarm existed but only fired on simulator cold
boot during open — too late for the fresh-machine flow.
- doctor now resolves an iOS simulator from the session or device
inventory and, when the runner artifact is missing, kicks the
best-effort background build and reports it as an ios-runner-cache
check ('build started in the background' / 'artifact cached'). The
artifact builds against a generic simulator destination, so any
simulator record works and the result is shared across runtimes.
- boot fires the same warmup for already-booted iOS simulators (cold
boots already warmed via the onIosSimulatorColdBootStart hook).
- help workflow, help debugging, doctor --help, and the agent-device
skill now say to run doctor first on fresh machines, so agents
(including small models) discover the warmup instead of paying the
build inside their first open. Doctor's help no longer claims to be
strictly read-only.
Verified live: wiped artifact cache -> doctor returns immediately with
the warming check while the build runs detached -> artifact present
~15s later -> second doctor reports cached; first open then skips the
build entirely (~10s off cold-cold).
* feat: show doctor runner warmup progress and guard remote/non-mac hosts
The warmup now emits request-progress lines ('Checking iOS runner build
cache (device)...' / 'Warming ... in the background') so doctor
progressively shows what it is doing, and it is guarded to environments
where a local xcodebuild makes sense: non-macOS hosts and provider-backed
devices skip it entirely (--remote already returned before device
checks). Tests cover both guards with a mocked platform.
* fix: validate the runner cache probe like the ensure path
Review finding (P2): hasCachedAppleRunnerArtifact treated any .xctestrun
under the expected derived dir as cached, while the open path rejects
partial/stale caches via cache-metadata and product-path validation - so
doctor could report 'artifact cached', skip the warmup, and leave the
first open paying the build anyway.
The probe now runs the same evaluateExistingXctestrun no-build evaluation
the ensure path uses (extracted into a shared device-scoped helper) and
reports cached only on reuse_ready. Regression tests cover the empty and
stray-xctestrun-only derived dirs; live-verified that a cache with
deleted metadata now triggers the background warmup instead of reporting
cached, and that a genuinely valid cache still probes true.
The AWS Device Farm runtime is registered with a hardcoded platform of 'android', so baseSessionForLease builds the createSession capabilities with platformName 'Android' before prepareSession resolves the real platform. On an iOS device Device Farm rejects this with "reserved ... invalid values: ['platformName']". Rebuild webdriverCapabilities in the AWS prepareSession using the resolved platform and device name (mirroring the BrowserStack provider).
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* perf: collapse iOS simulator relaunch into one simctl launch call
open --relaunch on a simulator dispatched close (simctl terminate,
~0.7s), settled 300ms, then dispatched open (simctl launch, ~0.8s).
simctl launch --terminate-running-process does the whole relaunch in one
call (~0.94s), so the fast path skips the close dispatch and the
post-close settle: steady relaunch ~2.9s -> ~2.0s.
The terminateRunningApp option threads from the open context through the
interactor to buildIosSimulatorLaunchArgs; only the simulator bundle-id
launch path consumes it. Runtime hints keep working - they are
user-defaults writes read at the next launch, which the collapsed call
still performs. --clear-app-state keeps the close-first ordering so it
never mutates a running app's container; real devices and Android keep
the two-step relaunch.
* fix: keep close-first ordering for URL relaunches on simulators
Review finding: open <app> <url> --relaunch reached the collapsed fast
path, but the URL dispatch branch never consumes terminateRunningApp -
a deep-link open never launches the app, so there is nothing to attach
the terminate to. The session layer skipped the close while the platform
layer did not terminate either, silently losing relaunch semantics.
The collapse now applies only to the single app-launch form
(openPositionals.length === 1); URL relaunches keep the explicit
close-then-open sequence, with a regression test.
* fix: bound retained iOS runner lifetime with an idle stop
A runner retained after session close (#1021) holds the device's runner
lease indefinitely, which blocks every other daemon on the machine from
using the device - observed in the wild when a leftover verification
daemon squatted a simulator through its retained runner.
Session close now schedules an idle stop when it retains the runner:
if nothing touches the runner within 5 minutes (any ensureRunnerSession
call cancels the timer), it is stopped and the lease released.
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS overrides the window; 0 disables
idle stops entirely (the previous retain-until-daemon-exit behavior).
The timer is unref'd and cleared on explicit stop and shutdown detach.
Runners warmed by explicit prepare ios-runner are not timed - prepare
expresses intent to keep a warm runner, and its ensureRunnerSession call
cancels any pending stop.
* test: reset idle-stop scheduler mock between session tests
Review finding on #1025: every other mock in the file resets in
beforeEach; without it the retention assertion could pass on call
history leaked from an earlier close test.
* perf: start iOS simulator runner prewarm before close/open dispatch
Since simulator close/open ride simctl and never touch the runner, the
xcodebuild ramp can overlap the app (re)launch instead of following it.
Real devices keep the post-open prewarm because relaunchCloseApp tears
their runner down first; the Maestro prewarm-before-open path is
unchanged.
Daemon-cold plain open: first snapshot 4.6-5.1s -> ~2.5s (the ramp gets a
~2s head start). Daemon-cold open --relaunch: ~8.9s -> ~8.2s mean; most
of the head start is eaten by ramp slowdown under concurrent simctl work,
matching earlier contention observations.
* perf: seed the simulator booted-memo from the device inventory parse
A simctl listing that reports a simulator Booted is the same observation
ensureBootedSimulator would make, so resolving a device now seeds the
memo and the ensureDeviceReady boot check that follows in the same
request skips its own ~0.7s listing. Daemon-cold plain open: ~2.6s ->
~2.0s.
* perf: hand iOS simulator runners off across daemon restarts
Graceful daemon shutdown detaches healthy simulator runner sessions
instead of killing them: the lease token is rewritten to a detached form
(so the daemon's own teardown paths no longer classify it as owned) and
the xcodebuild/runner pair keeps running. The next daemon start adopts a
stale lease when the runner process is alive, the artifact fingerprint
matches the current toolchain, and an uptime probe answers - otherwise
the existing cleanup-and-restart hygiene applies unchanged. Crash-killed
daemons leave the same stale lease, so crash recovery and deliberate
handoff share one path.
The adopted session wraps the orphaned xcodebuild in a pid-backed
surrogate child; every downstream consumer (liveness, kill-tree,
early-exit detection, disposal wait) operates on pid/exitCode, which a
low-frequency exit poll maintains.
Bounds and escape hatches: at most one detached runner per device, the
runner's XCTWaiter self-expires after 24h, clean:daemon still kills by
lease runnerPid, and AGENT_DEVICE_IOS_RUNNER_DETACH=0 disables both
detach and adoption.
Daemon-cold open --relaunch with a handed-off runner: ~8s -> ~3.2s
(startup.durationMs ~780), for both SIGTERM and SIGKILL'd predecessors.
* fix: keep scoped simulator-set runners out of the shutdown handoff
Review finding on #1011: detach released the XCTestDevices device-set
redirect while the runner kept running, and adoption rebuilt the session
without it - a custom-set runner could outlive the symlink/lock that its
xcodebuild depends on, breaking scoped simulator-set isolation.
The redirect's lifetime is bound to the owning session by design, so
scoped-set runners simply do not participate in the handoff: shutdown
detach skips any session holding a redirect (it goes through the normal
dispose-and-restore path), and adoption refuses devices that resolve to a
custom simulator set. Default-set sessions - the only ones that never
hold a redirect - keep the fast handoff.
* fix: gate relaunch runner teardown on Apple platforms explicitly
'device.platform !== \'android\'' predates the platform set growing to
include linux and web - for those it would take the runner-session lock
and walk the lease cleanup path on relaunch for a device that can never
have an XCUITest runner. isApplePlatform() states the actual intent:
Apple non-simulator targets tear down, simulators stay hot, everything
else never touches the runner.
* refactor: quality pass over the iOS startup perf stack
Applied from a 4-angle review (reuse / simplification / efficiency /
altitude) of the #1011 diff:
- RunnerSession.child is now a RunnerProcessHandle (pid/exitCode) instead
of ChildProcess: every consumer only reads pid/exitCode and kills by
pid, the adoption surrogate no longer needs double casts, and the
compiler enforces what a comment used to promise. Real children satisfy
the handle structurally.
- session-open prewarm scheduling collapsed into schedulePrewarm/
awaitPrewarm closures; the fallback guard is a dedicated local instead
of reading the OpenTiming telemetry object (kept separate from the
promise itself, which is legitimately undefined when prewarm is
unavailable).
- adoption: expected derived path computed once and threaded through
(resolveExpectedRunnerCacheMetadata does a recursive source-stat walk
per call, and it runs under the lease lock); guard chain flattened into
skip(reason) early returns; probe timeout 2s -> 500ms (localhost
refusal is instant, the timeout only bounds the wedged-runner case);
dead path.dirname fallback removed.
- shared normalizeRunnerStartupTimeoutMs and buildRunnerSessionId in
runner-session-types; deleted the verbatim copies.
- detach kill switch parses through utils/source-value parseBooleanLiteral
(now exported) instead of a bespoke dialect that treated 'no' as
enabled.
- dropped buildDetachedRunnerLease's untriggerable idempotence guard,
direct Map iteration in the detach loop, and a freshness warning on
markSimulatorBooted for future callers.
* perf: keep iOS simulator runner hot across open --relaunch
open --relaunch tore down the XCUITest runner session before closing the
app, then paid a full xcodebuild test-without-building restart (~6s) after
reopening it. Simulator close/open go through simctl and never touch the
runner, so a healthy runner survives the relaunch; stale runners are
recovered by the readiness preflight + invalidate/restart path that landed
after #705 reverted #700's version of this.
Steady-state open --relaunch on iPhone 17 Pro sim: 11.0s -> 4.9s
(startup.durationMs 5929 -> 1574). Real devices keep the conservative
teardown.
* perf: drop redundant simctl inventory listings from the iOS open path
A single open --relaunch spawned `simctl list devices -j` (~0.7s each)
three times: session-device re-resolve, closeIosApp's booted check, and
launchIosSimulatorApp's booted check.
- ensureBootedSimulator now keeps a 5s recently-observed-Booted memo
(mirroring DEVICE_READY_CACHE_TTL_MS at the daemon layer) so repeated
boot checks inside one request cost nothing; shutdownSimulator
invalidates it, boot transitions seed it.
- refreshSessionDeviceIfNeeded skips the re-resolve while the device's
XCUITest runner session is alive - a live runner attached to the UDID
already proves the simulator exists and is booted.
With the runner kept hot across relaunch, steady-state open --relaunch on
iPhone 17 Pro sim drops 4.9s -> 2.85s (startup.durationMs ~1574 -> ~760).
Combined with the previous commit: 11.0s -> 2.85s.
* test: expect single simctl listing in tvOS provider flow
The recently-observed-Booted memo removes the repeat state listings the
launch and terminate boot checks used to make; the transcript now records
one listing per flow.
* feat: surface appleOs discriminant on public device output
Additive, non-breaking: the public device/result shapes now carry the
stored Apple-OS discriminant (iPhone/iPad/tvOS/visionOS/macOS) alongside
the existing leaf platform (ios/macos). Consumers can distinguish Apple
OSes on the wire instead of only the collapsed ios/macos leaves.
- session-inventory devices/session_list stop stripping appleOs and emit
it only for Apple devices (non-Apple omit it); platform stays the leaf.
- boot/shutdown success results (contracts/device.ts) gain optional appleOs.
- AgentDeviceDevice/AgentDeviceSessionDevice + client normalizers carry it.
- appleOs values never equal the internal 'apple' token, so the
apple-platform-output-guard is unaffected.
Tests: devices projection per Apple fixture (ios/ipados/tvos/visionos/
macos) + non-Apple omission; client normalizer coverage.
* fix: gate public appleOs surfacing to Apple platforms
appleOs is Apple-only, but the daemon projection (devices, session_list, boot,
shutdown) and the client normalizers preserved it whenever the field was present —
so a malformed/legacy non-Apple record carrying a valid Apple OS value would leak
it on the public wire. Gate every emission site on isApplePlatform(platform), not
just field presence.
Regression tests: a non-Apple device carrying a stray appleOs='macos' drops it in
both the daemon devices projection and normalizeDevice.
Process guardrail (addresses the recurring miss behind the Platform-collapse leaks):
- Testing Matrix: platform/device-response changes must also run test:integration:provider
and test:coverage — check:unit alone misses the provider-integration project (which runs the
apple-platform-output leak guard).
- PR Readiness: don't call a PR CI-green off a local --project unit run; the Integration Tests
and Coverage jobs run provider-integration, so verify those on the actual PR head.
* feat: route recording + provider platform gates through PlatformPlugin facets
Phase 3 step b.3 (issue #974): land the two deferred daemon-owned
PlatformPlugin facets as DATA-ONLY seams, mirroring appLog/perf.
recording facet: PlatformPlugin.recording.resolveBackendTag(device)
returns a neutral RecordingBackendTag string ('web'|'android'|'macos'|
'ios-device'|'ios-simulator'|'unsupported', daemon-owned, type-only in the
plugin). resolveRecordingBackendForDevice now maps the tag back to the
daemon-owned backend instance via RECORDING_BACKENDS_BY_TAG, with a
'?? unsupported' fallthrough for the factless linux family. Backend
instances + RecordingBackend type stay in the daemon.
providers facet: PlatformPlugin.providers.platformGatedResolvers declares,
per family, which platform-gated request provider resolvers apply (the data
that replaces each descriptor's hand 'device.platform === …' gate). The
daemon still owns resolver invocation, wrapper composition, and request-scope
concurrency isolation; only the gate moved to data. Ungated resolvers
(appLog/recording providers) stay ungated in the daemon.
Both facet types are type-only in the plugin (R3-clean, like LogBackend), so
no platforms/->daemon value edge and no CLI cold-start regression. Pinned by
two table-equivalence parity tests (independent verbatim oracle, full
platform x kind x target matrix, facet-presence + fallthrough + end-to-end
routing).
* docs: mark b.3 recording/providers facets as landed in CONTEXT.md
This PR routes the recording + providers PlatformPlugin facets, so the durable
architecture context can no longer list them as deferred/on-the-daemon-branch.
Update the Deferred section: all four b.3 facets now route through the plugin;
only the perf sampling body (buildPerfResponseData) still branches in the daemon.
* docs: retire plans/perfect-shape.md — roadmap complete
The perfect-shape roadmap (two-registry thesis: CommandDescriptor +
PlatformPlugin, typed-result spine, folder DAG + layering lint, agent-cost,
and the Apple apple+appleOs platform model with a non-breaking leaf wire) is
substantively complete and merged. Per its own §5 retirement note, the durable
decisions now live in ADR-0008 (command descriptor) and ADR-0009 (Apple/AppleOS),
and current-state terms in CONTEXT.md; this removes the last plan file.
- Delete plans/perfect-shape.md (plans/ is now empty and gone).
- CONTEXT.md: add "Architecture (perfect-shape refactor, completed 2026-07)"
end-state summary plus a "Deferred / next-minor" note (Phase 2c client-types
narrowing, b.3 recording/providers facets, strict DAG back-edge inversion,
legacy alias drops) so nothing is lost.
- Repoint every remaining perfect-shape.md/§ reference (ADRs 0003/0008/0009,
ci.yml, scripts/layering/check.ts, and the platform-plugin/apple comments)
to ADR-0008/0009 or CONTEXT.md. No dangling references remain.
Docs/comment-only; tsc, oxlint, oxfmt, and the layering DAG check all pass.
* docs: repoint dangling perfect-shape section refs before retiring the roadmap
Removing plans/perfect-shape.md left three comments citing bare section numbers
with no surviving target. The rationales are already inlined, so drop the numbers
(and point the do-not-flatten note at the durable ADR):
- src/platforms/apple/plugin.ts: `(§7)` -> "do-not-flatten; see docs/adr/0009".
- src/core/interactors/register-builtins.ts: "the §5.1 ... sketch" -> "an ... sketch".
- scripts/layering/check.ts: drop `(§5.5 ...)`, keep the inline "re-export barrels only".
Adds a provider-integration guard (apple-platform-output-guard.test.ts) that
stands up a fake-provider daemon for BOTH a macOS Apple session and an
iOS-simulator Apple session, drives EVERY public command off PUBLIC_COMMANDS,
and deep-scans each serialized response for the internal 'apple' platform token
(any string VALUE or object KEY that exactly equals 'apple').
The guard is catalog-driven: a partition test fails if a new public command is
neither in DRIVEN_COMMANDS nor SKIPPED_COMMANDS, so a new command can't silently
escape the check. All 50 public commands are driven; the skip-set is empty.
Caught + fixed a leak PR #1004 misses: doctor's data.platform (session-doctor.ts)
echoed the raw internal device.platform ('apple') when doctor ran against a bound
session with no --platform flag. Now projected via publicPlatformString(device).
doctor's byPlatform.apple KEY leak stays tracked to PR #1004 via a narrow,
documented allowlist entry (not duplicated here).
The Platform collapse projected most `doctor` output but missed the device
inventory breakdown: `deviceInventoryEvidence` keyed `byPlatform` by the raw
internal `device.platform`, so an Apple session emitted `byPlatform: { apple: … }`
in the doctor response — a machine-facing `apple` leak (approach b keeps output
leaf `ios`/`macos`). Key it by `publicPlatformString(device)` instead (now
`Map<PublicPlatform, …>`, so an `apple` key is compile-impossible), which also
splits Apple devices into ios vs macos in the breakdown.
Also project two doctor command SUGGESTION strings (`apps --platform …`,
`close --platform …`) to the leaf — `--platform apple` was valid but broader than
the session's actual device.
The doctor descriptor routing (command-descriptor `daemon` block) and its lack of
a platform-specific `supports` gate are already correct; toolchain checks already
accept `apple`. Verified: tsc, oxlint, oxfmt, layering, fallow, provider-integration
82/82; full unit green except the known android fillAndroid contention flake
(passes 92/92 in isolation).
* refactor: collapse public Platform ios/macos into apple (#979)
Phase 3 d.3: collapse the internal `Platform` union from `ios`/`macos` to a
single `apple` platform, with `appleOs` as the sole OS discriminant. Approach
(b) NON-BREAKING: the daemon still ACCEPTS the legacy `ios`/`macos` selectors on
every read path and still EMITS the leaf `ios`/`macos` strings on every output,
so machine consumers see no change.
Kernel (src/kernel/device.ts):
- PLATFORMS = ['apple','android','linux','web']; add PUBLIC_PLATFORMS (leaf) and
PublicPlatform; PLATFORM_SELECTORS keeps legacy `ios`/`macos` as input aliases.
- New predicates: isMacOs (appleOs- or legacy-leaf-based), isIosFamily (the
post-collapse equivalent of `platform === 'ios'`), publicPlatformString (output
projection), deviceFieldsFromPublicPlatform (inverse), isPublicPlatform.
- isMobilePlatform and matchesPlatformSelector are now device-aware (appleOs).
Discovery now stamps `platform: 'apple'` (+ appleOs); ~125 internal
`device.platform === 'ios'|'macos'` branch sites migrated to the predicates,
behavior-preserving. Apple plugin owns `['apple']`; platformDescriptors collapse
to one `apple` row.
Output projection (approach b) emits the leaf via publicPlatformString at:
devices / session_list (session-inventory), boot / shutdown / appstate /
prepare-ios-runner (session-state, session), the selector/backend platform
(selector-runtime/screenshot-runtime/snapshot-runtime/interaction-runtime),
proxy device key, request-lock backfill, runtime-set binding, click-button
validation, and both `.ad` context-line writers.
Contracts/client keep leaf types (PublicPlatform); read paths (parsePlatform,
REPLAY_METADATA_PLATFORMS, matchesPlatformSelector) accept `apple` + legacy
leaves. Adds a parity test gate (platform-collapse-parity.test.ts).
Refs #979 (part of #972).
* fix: project platform to the public leaf at open/perf response sites (#979)
The Platform collapse left two daemon response builders emitting the raw
internal `device.platform` ('apple'), which the client normalizer rejects
(isPublicPlatform excludes 'apple') — dropping the resolved device from the
response:
- session-open-surface.ts: `open` result `platform`/device projection.
- session-perf.ts: the perf/frames/memory base response builders.
Both now go through `publicPlatformString(device)`, so output stays the leaf
`ios`/`macos` per approach (b). (The android/non-apple perf branches were
already leaf-safe.)
Also update macos-desktop provider test: the lifecycle mock observes the
INTERNAL DeviceInfo, which is now `platform:'apple'` (+ appleOs:'macos'), so the
recorded tag is `prepare:apple:desktop`.
Fixes the provider-integration assertions that blocked both the Integration
Tests and Coverage CI jobs (both run the provider-integration project).
Verified: provider-integration 82/82, coverage passes, tsc/oxlint/oxfmt/layering/
fallow green.
* fix: project platform to the public leaf at nested output sites (#979)
The Platform collapse (approach b) projects device.platform through
publicPlatformString at emit sites so machine consumers keep seeing the
leaf ios/macos and never the internal `apple`. Several nested output
fields were missed. Project them and narrow their emitted types to
PublicPlatform:
- Apple perf memory snapshot support (buildAppleMemorySnapshotSupport) —
response.support.platform / artifact.support.platform, plus the
sibling sampleAppleFramePerf error data.
- Apple xctrace perf capture/result platform surfaced in the perf
cpu-profile started/stopped response data.
- snapshotDiagnostics.stats.platform (recordSnapshotTiming) surfaced in
snapshot/test response data and the slow-snapshot warning string.
- doctor target-app evidence.platform + human summary, and doctor
target-app-device evidence.booted[].platform.
- provider/cloud UNSUPPORTED_OPERATION error.data.platform for cloud
Apple devices (reachable via deviceFieldsFromPublicPlatform).
Internal 'apple' emissions (selector-matching input, diagnostic
emitDiagnostic telemetry, session appLog state, replay .ad flags) are
left as-is. Adds focused tests pinning Apple perf memory support to the
leaf and a guard asserting no emitted platform field equals 'apple'.
* feat: polish replay test progress reporter
* test: stabilize replay reporter cursor test in CI
* refactor: dedupe replay reporter live progress checks
* fix: make Expo build cache path configurable
Phase 3 d.1. Makes getInteractor's core -> platforms routing the final shape by
moving the Apple-specific plugin pieces under src/platforms/apple/ while keeping
the generic registry where non-interactor core code can still import it.
Moved to src/platforms/apple/:
- plugin.ts - the applePlugin instance (APPLE_SUPPORTS_BY_DEFAULT closures,
appLog/perf facets, createInteractor/discoverDevices), extracted
from the former core/platform-plugin/register-builtins.ts
- interactor.ts - was core/interactors/apple.ts (createAppleInteractor)
- interactions.ts - was platforms/ios/interactions.ts (the Apple interaction
dispatcher: iOS synthesized gesture / tvOS remote-press /
macOS desktop-scroll); platforms/ios/ is now removed
- __tests__/watchos-sentinel.test.ts - co-located with its subject
Stayed in core/ (layering):
- The generic registry + PlatformPlugin type stay in core/platform-plugin/plugin.ts.
core/capabilities.ts (non-interactor core) imports getPlugin/tryGetPlugin, and R3
forbids core outside core/interactors/ from statically importing platforms/, so the
registry cannot move.
- register-builtins.ts moved to core/interactors/register-builtins.ts (still core/):
the android/linux/web wiring plus the registry-population entry point. As an
interactor-seam module it is the one place R3 permits a static value import of
platforms/, so it pulls in applePlugin and keeps the exhaustiveness assertion.
- apple-os-capabilities.ts stays in core/ (smallest move; the moved Apple closures
reach it via a legal platforms -> core import).
Layering guard passes (R1/R2/R3, 678 files). The applePlugin only reaches leaf code
via lazy dynamic import(), so the new static platforms/apple/plugin.ts import at the
seam does not regress CLI cold-start.
Introduce a per-`AppleOS` capability data table
(`src/core/platform-plugin/apple-os-capabilities.ts`) — the capability-axis
sibling of `RUNNER_PLATFORM_PROFILES` and the Swift `#if os()` guards — and have
the Apple capability closures read `device.appleOs` through it, collapsing the
scattered `target !== 'tv'` / `platform !== 'macos'` / `isTvOsDevice` predicates
into one lookup (`resolveDeviceAppleOs` + `appleOsCapabilities`).
Discipline (perfect-shape §7 / ADR-0009 step d.5): relocate the OS-axis
predicates into data, never change behavior. Only the AppleOS-shaped facts moved
to the table; the DEVICE-shaped nuance (simulator vs physical device — e.g.
two-finger synthesis is iOS-simulator-only, and the physical-iOS hint) stays in
the reading closure, and non-Apple branches keep their verbatim verdicts
(`appleOsCapabilities` returns `undefined` off the Apple family).
Parity gate: a new table-equivalence test
(`apple-os-capability-table-parity.test.ts`) pins the table-driven closures
byte-for-byte against an INDEPENDENT verbatim copy of the original predicates
across the full {command × sample-device} matrix, and the existing
capability-plugin-routing-parity test is extended with appleOs-bearing
iPadOS/visionOS fixtures so the stored-`appleOs` read path is covered
(iOS/iPadOS/tvOS/macOS/visionOS).
Deferred (behavior-change risk / out of scope): the `isTvOsDevice` interaction
leaves (dispatch-interactions, interactors/apple, platforms/ios) are irreducible
per-device gesture/focus synthesis, not capability admission; per-gesture table
granularity (pinch/rotate/transform) is left as one `multiTouchSynthesis` field
since all three are uniform today.
* feat: add doctor command
* fix: reduce doctor command complexity
* fix: classify doctor integration flags
* fix: simplify doctor setup
* refactor: split doctor checks
* fix: simplify doctor check set
* fix: include stopped android avds in devices
* fix: report doctor device inventory
* refactor: reuse device inventory selectors
* fix: summarize doctor inventory by platform
* fix: show metro cwd in doctor
* refactor: simplify metro doctor lookup
* fix: update doctor imports after apple consolidation
* feat: make doctor Metro probe controllable and surface hidden toolchain failures
Two gaps found while verifying the doctor command on a real environment:
- Metro host/port were uncontrollable from the CLI: --metro-host/--metro-port
were rejected by allowedFlags, and readDoctorOptions only read them from
req.runtime (populated by remote/connection profiles, never a plain CLI
flag). The Metro check's own hint told users to 'pass the correct
--metro-host/--metro-port', which did not exist. Declare the flags and read
them from req.flags (runtime kept as fallback) so the probe can target any
endpoint, e.g. from outside an RN/Expo project directory.
- A broken per-platform toolchain was silently hidden: readDoctorDeviceInventory
dropped inventory failures whenever any other platform returned devices, so a
broken Xcode or Android SDK still reported a green 'pass'. Keep the failures
and surface each as a warn (device-<platform>) when other platforms have
devices; scoped --platform runs stay quiet.
* fix: align doctor CI expectations
* feat: extend doctor preflight checks
* fix: keep doctor checks within ci gates
* fix: simplify doctor metro surface
* refactor: trim doctor bundle impact
* fix: restore useful doctor diagnostics
* refactor: reuse doctor output helpers
* refactor: share device inventory grouping
* refactor: keep doctor focused on preflight checks
* refactor: simplify doctor toolchain probes
* fix: keep scoped simulator hint generic
* fix: clarify doctor Xcode selection context
* fix: recognize provider scope in remote doctor
* fix: address doctor review gaps
* fix: keep doctor metro checks inferred
Add the `perf` facet to `PlatformPlugin`, typed platform-neutral as
`{ supportsMetrics(device: DeviceInfo): boolean }` (never the iOS provider
seam). Populate it by wrapping the Apple + Android arms of the existing
`supportsPlatformPerfMetrics` predicate verbatim (both return `true`), leave
linux/web factless, and route `supportsPlatformPerfMetrics` in
`daemon/handlers/session-perf.ts` through `tryGetPlugin(...).perf?.supportsMetrics`
with a `?? false` fallthrough that preserves the former hand disjunction.
Pinned by a table-equivalence parity test
(`daemon/__tests__/perf-plugin-routing-parity.test.ts`) that mirrors the merged
appLog gate: an independent verbatim copy of the former predicate is the BEFORE
oracle across the exhaustive platform x kind x target device matrix, facet
presence/fallthrough are asserted, and `buildPerfResponseData` is exercised with
no-app sessions to prove the daemon actually routes through the facet.
Only the support gate is routed; the perf sampling body (`buildPerfResponseData`)
and the Android-only native-collector gate stay on their daemon branch until each
clears the same gate. Follows the merged appLog facet template (type-only
core->daemon edge, lazy `registerBuiltinPlatformPlugins()`).
Refs #974