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.
…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. |
…eeds Live emulator replay (agent-device replay --maestro) reproduced a real gap from the PR review: immediately after launchApp, dumpsys window's mCurrentFocus can still name the previous foreground app for one read while dumpsys activity activities already shows the launched app resumed. getAndroidAppState takes whichever dump answers first, so killAndroidPackage's single-sample foreground check could read "backgrounded" for an app that was still genuinely in the foreground, skipping the android-kill-requires- background-app refusal. killAndroidPackage now corroborates a "not the target" read once after a short settle before trusting it, mirroring the existing gone-twice-in-a-row pattern used elsewhere in this file. Also: drop the killAndroidApp re-export from mechanics.ts (no consumer imports it there; the real caller imports directly from app-lifecycle.ts), and add a direct test proving mode: 'kill' reaches the platform close when closeWithoutSession handles close with no stored session. Claude-Session: https://claude.ai/code/session_01NVfRR5Pmmkg8ZstHQUZ2qR
|
Ran the requested live Android emulator replay ( What the live run foundFlow: The refusal didn't fire. With a temporary debug probe in
Fix ( Caveat: couldn't get a clean live confirmation of the background-kill + cold-relaunch caseThis dev machine had several concurrent iOS simulators + an The foreground-refusal fix itself doesn't depend on that confound — it's proven by the debug-instrumented live read above (a value, not a pass/fail) plus the new deterministic unit test. The three non-blocking cleanups
|
There was a problem hiding this comment.
2 issues found across 4 files (changes from recent commits).
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/app-lifecycle.ts">
<violation number="1" location="packages/platform-android/src/app-lifecycle.ts:582">
P3: The same-app fast path skips the settle in the mirror direction. The stale WMS `mCurrentFocus` lag this fix documents also applies to a read taken right after the user backgrounds the app, so `killApp` issued immediately after `pressKey: Home` can still see the target named and spuriously refuse a legal kill — the hint then tells the user to background an app they already backgrounded. Corroborate this direction too: settle and re-read, refusing only when the confirmatory read still names the target (fail closed on an unreadable confirm).</violation>
</file>
<file name="src/daemon/session-lifecycle/internal/__tests__/session-close-without-session-mode.test.ts">
<violation number="1" location="src/daemon/session-lifecycle/internal/__tests__/session-close-without-session-mode.test.ts:62">
P3: The wrapper discards everything except `input.mode`, so the test stays green if the no-session close later drops or corrupts `positionals`, `surface`, `outPath`, or `ensureReady: true`. Record the full input and assert the key fields alongside `mode` so this path's dispatch contract is pinned, not just the kill bit.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
| if (first?.package === packageName) return first; | ||
| await sleep(ANDROID_KILL_FOREGROUND_STABLE_MS); | ||
| return await readAndroidForegroundApp(device); |
There was a problem hiding this comment.
P3: The same-app fast path skips the settle in the mirror direction. The stale WMS mCurrentFocus lag this fix documents also applies to a read taken right after the user backgrounds the app, so killApp issued immediately after pressKey: Home can still see the target named and spuriously refuse a legal kill — the hint then tells the user to background an app they already backgrounded. Corroborate this direction too: settle and re-read, refusing only when the confirmatory read still names the target (fail closed on an unreadable confirm).
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 582:
<comment>The same-app fast path skips the settle in the mirror direction. The stale WMS `mCurrentFocus` lag this fix documents also applies to a read taken right after the user backgrounds the app, so `killApp` issued immediately after `pressKey: Home` can still see the target named and spuriously refuse a legal kill — the hint then tells the user to background an app they already backgrounded. Corroborate this direction too: settle and re-read, refusing only when the confirmatory read still names the target (fail closed on an unreadable confirm).</comment>
<file context>
@@ -561,6 +562,28 @@ async function readAndroidForegroundApp(device: DeviceInfo): Promise<AppStateRun
+ packageName: string,
+): Promise<AppStateRuntimeResult | null> {
+ const first = await readAndroidForegroundApp(device);
+ if (first?.package === packageName) return first;
+ await sleep(ANDROID_KILL_FOREGROUND_STABLE_MS);
+ return await readAndroidForegroundApp(device);
</file context>
| if (first?.package === packageName) return first; | |
| await sleep(ANDROID_KILL_FOREGROUND_STABLE_MS); | |
| return await readAndroidForegroundApp(device); | |
| if (first?.package === packageName) { | |
| await sleep(ANDROID_KILL_FOREGROUND_STABLE_MS); | |
| const confirmed = await readAndroidForegroundApp(device); | |
| if (confirmed && confirmed.package !== packageName) return confirmed; | |
| return first; | |
| } | |
| await sleep(ANDROID_KILL_FOREGROUND_STABLE_MS); | |
| return await readAndroidForegroundApp(device); |
| operations: { | ||
| ...binding.operations, | ||
| closeApplication: async (input: Parameters<typeof innerClose>[0]) => { | ||
| seenModes.push(input.mode); |
There was a problem hiding this comment.
P3: The wrapper discards everything except input.mode, so the test stays green if the no-session close later drops or corrupts positionals, surface, outPath, or ensureReady: true. Record the full input and assert the key fields alongside mode so this path's dispatch contract is pinned, not just the kill bit.
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-without-session-mode.test.ts, line 62:
<comment>The wrapper discards everything except `input.mode`, so the test stays green if the no-session close later drops or corrupts `positionals`, `surface`, `outPath`, or `ensureReady: true`. Record the full input and assert the key fields alongside `mode` so this path's dispatch contract is pinned, not just the kill bit.</comment>
<file context>
@@ -0,0 +1,85 @@
+ operations: {
+ ...binding.operations,
+ closeApplication: async (input: Parameters<typeof innerClose>[0]) => {
+ seenModes.push(input.mode);
+ },
+ },
</file context>
|
This is a follow-up on f894395 (#2749 (comment)) at e676ade, and the fixes there don't close the gap. readAndroidForegroundAppSettled (packages/platform-android/src/app-lifecycle.ts#L579) still re-reads the same source that went stale in the live run, just 150 ms later. getAndroidAppState ranks WMS mCurrentFocus ahead of the AMS resumed activity (window-state.ts ANDROID_WINDOW_QUESTIONS.foreground), and three inputs still take the wrong branch on that ranking. If the focus lag after launchApp outlasts 150 ms, which a heavy RN cold start can do, the refusal is skipped, Not blocking: the new JSDoc and the test headers at app-lifecycle.ts#L565, app-lifecycle-kill.test.ts:53, and session-close-without-session-mode.test.ts:44 carry dated live-run narrative and a "#2749 review:" history that AGENTS.md asks to keep out of implementation comments, worth removing once the fix above lands but not a reason to hold this up on its own. The iOS runner XCTest testSynthesizedReplacementPacesAnAppOwnedFieldAtItsAcknowledgeWindow (RunnerTests+SynthesizedTextEntryTests.swift:120) is failing in CI, but this PR touches no Swift or runner file, its route is Maestro → daemon close → Android lifecycle, and main recently changed that exact fixture in 94433db, so that failure looks unrelated to this change. The Fallow findings are against a stale base (a798792, 918 changed files); every flagged symbol, clone, and complexity hit sits in a file this PR doesn't touch, or is a pre-existing export (android test-utils/app-error.ts assertThrowsAppError) whose line only moved, identical on main, so a rebase should clear those. There are no known conflicts. I didn't run the unit suites; the test-validity read above comes from reading the pre-delta and head code, not from executing it. I also don't know how long the WMS focus lag actually runs after launchApp or after Home on a real device, so the launch-lag and Home-lag cases in the finding above are marked likely rather than confirmed, and I haven't checked whether upstream Maestro's own killApp refuses or auto-backgrounds a foreground app. The author's background-kill replay was confounded by the OOM killer by their own account, so Before this can merge, the kill precondition needs to switch to the AMS resumed activity as above, and then needs a live emulator replay, on an emulator with no concurrent simulators or xcodebuild and no lowmemorykiller/signal 9 for the package in logcat: |
Summary
Supports Maestro
killApp(system-initiated process death) end to end, mirroringstopAppplumbing. On Android it dispatchesadb shell am killwith its own foreground/liveness preconditions; every other platform ignores the kill mode and falls through to an ordinary close (stopApp-equivalent). No new public CLI surface —killAppstays inside the Maestro compatibility layer.CloseApplicationInput.mode?: 'kill'threaded throughcloseApplicationkillAndroidApp(am kill), with a foreground refusal (android-kill-requires-background-app), a probe-unavailable refusal (android-process-probe-unavailable), and a survived-kill refusal (android-kill-process-survived)killAppIR kind, parser, runtime port, daemon projection to an app-only close carryingkillAppdispatchkillApp; session close (both the stored-session and no-stored-sessioncloseWithoutSessionpaths) passesmode: killintocloseApplicationupstream/116_kill_appnow classifies identical; declared divergence removed. Touched 31 files.Validation
Tested commit
e676aded6. Focused suites pass: contracts interaction, maestro parser/runtime-port/daemon-port/conformance-verify (14 pass), android app-lifecycle, session-close, replay-maestro-request, fuzz arbitraries, maestro help/docs sync.pnpm check:quickpasses.pnpm check:affected --run: fallow audit clean on changed files. Unresolved:check:production-exportsreports 68 unused exports, reproduced identical onorigin/main, pre-existing.Live Android emulator replay (
agent-device replay --maestro) found and fixed a real foreground-detection race inkillAndroidPackage; see the PR review thread for the evidence and remaining caveats.