Conversation
Maestro killApp triggers system-initiated process death (adb shell am kill on Android) instead of stopApp's force-stop. On other platforms it aliases stopApp through the shared close dispatcher. - contracts: optional Interactor.kill, CloseApplicationInput.mode, with fallback to close in invokeApplicationClose - android: killAndroidApp (am kill) wired onto the interactor - maestro: killApp IR kind, parser, runtime port, daemon projection to an app-only close carrying killApp dispatch, conformance canonical - daemon: replay dispatch folds killApp; session close passes mode kill into closeApplication - upstream/116_kill_app now classifies identical; divergence removed
executeLifecycleCommand grew a sixth case for killApp and tripped the complexity gate. The stopApp/killApp/clearState legs share one shape, so dispatch them through a lookup instead of three switch cases.
am kill only reaps background processes, so a foreground killApp used to succeed without killing anything. Refuse a foreground target naming the background-first precondition, and verify the process is gone after the kill instead of reporting an unproven success.
|
Reviewed 78727e3. I found no code defect, but the kill route has only run against adb fakes, and I have one smaller-design question. The route (Maestro replay → close with Could this be smaller? Only the Android lifecycle owner reads Not blocking: the support matrix and replay-e2e.md do not say that a foreground killApp is refused rather than a silent no-op; a process that survives All checks are green and there are no conflicts. I could not check whether upstream Maestro's killApp also refuses a foreground app. |
…emon (callstack#2736) * fix(ios): serve a simulator recording whose recorder died with its daemon A simctl reattach answered `missing` as soon as the recorder process was gone, which is the ordinary state after daemon loss, so a retried `record stop` threw `resource-missing` forever even with the recorder's video on disk. The simctl descriptor now carries the caller-facing export coordinates, so a proven-gone recorder whose file still passes the container sniff reattaches as a handle that runs the same stop-and-export sequence the live recording would have run, and discloses the touch overlay whose events did not survive the daemon. A manifest written without those coordinates is answered exactly as it was before, because it cannot name an export it never recorded. * fix(ios): resume a recovered simulator export from what the first stop journaled A recorder that died with its daemon proved nothing about the export its file can still become, and neither did a manifest whose stop had already collected a copy or journaled a finalization. Reattach decided from the recorder's own path alone, so a retry after those steps found no file there and reported a loss the manifest contradicted. It now asks what a resumed stop would still have to read, which the shared stop sequence answers from the checkpoints the first attempt wrote. The coordinates a recovered export needs are the caller-facing keys of the live snapshot, so the facet is a Pick of it, encoded and restored by spread, and validated by the recording-facts validator Android's descriptor already needed — moved to capture-kit so both backends answer with the same strictness. A recovered handle refuses an overlay whose gesture events died with the daemon instead of running the overlay pass with nothing to burn in and then reporting it unavailable. * chore(gates): declare the capture-kit recording-facts subpath in the boundary enumeration
…ead (callstack#2188) (callstack#2750) * fix(ios-benchmark): wait out unmounted fixture screens during setup admission The proxy leg stopped on `fixture-anchor` for every deep-linked screen it reached: the persistent client reads the anchor right after `apps.open`, and that read lands before the app mounts its first tree. Measured on the fixture app, the snapshot taken immediately after an `open --relaunch --launch-url` returns the single Application node, while a read 1.5s later returns 31 nodes with the anchor. The fresh-process CLI leg hides the same gap behind its per-read process spawn. Setup reads are untimed, so the shared admission now re-observes until the expected anchor is exposed, bounded by FIXTURE_ANCHOR_ADMISSION_BUDGET_MS, and still stops the run on the same typed reason when a fixture never exposes it. An unsuccessful observation keeps failing immediately rather than polling. Also repairs the test module's stale `FixturePreparationResult` import: the scripts directory is erased rather than typechecked, so the rename to `FixtureOperationResult` went unnoticed. * docs(evidence): record the iOS snapshot convergence final head The sweep that gated callstack#2188 on callstack#2199 completed at `7c434b5758`: cold/cold-cold, first-interaction, warm/relaunch with the package-size leg, and the 0/20/80 ms proxy matrix, every leg clean and free of failed samples. The four Markdown summaries ride along; the record states what the numbers prove and the deviations that bound them — target and runtime, host load, the fixture app's changed trees, and the 196-commit span behind the package-size delta. Two findings that should not travel quietly: the warm snapshot daemon cost is about double the baseline under a controlled side-by-side diagnostic, and the callstack#2198 slice corpora come from unmerged branch heads, so they are not parity baselines for anything. * chore(gates): pin the iOS snapshot convergence final corpus `PUBLISHED_EVIDENCE` becomes `PUBLISHED_CORPORA`: the pinned evidence is now two corpora measured at two revisions on two runtimes, each carrying its own tag and evidence commit, and `PUBLISHED_EVIDENCE` derives from them. A missing file is now fetched with a hint naming the corpus that actually holds it, instead of a single tag that is wrong for one of them. Disjointness, naming, and the tag and commit each corpus is cited by are tests rather than comments. Also stops exporting the setup admission's internals, which the dead-code audit rightly flags.
…space (callstack#2737) The fold response's `screen.widthPt`/`heightPt` are the lit panel's native points (pixels divided by its point scale, never rotated), not the next snapshot's viewport. Add the required `screen.coordinateSpace: "native-panel"` discriminator at the construction site, export it from the owning contract so the Apple owner and the MCP schema share one value, and correct every doc and message that implied these numbers could place a tap. A fresh snapshot stays the only source of app-viewport coordinates: on an open Duo the 669x951 inner panel hosts a 951x669 app window. Closes callstack#2729
* feat(cli): read a recording with record contact-sheet * chore(gates): publish the contact-sheet capture-kit facade
…ck#2724) * fix(ios): rotate synthesized gestures in the capture viewport The synthesized lane derived its rotation frame from XCUIScreen.main.screenshot() image size while capture normalizes node rects into the acquisition viewport (app.frame). On an iPhone Duo in the open pose the runner's main screen is the dark outer panel, so dispatch was not the inverse of capture and taps drifted by a constant offset (hit the Home tab by luck, missed Settings). Use app.frame as the reference frame for every synthesized lane so dispatch is capture's inverse on any panel and orientation, and assert that inverse as a round-trip unit test including the Duo inner panel (669x951, rot90). Drops the per-gesture main-screen screenshot. * fix(ios): route foldable interactions through the resolved window * test(apple): read snapshot capture guard from the acquisition module captureSnapshotRootBounded moved to RunnerTests+SnapshotAcquisition.swift with the capture split; the source-shape guard still read RunnerTests+Snapshot.swift, failing the Coverage job. * feat(ios-runner): log synthesized dispatch geometry and record routing The dispatch point after rotation, the reference window it was computed against, and the display a record is routed to are the three facts that decide whether a synthesized event lands. runner.log now carries them (SYNTHESIZED_DISPATCH, SYNTHESIZED_RECORD) so delivery regressions are readable without instrumenting a build. Documents the origin invariant CoordinateSpaceRotation relies on. * fix(ios-runner): keep the os(iOS) directive on its own line in synthesizedCoordinateContext The formatter had joined it to the opening brace, which the selection scanner's line-start directive match misses, so the else branch parsed as unmatched. * refactor(ios-runner): resolve the interaction window through one helper The viewport, the interaction root, and the synthesized reference frame each walked app.windows with their own exists/frame rules, so a sheet or a fold could move one consumer without the others. resolveRunnerWindow is now the single walk: the first window with a non-empty frame, the application otherwise. Alert activation logs the chosen button's label and frame center so a tap that lands beside its button is visible in runner.log. * fix(ios-runner): route synthesized display IDs through the resolved window The gesture reference frame is booked against the window resolveRunnerWindow picks, but record creation re-walked windows.firstMatch for the display ID. With an empty window 0 beside a lit window N the reference lands on N while the gesture routes to window 0's display, or fails outright. The bridge now takes the resolved window: the gesture entry points accept it, record creation reads the display from it, and every remaining firstMatch geometry read (keyboard avoidance, touch reference frame, system edge gestures) goes through the shared resolver. The interaction anchor and its frame come from one resolution pass, keeping the coordinate offset on the window the tap was measured against. Unit tests cover empty-window-0, absent windows, and the pure window-index rule. * fix(ios-runner): anchor iOS coordinate interactions at the app origin `interactionCoordinate` resolved the interaction root through `resolveRunnerWindow`, so an iOS coordinate tap/double-tap/long-press/drag anchored on the first qualifying window and subtracted its frame origin. On SpringBoard that first window is not necessarily the alert's — the wallpaper or status-bar window can qualify first — so `alert accept` (reached via activateElement, tapAt, performCoordinateTap) missed the Open button, which is why iOS Smoke failed at "wait for Automation lab" after the deep-link confirmation. Restore the main-branch iOS app-origin anchor (app.coordinate(0,0) + the snapshot-space point). Reference frames and the synthesized display ID still resolve through `resolveRunnerWindow`, so a foldable tap keeps its panel and routes on the resolved window's display. macOS keeps the window-relative anchor. Refs callstack#2724 * refactor(ios-runner): fail a gesture with no resolved window directly The gesture callers resolve the window in Swift before the call, so a nil resolved window already means no window qualified. Walking windows.firstMatch a second time in record creation could only book a display that every Swift caller had refused to book geometry against; the missing window is now the error. * fix(ios-runner): refuse a synthesized context with no resolved window resolveRunnerWindow reports "no window" honestly: it returns nil alongside app.frame when no window has a non-empty frame. synthesizedCoordinateContext still built a context from that frame, so a synthesized drag or pinch carried a nil window into record creation and failed with "no resolved application window" on a case main dispatched. The context now refuses to exist without its window, which sends the gesture down the XCUITest fallback that already covers it and lets SynthesizedCoordinateContext.resolvedWindow be non-optional. Refs callstack#2724 * refactor(ios-runner): drop the unused application display ID resolver Every synthesized gesture routes its display through the window it already resolved for geometry, so the wrapper that re-walked windows.firstMatch had no production caller left. Its assertions move to RunnerResolveWindowDisplayID, so the resolve-before-read ordering, the primary-window identity, and both refusal branches stay under test. Refs callstack#2724
…allstack#2738) Drives one booted iPhone Duo through closed -> half-open -> open -> closed and asserts the app reacts after every pose change: a tab press routes and selects, Add to cart moves the cart counter, a scroll reveals a canary, a long press moves its dedicated count, and one multipointer pinch changes a recognized scale. Coordinates are derived from the current target's bounds and refs are re-resolved after each fold, never reused across a pose change. Local/manual coverage only; GitHub Actions cannot schedule it yet (no hosted Duo runtime or registered Duo runner). --demo-geometry-mutation derives the pinch origin from a different control's bounds to prove the scale assertion is sensitive to targeting geometry. Refs callstack#2731
…stack#2755) * docs(adr): record scroll clip authority for iOS snapshot presentation An interactive snapshot of a list dropped every row after a row holding selectable text: a UITextView is a UIScrollView, XCTest publishes its indicator inside the text, and the presentation attributed that indicator to the surrounding list, so the list's visible band became one line of text and everything below it was clipped (callstack#2214, fixed by callstack#2740). The type-specific fix closed one instance. ADR 0026 records the rule that keeps the class out: indicator ownership is a fact the producer reports, the visible band stays host interpretation, and an inferred attribution gets hint authority only — it cannot eject a node. Reshape and eject become separate rule APIs, scroll capability becomes a matrix instead of one boolean answered five different ways, and every ejection carries a typed reason the differential can check. Design contract: callstack#2754. * docs(adr): read scroll indicator ownership from the parent edge Review found the first draft built eight contracts on top of a bug that a parent-edge lookup removes. UIKit publishes an indicator inside its own scroll view and projection preserves that edge, because every scroll host is regular-eligible in both languages; the ancestor walk past the parent is what misattributed the text view's indicator in the first place. Reading the parent instead retires the proposed IosRunnerPresentation field, its capture-local indexing and remapping, the ownership capability matrix, and the two-tree split. Also corrects the band's premise: the pinned Settings tree has the CollectionView frame at 0-874 with an indicator track at 116-812, so the frame spans the bars and safe area rather than reporting a content extent. Drops the two-API reshape/eject split, which does not hold against ~23 suppression sites across ten rules, and against a reshape surface that still accepts rect. Keeps the decision: ownership is read, ejection needs evidence, every removal carries a disposition. Measured while revising: the full unit suite passes with parent-edge ownership, and a synthetic WebView row keeps rows that main drops. * docs(adr): name scrollarea as the iOS-scoped exception Review asked the eligibility claim be scoped, since isScrollableSnapshotType also accepts scrollarea and neither eligible set contains it. Verified the scope is real rather than hypothetical: macOS desktop capture reaches these rules through snapshot-desktop-surface -> ios-snapshot-runtime -> publishIosSnapshot, and its helper emits ScrollArea from AXScrollArea while the iOS runner never does. So "every scroll host is regular-eligible" is written as an iOS claim, with a ScrollArea decision owed on that surface before the parent-edge rule is reused there. Also names the divergence left vague before: only tree.ts accepts scrollarea. Counts made exact while there: 23 suppression sites across ten rules, and scroll and noise-overlay already patch rects while ejecting. * docs(adr): scope visibility ejection and name the real rect-rewriting pair The removal row read as if every ejection needed a clip-fold result or a parent-edge owner. Nineteen of the 23 sites delegate semantics or drop decoration and keep their own authority, so the row and the paragraph now name the four visibility-driven sites the ADR actually governs, plus the clip fold's inclusion decision, and a second row states that compaction is out of scope. Corrects the rect-rewriting pair: transitions rewrites a field's rect and ejects the title and image in one pass, while noise-overlay patches rects in a presentation function that receives replacements only and ejects from a separate suppression function. That module already has the split the rejected API proposed, so it is cited as the precedent instead. Names why the runtime guard was rejected: a threshold either fires on ordinary compaction or is loose enough to pass a 74 to 20 collapse. Cites callstack#1797 for the fold's leak class with callstack#1784 adjacent instead of claiming one class, drops the drafting narrative from the refuted field, records the unpinned self branch, and notes the table's own self case.
…callstack#2741) * fix(ios): capture the display that owns a window, not the main screen A foldable lights one panel at a time while XCUIScreen.main names one fixed panel, so an app on the other panel was captured as a valid PNG of black. The runner now asks the target app's window first and the system surface's window second, reads the display off that same window instance, and encodes the capture upright, reporting the display ID, the pixel size it encoded, and the pixels-per-point of that image so the host normalizes density from the capture instead of a panel it guessed. The home screen is SpringBoard's window, so a capture with no session app still has a display that owns a window; a capture fails closed with a typed reason only when neither resolves one. An app with no window is refused on the query's own answer rather than on geometry alone. The screenshot command skips the app-activation preflight, so it resolves the requested bundle id for observation without ever foregrounding an app to see which display it is on. Co-authored-by: Apex <noreply@callstack.com> * test(ios-runner): restore the window-path order proof and refuse typed screens The fixture resolved on the exists read, so the window-path tests resolved the window before the call and could no longer see a screen-first change route a foldable gesture to display 1. The frame read resolves again, the exists pre-reads are gone, and two new refusals pin the window path to the typed screen reason: a window answering display ID 0 and a window whose screen read raises both fail with no resolved window display ID. The window resolver's frame read now sits inside the exception catcher, so the raise answer carries the same typed window refusal and log line the application walk gives. * test(ios-runner): pin the caught frame read on the window display path The window resolver moved its frame read inside the exception catcher, but no test drove a frame lookup that raises, so the catcher was carried on the comment alone. KVC raises on a key the runtime stops answering, which is how a private XCTest read fails when it renames, and the gesture path hands this window across a command boundary. The case now expects the typed window refusal and leaves the caller's display ID untouched. --------- Co-authored-by: Apex <noreply@callstack.com>
…ain carries it (callstack#2757) callstack#2747 approved packages/capture-kit/src/recording/contact-sheet.ts over its domain-facade ceiling. Main now carries the entry, so the no-growth rule governs it and the row can no longer change a verdict. The stale-row check defers on main itself, so every branch based on main after callstack#2747 failed Coverage with "no APPROVED_OVER_CEILING row is stale". Co-authored-by: Claude <noreply@anthropic.com>
…tack#2754 step 1) (callstack#2758) Ownership of a scroll indicator is now read from the producer's parent edge, passing up only through ancestors that share the scroller's exact frame, instead of walking for the nearest scrollable or text node. A scroll-typed node labelled as an indicator describes itself and owns nothing. This stops an indicator from a scroll-shaped host that publishes as a non-scroll type (a WebView row, a map, a paged cell) from clipping the enclosing list to the host's band (callstack#2214), while a WKWebView page whose wrappers fill the ScrollView keeps its visible band (callstack#1784/callstack#1797). The walk stops at the first frame change, so a host smaller than its list still owns nothing. ADR 0026.
…r macOS (callstack#2754 step 4) (callstack#2759) `REGULAR_ELIGIBLE_TYPES` (projection.ts) and `eligibleInteractiveTypes` (SnapshotPresentationProjection.swift) are one fact in two languages: they keep a scroll host alive through regular projection so the parent-edge ownership rule can find it. No test referenced either list, so they could silently drift. `eligibility-parity.test.ts` reads both literals from source, normalizes them with the runtime's `normalizeType`, and fails on drift (verified by planting an extra member). A production export was rejected in favor of source parsing so the sets stay module-private. The `scrollarea` asymmetry is settled for the macOS surface with a case rather than by editing a set for iOS's sake: the desktop path (snapshot-desktop-surface -> ios-snapshot-runtime -> publishIosSnapshot) reaches these rules and its helper emits `ScrollArea`. A content-bearing `ScrollArea` survives eligibility and owns its band under parent-edge ownership; a label-less one is dropped and its indicator resolves no owner, which under-clips (safe) instead of mis-clipping the enclosing list — strictly safer than the retired ancestor walk. So the type stays out of both eligible sets, and the guard pins that exclusion. Co-authored-by: opencode <noreply@opencode.ai>
* feat(ios): support timed hinge keyframes * fix(ios): preserve fold timelines in replay and verify boundary angles * fix(ios): terminate guest fold animation on cancellation * chore(gates): classify timed fold keyframes as device-observable * chore(ci): retrigger checks for fold trajectories
) When 0.21.0 made the host AX bridge the iOS Simulator snapshot source, the bridge node reader mapped the traits word into enabled but never into selected, so a selected: selector or Maestro selected: qualifier stopped matching the active tab, segment, or checked row. Restore the fact by reading the selected-trait bit of the same word, publishing selected: true only when set and omitting it otherwise to match the XCTest tree. Pin the bit against the guest's real captured words and fold the traits parse into one shared source so a producer encoding change cannot drop it silently.
…allstack#2712) (callstack#2765) * fix(ios): pay for one toolchain in the snapshot bridge identity read The identity read probed `xcodebuild -version` and then `xcrun --sdk iphonesimulator --show-sdk-version` — two Xcode-owned binaries, each a tool the host can stall on before it answers. On CI the `xcrun` probe lost twice at the shared 30 s ceiling and took the whole iOS smoke job down over a cache-key field (60.9 s of a 120 s budget, job 106068926059). The Simulator SDK ships inside the selected `Xcode.app`, so the version and build `xcodebuild -version` already reports pin it: the field left the cache key and the exec left the identity read. The `xcrun clang` the build runs pays that wait once, under the build's own ceiling, where a stall of that length fits instead of exhausting the budget. A probe or compile that cannot answer now says so at the seam that waited: `timeout` with `toolchain-probe-stalled` or `native-build-stalled`, the command, the budget each attempt was armed with, and the exec layer's kill kept as the cause. Before, a job got a bare `xcrun timed out after 30000ms` stack that reads exactly like a device failure. Cache entries written before this hash to a different key, so they rebuild once instead of being misread; no schema bump is needed to prove that. Closes callstack#2712 * test(ios): take the snapshot bridge stall fixtures from the exec layer Both stall fixtures built the timeout error by hand, so a probe could keep classifying a kill `exec.ts` no longer reports as one. They now run a command the exec layer really kills and carry whatever error it raises; the probe assertions still read the shape through `isCommandTimeoutError` rather than a message. Review note on callstack#2765.
…e and survives the selector digest (callstack#2920) Review follow-up: `contentDescription` joined the presentation-equivalence key and the digest allowlist without a case in either table, so removing it later would have left both suites green.
… declared fact (callstack#2908) * fix(ios): carry the snapshot viewport across the runner boundary as a declared fact The runner spelled "viewport unknown" as CGRect.infinite, which SnapshotGeometry.isGeometricallyActionable read as "everything is actionable" before computing any center, while the host-AX path models the same state as IosViewportEvidence and declines to publish the bit. One state, two encodings, opposite directions, across a language boundary. SnapshotViewport now carries the three cases the host already uses -- reported, derived, and missing { reason } -- and refuses an unusable box at declaration, so no caller has to re-check what it was handed. The unknown-viewport policy is stated once, at the Swift decision site, and fails CLOSED: no box means no supportable claim about where a tap lands. Clipping stays a separate question from containment; a capture with no box has no clip and no root clip for the cumulative invariant to violate, which is an absent answer rather than an unbounded one. * test(ios): pin the hittable predicate with a cross-language vector table The fixed 320x240 fold fixture cannot reach an edge-of-viewport center, a node rect neither language's null/empty check refuses, or an unknown viewport, so the predicate's two implementations disagreed on two of them: CGRect.infinite is built of finite Doubles and its center is (0,0), and a negative width passes an empty check while its center lands inside -- both actionable in Swift and not actionable in TypeScript. The private AX bridge's JSON frame parser can hand over either. contracts/fixtures/snapshot-actionability-policy.json is replayed by ActionabilityPolicyTests (Swift) and by scripts/ios-snapshot-differential .test.ts (TypeScript), which also asserts the host's own instrument: resolveViewportEvidence refuses a regular presentation without a positive finite viewport, which is why the TypeScript predicate has no unknown viewport case. Rows come in pairs sharing one center so a `false` that the guard, rather than containment, decides cannot go quiet. * refactor(ios): plot the quarter-turn test through the shared rect predicate `SnapshotGeometrySpace.isPlottable` and `SnapshotGeometry.isPositiveFinite` were the same five comparisons, written twice in one package, which is how callstack#2891 started: one question with two encodings that can drift apart. `.null`, an inverted box, and an infinite box are refused identically by both, so the coordinate-space table needs no new rows. * fix(ios): make the viewport type the only place a box becomes a fact `SnapshotViewport.reported(box:)` normalised an unusable box to `.missing(reason: .invalid)`, and the commit said no caller had to re-check what it was handed — while `.reported(rect:)` stayed a public case that accepted `CGRect.infinite` on the way past that claim. The associated value is now a box with an internal initialiser, so outside this package a viewport fact is only reachable through the two factories, and the case labels cannot be named around them. MSG2 * test(ios): write down the one input the two predicates cannot share The table replayed `{"infinite": true}` as `CGRect.infinite` in Swift and as a rect with infinite components in TypeScript, so its infinity row never put the same number in front of both sides. `CGRect.infinite` is made of finite Doubles: TypeScript would call those components actionable, and only the platform that can name the value can refuse it. The row is now Swift-only and carries `asymmetry` naming that, every row declares both `swift` and `typescript`, and a shared non-finite row replaces it with numbers both sides actually see. Also states three claims the code had not been checked against: a published `hittable: false` is retained rather than filtered out, the runner's viewport fact decides only runner bits because the host re-derives its own from the capture's root node, and `viewportFromRoot` — not only `resolveViewportEvidence` — is what makes the TypeScript `viewport` argument total. * fix(ios): leave hittable absent without a viewport box; only a reported viewport anchors a rotation A capture with no viewport box can still decide a disabled or degenerate node, but not containment, so the runner now omits hittable instead of declaring false, matching the host bridge's undefined bit. The interface orientation moves into SnapshotViewport.reported, so a derived or missing viewport cannot anchor a rotation, and SnapshotGeometrySpace takes the viewport fact rather than a sentinel CGRect. * chore(gates): route the actionability vector table to the differential lane contracts/fixtures/snapshot-actionability-policy.json is read by both sides of scripts/ios-snapshot-differential, and packages/kernel/src/rect.ts holds the TypeScript predicate that table replays, so a change to either must select that lane rather than rely on the generic golden-table rule reaching only the unit and Swift builds.
…runXcrun runs simctl (callstack#2917) * refactor(platform-apple): brand the simctl command that runXcrun runs buildSimctlArgsForAddress and buildSimctlArgsForDevice now return a ScopedSimctlCommand, minted in core/tool-provider.ts from set-scoped arguments. runXcrun takes that command or an argv whose tool is a named non-simctl tool (devicectl, xcdevice, xctrace), so a literal, const-held, string-typed or copied simctl argv is a type error. The simctl branch unwraps the command to its scoped arguments; the ScopedSimctlArgs double cast is gone. The Apple tool host hands a simctl request's scoped args straight to the simctl provider. The set-scope primitive is private to core/simctl.ts; doctor and inventory use the named mints simctlAvailabilityProbeArgs and simctlListDevicesArgs. Every argv stays byte-identical. Refs callstack#2878 * chore(gates): narrow R79 to the simctl bypasses tsc cannot reject Drop the '--set' literal ban, the global ArrayExpression clause, the set-scope identifier check and the tool-provider argv exemption. R79 now checks the argv at an xcrun invocation (a call with an 'xcrun' argument or an executable: 'xcrun' spec), which must name its tool as a literal other than simctl, and refuses casts to the scope brands outside the two modules that mint them. Refs callstack#2878 * chore(gates): refuse a simctl argv array outside the brand mints however it reaches an executor runCmd, runCmdBackground and an executable: 'xcrun' spec take any string argv, so the runXcrun brand does not reach them. R79 again flags every array whose first element names simctl (literal, quasi-only template, or a same-file binding of either) outside core/simctl.ts and core/tool-provider.ts, so a variable-held, aliased or spread simctl argv fails the gate as the inline form does. * refactor(platform-apple): run every simctl executor through one argv form * chore(gates): refuse a simctl tool named after leading xcrun options
…callstack#2896) * perf(ios): reduce smoke critical path with affected XCTest selection * chore(gates): enforce iOS XCTest impact selection coverage * fix(ci): run bridge proof after macOS live replay * chore(gates): guard macOS replay before bridge proof * chore(gates): derive iOS XCTest selection from Swift guards and ownership * chore(gates): resolve shallow PR impact and iOS screenshot coverage
…ceholder (callstack#2927) The helper wrote hint-showing but never the hint itself, and text holds the hint only while the field is empty, so a filled field's placeholder was readable nowhere. The helper now writes hint (getHintText, API 26+), the node carries it as placeholder whether or not it is showing, and the fact joins the unchanged map and the find/get digest. Value and label are unchanged.
…allstack#2916) * fix(ios): derive the fold request envelope from a worst-case ledger test FOLD_REQUEST_TIMEOUT_MS was a literal whose basis lived only in a comment, and that comment kept drifting from the route: it first cited one 30s helper build and four 20s hinge reads, missing two 5s display-inventory reads and the HID-dispatch guard/grace, then callstack#2858 hand-summed the route again for its own preparation-phase change and landed a fresh hand-summed figure with the same failure mode. Add a ledger test that drives fold through the public client and daemon against a fake Apple tool provider recording each call's timeoutMs, including the fold-helper cache's toolchain probes and build. A calibration run learns the route's hinge-settle-attempt count from an oscillating angle that never settles; a measured run uses the same oscillation but lands the last allowed read on target, so it succeeds after making exactly as many reads. Summing every call's timeout (minus 1ms, plus any kill grace) gives the route's worst case that still succeeds, asserted against the resolved command envelope with the usual 30s daemon-result margin. The test imports no platform-apple step figure other than MAX_FOLD_DURATION_MS, so it keeps proving the bound however the route's steps change next. Widen FOLD_REQUEST_TIMEOUT_MS to 255_000 (the smallest 5s multiple at least ledger + margin) and point both the constant's comment and the descriptor-timeout-policy pinning test at the ledger test instead of a hand-summed figure. * fix(ios): keep a daemon-result margin over prepare's default runner budget readPrepareIosRunnerTimeoutMs's fallback and the client envelope both resolved to PREPARE_REQUEST_TIMEOUT_MS (240_000), so with no --timeout the daemon-side runner budget and the client envelope were identical: a slow cold runner build ended in a client-side timeout and daemon reset instead of the daemon's own typed runner_phase_budget result. Every other bounded command keeps a 30s margin between its daemon-side budget and its envelope; prepare's explicit-timeout case already widened past its own base by that same margin (widenToUserBudget), only the default case had none. Split the daemon-side runner budget out as PREPARE_STARTUP_BUDGET_MS (240_000, unchanged) and derive PREPARE_REQUEST_TIMEOUT_MS from it plus the margin (270_000). The handler falls back to the startup budget, not the now-wider request timeout. Add a handler-level test that calls handlePrepareCommand directly with a fake runner binding recording the timeoutMs it receives, then checks that value plus the margin against the same request's resolved client envelope for the no-flag, --timeout above default, and --timeout below default cases - the rule the fix proves, not just the constants it compiles to. * fix(ios): fail the fold ledger test on an unbounded provider call The fold worst-case ledger fake defaulted an unset timeoutMs to 0, so a provider call reaching it with no bound cost 0 virtual ms instead of exposing the true unbounded worst case. Its runCommand handler also skipped `open -a Simulator` before recording, dropping any such call from the ledger with no error. Build the ledger on the shared recording provider and assert every call carries a finite, positive timeoutMs, so an unbounded call fails the test instead of passing silently. * test(ios): give the fold ledger test a cold-import budget The ledger test runs two cold folds through a fresh daemon each. After a platform-apple source change the first import is untransformed, and the test ran past vitest's 5 s default, which hid the ledger printout behind a timeout. Match the provider-scenario precedent of an explicit budget.
…readable (callstack#2895) * fix(android): return from an app open only after the launched app is readable am start -W returns when the activity draws its first frame, which can be a splash or an empty root while a React Native app still mounts. The first capture after a cold open --relaunch then saw a content-poor tree and spent its own budget on re-captures. The open now captures the launched app through the interactor snapshot path, whose content verdict and bounded re-capture decide readiness, and reports postOpenObservation. An app that stays unreadable, or a failed capture, still opens as unobservable. Refs callstack#1571 (iOS half: callstack#2838). * fix(android): bound and type the launch observation of an app open The open's launch capture now runs behind an injected launch observation port with a fixed 6 s window of its own, separate from the caller's cancellation. Only a content verdict, a system surface over the app, or the window running out reads as unobservable. Any other capture failure is a typed probe-failed result that the open reports and survives. The capture is transient: it borrows a running helper session and stops only a session it started, installs no helper, and does not retire the helper after a content verdict. A URL open reports no observation, and an app open whose package cannot be read reports app-unidentified. PostOpenObservation is one documented union in the lifecycle contract, shared with the Apple owner. Refs callstack#1571 * fix(android): let the launch settle window end re-captures, never helper work The open's 6 s window reached the snapshot helper as an abort, so a window that closed during a cold helper start or a borrowed capture tore the helper down and left the next read to recover it. The window is now a settle deadline on the transient capture: the content re-capture loop starts no attempt after it, while helper start, capture and teardown keep their own budgets and only the caller's signal cancels them. The transient read replaces the injected port, the borrow session scope and the interactor-side install mapping: the capture itself keeps a session it found, releases one it started, installs no helper and does not retire it after a content verdict. Refs callstack#1571 * fix(android): keep a missing-helper refusal out of capture failure recovery A transient capture on a device without the current helper refused at the install check, and that refusal went through the capture failure handler, which logged an error and force-stopped the helper runtime on every new device's first open. The refusal now reaches the caller directly. The transient-capture tests move to their own file, so snapshot.test.ts stays under the test-file size ratchet. Refs callstack#1571
…tack#2893) * fix(wait): report readiness work that consumed the wait budget A wait whose deadline cancels a poll while the iOS XCTest runner is still starting, or while the Simulator app is still being discovered, now fails with wait_readiness_exhausted and details.readinessPhase instead of wait_capture_stalled. The platform names the phase on the cancellation it throws; the wait classifies the cancelled poll as readiness. Refs callstack#2343 * fix(wait): tag only running discovery as readiness and keep platform imports type-only * fix(wait): keep an earlier retriable refusal ahead of the readiness verdict * test(ios): type the route test tool mocks against readonly simctl args * refactor(ios): name the route's cancelled target-resolution rethrow * test(ios): pin the resolver's discovery and re-check cancellations
…d enum (callstack#2888) * refactor(ios-runner): close the snapshot quality verdict state vocabulary The runner wrote the verdict `state` as a free String while the host accepts only healthy | recovered | sparse, and the contracts annotation reader cast any string state into the verdict type. A typo or a one-sided rename therefore dropped the verdict and its disclosure. `SnapshotQualityState` now owns the runner side, raw-value Codable keeps the wire JSON unchanged, and `reasonCode` stays open. The kernel states the vocabulary once as `SNAPSHOT_QUALITY_STATES` with `isSnapshotQualityState`; capture-kit and contracts both read through it, so neither keeps a second accepted-state set and the annotation reader rejects an unknown state instead of casting it. `contracts/fixtures/ios-snapshot-quality-states.json` is the table the Swift `allCases` order and the kernel tuple are each pinned to. * refactor(substrate): hold the verdict state map in each eager-frozen reader The readers reached the vocabulary through `isSnapshotQualityState` in `kernel/snapshot.ts`, which made that module eager in six entry closures the eager-closure gate holds at its merge-base size — the contracts capture façade at 9 modules, capture-kit's verdict reader at 2. The only module those two closures already evaluate is the one whose edge the gate rejects, so no single runtime home for the set exists. Each reader now keys a `Record<SnapshotQualityState, true>` over the kernel union, the home the gate prescribes for code that has to live where it is already evaluated. Membership goes through `Object.hasOwn`, so an inherited key is never a state, and a state added to the tuple without a key in a reader is a compile error there: the guarantee the shared import was bought for, without the eager edge. Both reader tests walk the tuple and the kernel test still pins it to the fixture. * refactor(snapshot): gate the backend name, and stop pinning vocabulary order The annotation reader was one predicate plus a blanket cast. `backend` names the recovery strategy in the warning line, so it goes through a declared map keyed against the kernel union now, and capture-kit lost both of its `as` casts on the way: the strategy is gated on the capability table it already imports, the reason code on an exhaustive map over its union. A full projection in contracts was tried and the repo's own gates refuse it — fallow reports a 4-group, 104-line clone family against capture-kit's normalizer, on top of the eager-closure gate that already forbids a shared reader. The re-read stays in the shape `readTargetActivation` in the same file uses: check the two names that decide presentation, forward what this module published, and pin the pair payload-by-payload from capture-kit's test. Order pinning drops out. The fixture is compared as a set on both sides, the stamping test asserts each case's own raw value, and the unread 28-line capture builder and its import are gone. * refactor(contracts): name the verdict re-read apart from the strict reader Two same-named readers with different trust levels is how the half-migration read as validating more than it does. This module's version checks the two load-bearing names and forwards what this repo published, in the shape `readTargetActivation` uses, so it is now `readPublishedSnapshotQualityVerdict` and the strict per-field reading keeps the plain name for capture-kit's untrusted-payload reader. * refactor(snapshot): answer the review round on sharing, parity, and evidence The eager-closure refusal the review asked to be pointed at or filed is not a table of budgets: the gate ratchets every entry's closure against the committed merge-base tree, so the numbers to read are 9 for `facades/capture.ts` and 2 for `snapshot-quality-verdict.ts`. Re-installing one shared kernel predicate for both readers on this head fails six entries and names both of those, which is why the vocabulary stays a map per reader; recorded on callstack#2872 as the accepted deviation. The `android-helper` asymmetry is not real: `SNAPSHOT_QUALITY_BACKEND_CAPABILITIES` merges the Android declaration, so both readers accept that strategy and the parity table now carries the row instead of omitting it. The client-side drop of an unknown state or backend gets its CHANGELOG entry, the justification comments shrink to one constraint sentence each, the kernel test keeps only its `@ts-expect-error`, and the word left dangling by the last rename goes.
…allstack#2886) * test(platform-apple): rename the macOS surface interactor test * fix(macos): derive surface routing and crop classification from one owner Add a SessionSurface-keyed MACOS_SURFACE_BACKENDS table in contracts with macOsSurfaceBackend and a table-derived MacOsHelperSurface brand. The platform-apple snapshot, screenshot, readText, press, runtime capture and native find sites, and the daemon crop classifier, now read it. The crop classifier now reports frontmost-app as macos-helper (the helper captures it) and a surface-less macOS session as macos-app-window (every route serves it through the runner). Helper entry points require an owner-routed surface. Fixes callstack#2880 * test(macos): assert the surface loader's refusal message, not only its class
…hree fixed points (callstack#2926) * fix(android): fill verification samples until a deadline instead of three fixed points The typed text reaches the accessibility tree when the app renders it, which a React Native controlled input on a loaded emulator did later than the three fixed samples allowed (at 0, 150 and 500 ms elapsed: sleeps of 0, 150 and 350 ms before each): an otherwise passing fill failed twice on a 4-vCPU CI emulator while the field still showed its hint. Sampling now continues every 150 ms until the text has held for two consecutive samples or 1.5 s have passed, and the sample taken at the deadline is the answer. A fast device confirms after two samples, one fewer than before. * test(android): the fill sampler paces itself by an injectable clock, so its tests advance time instead of waiting it out * docs(android): the fill sampler's comment states the sampling rule, not the history behind it
…napshot node (callstack#2913) * fix(android): carry the checked state of a checkable control on the snapshot node The snapshot helper never serialized `checked` or `checkable`, and the host reads only the helper's XML, so no later layer could recover it: a switch, checkbox, or radio button looked the same on or off, `get attrs` had no `checked` field, and snapshot text showed the control as plain. The helper now writes `checked` on every node Android reports as checkable, with both answers, so an unchecked switch reads `false`, a node that cannot be checked reads nothing, and so does a helper older than the attribute. The parser, the Android hierarchy node, and the published snapshot node carry it to `get attrs`, the unchanged-snapshot comparison, the selector digest, and the `[checked]` and `[unchecked]` markers in snapshot text. Both answers render because the diff compares the state and a checkable control rendered as plain would hide that it toggles. * fix(daemon): the interaction outcome weighs the states stateMarkers prints, and a scroll's container check matches content by identity Review follow-up. The interaction surface key folded enabled and selected but not checked, so a tap whose only effect was a toggle read as a no-op on the outcome lane while the diff called it a change, and a no-change retry would tap the switch straight back. The key now spreads stateMarkers, so the outcome lane, the unchanged-snapshot comparison, and the diff weigh one list. With checked in the key, a flip inside a scroll container beside an unrelated change elsewhere (the status clock) read as content moving within the container. discriminatingSurfaceChangedWithinRect now matches entries on the flip-tolerant identity where one exists, told apart by document order when repeated, so a state flip at the same rect is not movement. Tests: a checked-only flip is a change on the outcome lane; a flip at the same rect is not movement and a moved row is; the scroll claim is withheld for a flipped toggle inside the container; checked in both answers invalidates snapshot reuse and survives the selector digest. * test(daemon): the capture-retry fixtures spell the interaction key with state markers The two hand-written pre-signatures carried the literal enabled and unselected segments the key no longer writes for a plain node, so the post-tap capture read as changed and the retry never fired. * fix(daemon): a surface entry carries a state-free content key, so an anonymous toggle is not movement Review follow-up. The within-container movement check fell back to `key` for an entry without an identity, and `key` carries the checked state, so an unlabelled switch a swipe brushed still read as content moving. Every entry now carries `content`: the identity where the node has one, else its type and role; the check compares on that alone. * fix(maestro): the snapshot signature weighs checked, so a toggle tap is not a no-op to retap maestroSnapshotSignature hashed label, value, enabled, selected, focused and bounds; a switch, checkbox or radio whose only observable effect is its checked state produced the same signature before and after the tap, so the settle and the no-change retry read the tap as a no-op and could tap it back. checked joins the hashed object the way the interaction outcome key already weighs it.
…ack#2931) Six of the sixteen `test()` registrations in this file were byte-identical copies of three others: "keeps delayed typing in typed-input mode" appeared twice, and "tolerates delayed React Native text verification" and "reports clear error when unicode input is unsupported" appeared three and four times. Block-level diffing confirms one distinct body per name, so the extra copies registered extra passes that no production change could distinguish. They arrived already duplicated in 6dd7d40 and survived two file moves. Deleting them removes ~3s of repeated fake-adb wall clock from the file. Verified the surviving copy still owns the delayed-typing contract: dropping `chunkSize: delayMs > 0 ? 1 : ANDROID_INPUT_TEXT_CHUNK_SIZE` in text-input.ts turns "fillAndroid keeps delayed typing in typed-input mode" red, and no other test in the family catches that decision.
…n its snapshot metadata (callstack#2928) Android bounds are physical pixels and so are the points press and the gestures take; nothing said what a pixel was worth, so a consumer that lays out in dp had no way to place a tap or read a size. The helper now reports DisplayMetrics.density beside its capture metadata (the same configuration the framework lays out with, a wm density override included), and the host publishes it as androidSnapshot.pixelDensity, the display's physical pixels per dp (2.625 on a 420 dpi phone). Rects stay in physical pixels; an older helper omits the field.
…es (callstack#2930) The `@internal` AWS Device Farm and BrowserStack factory pairs claimed to be "used by integration tests" while no test or production module referenced them. `createCloudWebDriverProviderDefinitions` composes each provider's runtime directly and is the only construction path, so both factories were the superseded pre-definitions shape. Removing them also removes `resolveConfiguredBrowserStackCapabilities`, whose webdriverCapabilities-shadowing precedence the live path never applied: it passes `buildCloudWebDriverBaseCapabilities(platform, deviceName)` as `configured` instead.
…ble TS and Swift both verify (callstack#2900) * test(apple-runner): pin production-built runner requests in a golden table Add contracts/fixtures/runner-requests.json: one entry per production request site, captured at its real entry point. Three producer tests own the entries: the platform-apple request sites, the runner-internal sites (real HTTP bodies from the fake runner), and the two recording modules. runner-contract.test.ts asserts every RunnerCommand has an entry, names are sorted and unique, and the drive code builds no request itself. Refs callstack#2881 * test(apple-runner): verify the runner request golden table in Swift Every fixture request decodes as Command and re-encodes with the same key set, every CommandType case has a request, and every stored field of Command, SequenceStep and RunnerGesturePlan appears in some request. CommandType becomes CaseIterable for the case check. Refs callstack#2881 * chore(gates): keep runner-requests.json entries on one line each Refs callstack#2881 * test(apple-runner): pin the macOS desktopScroll pixels request * test(apple-runner): share the runner-requests fixture check with root tests Export the fixture reader as @agent-device/platform-apple/runner/requests-fixtures so root producers pin requests through the same commandId normalization, and derive the request-literal guard's sources from the producers the fixture declares. Refs callstack#2881 * test(apple-runner): drive root recording requests from a dedicated golden file The request-literal guard scans every fixture producer, so root behavior tests that doubled as producers had to weaken their exact request assertions. Move the root recording drives into src/__tests__/screen-recording-runner-requests.test.ts, restore the original literals, and pin the recordStop sent without an app bundle. Producers must now be *runner-requests.test.ts files that call assertProducedRunnerRequests(import.meta.filename, ...), so a comment no longer satisfies the check. The runner-internal drive moves to runner/__tests__/runner-requests.test.ts to follow that rule. Refs callstack#2881
|
Hey! Yeah, I’m going to check the review and push the changes. I’m just waiting for the weekend 😄 It would be great to collaborate and get this thing done together ^_^ |
Distinct survived-kill reason, direct Android kill without shared Interactor change, foreground-refusal docs, exhaustive lifecycle dispatch.
There was a problem hiding this comment.
7 issues found across 27 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/platform-android/src/__tests__/app-lifecycle-open.test.ts">
<violation number="1" location="packages/platform-android/src/__tests__/app-lifecycle-open.test.ts:239">
P3: These two tests invoke `killAndroidApp` twice because `assertRejectsAppError` (test-utils/app-error.ts) only matches `code`/`message`/`hint`, so a second raw call is needed just to read `details.reason`. In the survivor test the second invocation re-runs the whole failure path, adding another ~2 s of real time (ANDROID_CLOSE_PROCESS_TIMEOUT_MS poll loop) to CI and duplicating identical adb mock traffic. Either extend `ExpectedAppError` with a `details` partial matcher and assert `code`+`hint`+`reason` in one `assertRejectsAppError`, or replace the double call with a single try/catch that asserts code, hint, and `details.reason` together.</violation>
</file>
<file name="src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts">
<violation number="1" location="src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts:338">
P3: `mockBindDeviceRuntime.mockImplementation(...)` permanently replaces the shared harness mock for the rest of this file: the file's `beforeEach` only calls `mockClear`, which keeps implementations, so the wrapper (and its `seenModes` closure) stays installed for every subsequent test here. Such tests still pass only because the wrapper delegates to `baseBind`, but the shared mock is left hijacked, which silently couples this test to all later tests in the file. Restore the base implementation at the end of the test, or use two `mockImplementationOnce` registrations.</violation>
</file>
<file name="src/daemon/session-lifecycle/internal/session-close.ts">
<violation number="1" location="src/daemon/session-lifecycle/internal/session-close.ts:152">
P1: This only propagates `kill` through the session-backed dispatcher. A Maestro flow that starts before a daemon session exists enters `closeWithoutSession`, where the omitted mode makes Android perform `stop` instead of `killApp`; pass the same mode through that branch too.</violation>
</file>
<file name="packages/platform-android/src/app-lifecycle.ts">
<violation number="1" location="packages/platform-android/src/app-lifecycle.ts:512">
P2: `killAndroidPackage` proceeds with `am kill` when the foreground probe cannot produce a state. `getAndroidAppState` converts failed or empty dumps into `{}`, so this guard cannot enforce the documented foreground precondition; fail closed or surface the probe failure before dispatching.</violation>
<violation number="2" location="packages/platform-android/src/app-lifecycle.ts:521">
P1: This final liveness check cannot prove the process is gone because `isAndroidPackageProcessRunning` treats every empty `pidof` result as false, including probe failures. Distinguish the normal no-process exit from command errors and fail when the liveness probe itself is unavailable.</violation>
</file>
<file name="packages/maestro/src/internal/__tests__/runtime-port.test.ts">
<violation number="1" location="packages/maestro/src/internal/__tests__/runtime-port.test.ts:187">
P3: The explicit and config app ids are the same value, so this test cannot distinguish the two paths it claims to cover: the dispatcher computes `{ appId: command.appId ?? request.appId }`, and both commands resolve to `com.example.checkout` regardless of which branch is exercised. A regression that, for example, always used the config app id and dropped the explicit `killApp:` app id would still pass. Use a distinct explicit app id (e.g. `com.example.other`) and assert calls[0] receives it while calls[1] falls back to the config id.</violation>
</file>
<file name="packages/platform-android/src/lifecycle.ts">
<violation number="1" location="packages/platform-android/src/lifecycle.ts:69">
P2: `mode: 'kill'` with an empty `positionals` returns success without killing anything. Fall back to `input.appBundleId` (the session-app identity this binding uses everywhere else) and throw `INVALID_ARGS` when neither exists, so a kill that has no target fails loudly instead of reading as success.</violation>
</file>
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
| outPath: req.flags?.out, | ||
| appBundleId: session.appBundleId, | ||
| surface: session.surface ?? 'app', | ||
| ...(req.internal?.killApp === true ? { mode: 'kill' as const } : {}), |
There was a problem hiding this comment.
P1: This only propagates kill through the session-backed dispatcher. A Maestro flow that starts before a daemon session exists enters closeWithoutSession, where the omitted mode makes Android perform stop instead of killApp; pass the same mode through that branch too.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/daemon/session-lifecycle/internal/session-close.ts, line 152:
<comment>This only propagates `kill` through the session-backed dispatcher. A Maestro flow that starts before a daemon session exists enters `closeWithoutSession`, where the omitted mode makes Android perform `stop` instead of `killApp`; pass the same mode through that branch too.</comment>
<file context>
@@ -149,6 +149,7 @@ async function dispatchTargetedPlatformClose(params: {
outPath: req.flags?.out,
appBundleId: session.appBundleId,
surface: session.surface ?? 'app',
+ ...(req.internal?.killApp === true ? { mode: 'kill' as const } : {}),
execution: applicationLifecycleExecutionFromRequest(req, logPath, session.trace?.outPath),
});
</file context>
| } | ||
| await runAndroidShell(device, ['am', 'kill', packageName]); | ||
| await waitForAndroidPackageStopped(device, packageName); | ||
| if (await isAndroidPackageProcessRunning(device, packageName)) { |
There was a problem hiding this comment.
P1: This final liveness check cannot prove the process is gone because isAndroidPackageProcessRunning treats every empty pidof result as false, including probe failures. Distinguish the normal no-process exit from command errors and fail when the liveness probe itself is unavailable.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-android/src/app-lifecycle.ts, line 521:
<comment>This final liveness check cannot prove the process is gone because `isAndroidPackageProcessRunning` treats every empty `pidof` result as false, including probe failures. Distinguish the normal no-process exit from command errors and fail when the liveness probe itself is unavailable.</comment>
<file context>
@@ -488,6 +488,44 @@ export async function closeAndroidApp(device: DeviceInfo, app: string): Promise<
+ }
+ await runAndroidShell(device, ['am', 'kill', packageName]);
+ await waitForAndroidPackageStopped(device, packageName);
+ if (await isAndroidPackageProcessRunning(device, packageName)) {
+ throw new AppError('COMMAND_FAILED', `am kill did not stop ${packageName}`, {
+ reason: 'android-kill-process-survived',
</file context>
| } | ||
|
|
||
| async function killAndroidPackage(device: DeviceInfo, packageName: string): Promise<void> { | ||
| const foreground = await readAndroidForegroundApp(device); |
There was a problem hiding this comment.
P2: killAndroidPackage proceeds with am kill when the foreground probe cannot produce a state. getAndroidAppState converts failed or empty dumps into {}, so this guard cannot enforce the documented foreground precondition; fail closed or surface the probe failure before dispatching.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-android/src/app-lifecycle.ts, line 512:
<comment>`killAndroidPackage` proceeds with `am kill` when the foreground probe cannot produce a state. `getAndroidAppState` converts failed or empty dumps into `{}`, so this guard cannot enforce the documented foreground precondition; fail closed or surface the probe failure before dispatching.</comment>
<file context>
@@ -488,6 +488,44 @@ export async function closeAndroidApp(device: DeviceInfo, app: string): Promise<
+}
+
+async function killAndroidPackage(device: DeviceInfo, packageName: string): Promise<void> {
+ const foreground = await readAndroidForegroundApp(device);
+ if (foreground?.package === packageName) {
+ throw new AppError('COMMAND_FAILED', `Cannot kill foreground app ${packageName}`, {
</file context>
| } | ||
| if (input.mode === 'kill') { | ||
| const target = input.positionals[0]; | ||
| if (target) await killAndroidApp(device, target); |
There was a problem hiding this comment.
P2: mode: 'kill' with an empty positionals returns success without killing anything. Fall back to input.appBundleId (the session-app identity this binding uses everywhere else) and throw INVALID_ARGS when neither exists, so a kill that has no target fails loudly instead of reading as success.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-android/src/lifecycle.ts, line 69:
<comment>`mode: 'kill'` with an empty `positionals` returns success without killing anything. Fall back to `input.appBundleId` (the session-app identity this binding uses everywhere else) and throw `INVALID_ARGS` when neither exists, so a kill that has no target fails loudly instead of reading as success.</comment>
<file context>
@@ -63,6 +64,11 @@ export function bindAndroidApplicationLifecycle(
}
+ if (input.mode === 'kill') {
+ const target = input.positionals[0];
+ if (target) await killAndroidApp(device, target);
+ return;
+ }
</file context>
| }, | ||
| { serial: 'emulator-5554' }, | ||
| async () => { | ||
| await assertRejectsAppError(() => killAndroidApp(device, 'com.example.app'), { |
There was a problem hiding this comment.
P3: These two tests invoke killAndroidApp twice because assertRejectsAppError (test-utils/app-error.ts) only matches code/message/hint, so a second raw call is needed just to read details.reason. In the survivor test the second invocation re-runs the whole failure path, adding another ~2 s of real time (ANDROID_CLOSE_PROCESS_TIMEOUT_MS poll loop) to CI and duplicating identical adb mock traffic. Either extend ExpectedAppError with a details partial matcher and assert code+hint+reason in one assertRejectsAppError, or replace the double call with a single try/catch that asserts code, hint, and details.reason together.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-android/src/__tests__/app-lifecycle-open.test.ts, line 239:
<comment>These two tests invoke `killAndroidApp` twice because `assertRejectsAppError` (test-utils/app-error.ts) only matches `code`/`message`/`hint`, so a second raw call is needed just to read `details.reason`. In the survivor test the second invocation re-runs the whole failure path, adding another ~2 s of real time (ANDROID_CLOSE_PROCESS_TIMEOUT_MS poll loop) to CI and duplicating identical adb mock traffic. Either extend `ExpectedAppError` with a `details` partial matcher and assert `code`+`hint`+`reason` in one `assertRejectsAppError`, or replace the double call with a single try/catch that asserts code, hint, and `details.reason` together.</comment>
<file context>
@@ -162,6 +162,151 @@ test('closeAndroidApp waits until package process exits after force-stop', async
+ },
+ { serial: 'emulator-5554' },
+ async () => {
+ await assertRejectsAppError(() => killAndroidApp(device, 'com.example.app'), {
+ code: 'COMMAND_FAILED',
+ hint: /Background the app before killApp/,
</file context>
| sessionStore.set(sessionName, session); | ||
| const seenModes: Array<unknown> = []; | ||
| const baseBind = mockBindDeviceRuntime.getMockImplementation(); | ||
| mockBindDeviceRuntime.mockImplementation(async (boundDevice, use) => { |
There was a problem hiding this comment.
P3: mockBindDeviceRuntime.mockImplementation(...) permanently replaces the shared harness mock for the rest of this file: the file's beforeEach only calls mockClear, which keeps implementations, so the wrapper (and its seenModes closure) stays installed for every subsequent test here. Such tests still pass only because the wrapper delegates to baseBind, but the shared mock is left hijacked, which silently couples this test to all later tests in the file. Restore the base implementation at the end of the test, or use two mockImplementationOnce registrations.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts, line 338:
<comment>`mockBindDeviceRuntime.mockImplementation(...)` permanently replaces the shared harness mock for the rest of this file: the file's `beforeEach` only calls `mockClear`, which keeps implementations, so the wrapper (and its `seenModes` closure) stays installed for every subsequent test here. Such tests still pass only because the wrapper delegates to `baseBind`, but the shared mock is left hijacked, which silently couples this test to all later tests in the file. Restore the base implementation at the end of the test, or use two `mockImplementationOnce` registrations.</comment>
<file context>
@@ -319,6 +319,56 @@ test('a supported Android close clears admitted runtime hints exactly once', asy
+ sessionStore.set(sessionName, session);
+ const seenModes: Array<unknown> = [];
+ const baseBind = mockBindDeviceRuntime.getMockImplementation();
+ mockBindDeviceRuntime.mockImplementation(async (boundDevice, use) => {
+ const binding = await baseBind!(boundDevice, use);
+ const innerClose = binding.operations.closeApplication;
</file context>
| killApp: vi.fn(async (input, context) => record(calls, 'killApp', input, context)), | ||
| }); | ||
| const program = parseMaestroProgram( | ||
| ['appId: com.example.checkout', '---', '- killApp: com.example.checkout', '- killApp'].join( |
There was a problem hiding this comment.
P3: The explicit and config app ids are the same value, so this test cannot distinguish the two paths it claims to cover: the dispatcher computes { appId: command.appId ?? request.appId }, and both commands resolve to com.example.checkout regardless of which branch is exercised. A regression that, for example, always used the config app id and dropped the explicit killApp: app id would still pass. Use a distinct explicit app id (e.g. com.example.other) and assert calls[0] receives it while calls[1] falls back to the config id.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/maestro/src/internal/__tests__/runtime-port.test.ts, line 187:
<comment>The explicit and config app ids are the same value, so this test cannot distinguish the two paths it claims to cover: the dispatcher computes `{ appId: command.appId ?? request.appId }`, and both commands resolve to `com.example.checkout` regardless of which branch is exercised. A regression that, for example, always used the config app id and dropped the explicit `killApp:` app id would still pass. Use a distinct explicit app id (e.g. `com.example.other`) and assert calls[0] receives it while calls[1] falls back to the config id.</comment>
<file context>
@@ -178,6 +178,25 @@ describe('MaestroRuntimePort', () => {
+ killApp: vi.fn(async (input, context) => record(calls, 'killApp', input, context)),
+ });
+ const program = parseMaestroProgram(
+ ['appId: com.example.checkout', '---', '- killApp: com.example.checkout', '- killApp'].join(
+ '\n',
+ ),
</file context>
Maestro killApp triggers system-initiated process death (adb shell am kill on Android) instead of stopApp's force-stop. On other platforms it aliases stopApp through the shared close dispatcher. - contracts: optional Interactor.kill, CloseApplicationInput.mode, with fallback to close in invokeApplicationClose - android: killAndroidApp (am kill) wired onto the interactor - maestro: killApp IR kind, parser, runtime port, daemon projection to an app-only close carrying killApp dispatch, conformance canonical - daemon: replay dispatch folds killApp; session close passes mode kill into closeApplication - upstream/116_kill_app now classifies identical; divergence removed
executeLifecycleCommand grew a sixth case for killApp and tripped the complexity gate. The stopApp/killApp/clearState legs share one shape, so dispatch them through a lookup instead of three switch cases.
am kill only reaps background processes, so a foreground killApp used to succeed without killing anything. Refuse a foreground target naming the background-first precondition, and verify the process is gone after the kill instead of reporting an unproven success.
Distinct survived-kill reason, direct Android kill without shared Interactor change, foreground-refusal docs, exhaustive lifecycle dispatch.
…523/agent-device into feat/maestro-killapp-impl # Conflicts: # packages/platform-android/src/lifecycle.test.ts
- session-close: propagate kill mode through closeWithoutSession - android: fail kill closed when foreground probe cannot answer; distinguish pidof probe failure from reaped process - android lifecycle: fall back to session app identity for kill target, INVALID_ARGS when no target exists - tests: single-call reason assertions via details matcher, mockImplementationOnce for mode capture, distinct killApp app ids
|
The Compatibility & Provenance → Run Fallow audit failure is pre-existing mainline drift, not introduced by this PR. I reproduced the audit locally at this PR's head (
The 4 complexity hits ( Suggested path: the owning mainline changes address those three findings (or get path-keyed baseline updates), then a rebase turns this job green. Per repo rules I'm keeping this PR within its command-family scope and not touching those files or regenerating baselines. |
- Move killAndroidApp tests to app-lifecycle-kill.test.ts so app-lifecycle-open.test.ts stays under the 1,000-line tripwire - Stub the platform close in the session-close mode-plumbing test instead of executing the real Android kill against live adb
|
Following up on f894395 after the earlier evidence-pending review at 78727e3. The kill route (packages/platform-android/src/app-lifecycle.ts#L510) is still only exercised through adb exec fakes: Maestro replay calls close with closeAppOnly+killApp, lifecycle.ts:70 calls killAndroidPackage, which does a foreground probe, am kill, waitForAndroidPackageStopped, then a pidof check. Neither this delta nor any author comment adds the emulator replay the earlier review asked for. Nobody has shown that am kill actually reaps an app that was sent to background with Home on a real emulator, or that the pidof check correctly reads a backgrounded-but-alive process. Either gap could make killApp fail with android-kill-process-survived, or report success without the process being killed. Can you run Not blocking, can be taken or left: the stricter isAndroidPackageProcessRunning (app-lifecycle.ts#L585) now throws android-process-probe-unavailable on any pidof hiccup, which would abort close/stopApp/relaunch paths on a single flaky probe even after force-stop already succeeded, so should a probe failure only fail the operation whose contract needs proof of death, and should closeWithoutSession's mode spread in session-close.ts#L439 get a direct test (closing with {closeAppOnly, killApp} and no stored session, asserting mode 'kill' reaches platform closeApplication) since nothing exercises it today; also the killAndroidApp re-export in mechanics.ts#L86 looks like dead leftover code with no consumer at head, and the PR body still references the old Interactor.kill design and a different tested commit, so worth cleaning up before merge. Compatibility & Provenance ran fallow against the pre-rebase base and its findings sit in files this PR does not touch (session-test-harness.ts, runner-contract.ts, runner/index.ts, the apple/harmonyos recovery.ts pair, run-script-http.ts, install-source-network-transport.ts, args.ts); the one file this PR does touch, conformance-normalize.ts, only gets new cases in the lifecycle sub-helpers, not in the flagged canonicalizeUpstreamCommand or canonicalizeAgentCommand, so this failure looks unrelated to the change. I did not re-run the author's reproduction on main, so I can't confirm main actually reproduces the failure being fixed here. I also did not run the unit suites; the regression read above comes from reading the diff and the pre-change code, not from a run. I can't say which Android images in the fleet lack pidof or emit stderr noise from it, so the probe-hardening impact is plausible, not confirmed. And I don't know whether upstream Maestro's killApp also refuses a foreground app. The remaining requirement before this can merge is the live Android emulator replay described above: killApp after Home must cold-kill the app (empty pidof, cold relaunch), and killApp on a foreground app must fail with android-kill-requires-background-app. |
Summary
Supports Maestro
killApp(system-initiated process death) end to end, mirroringstopAppplumbing. On Android it dispatchesadb shell am kill; every other platform aliasesstopAppthrough the shared close dispatcher (Interactor.killfallback). No new public CLI surface —killAppstays inside the Maestro compatibility layer.Interactor.kill,CloseApplicationInput.mode, kill-with-fallback ininvokeApplicationClosekillAndroidApp(am kill) wired onto the interactorkillAppIR kind, parser, runtime port, daemon projection to an app-only close carryingkillAppdispatchkillApp; session close passesmode: killintocloseApplicationupstream/116_kill_appnow classifies identical; declared divergence removed. Touched 30 files.Validation
Tested commit
369a51b35. Focused suites pass: contracts interaction, maestro parser/runtime-port/daemon-port/conformance-verify (node --test, 14 pass), android app-lifecycle, session-close, replay-maestro-request, fuzz arbitraries, maestro help/docs sync.pnpm check:quick(lint + typecheck) passes.pnpm check:affected --run: fallow audit clean on changed files; complexity gate shows only the 2 inherited findings after table-driving the lifecycle dispatch. Unresolved:check:production-exportsreports 68 unused exports — reproduced identical onorigin/main, pre-existing and unrelated. No live-device run; risk is Androidam killsemantics on a real emulator (unit asserts the exact argv).