simframe 0.14.3 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -323,6 +323,7 @@ boundary hands it frames and nothing above it knows what a simulator is.
323
323
  | Clipboard, and `paste` into a field | yes | the emulator's gRPC `setClipboard`, over `node:http2`, no dependency, then `KEYCODE_PASTE` to deliver it |
324
324
  | List/resolve devices, launch, terminate, open a URL, permissions | yes | `adb`, with the permission state read back off the device |
325
325
  | Accessibility tree | **not available (OCR + CV only)** | `uiautomator dump` costs **2,012 ms** a read, against 45 ms for the iOS tree. See [`docs/DEFERRED.md`](docs/DEFERRED.md) |
326
+ | A launch confirmed to have reached the front | **not available (the launch is not checked)** | iOS compares the pid `simctl launch` printed against the pid the device reports as frontmost, in **2–5 ms**. Nothing here reports either; `am start` fronts synchronously, which is why it has not bitten — but that is not a check. See [`docs/DEFERRED.md`](docs/DEFERRED.md) |
326
327
 
327
328
  ```bash
328
329
  # an emulator is found the same way a simulator is
@@ -402,7 +403,7 @@ steer the model is a tool surface the model uses wrong.
402
403
  | `sim_flow_run` | Replay a flow that verified end to end. |
403
404
  | `sim_find` | Resolve an intent to one control, without acting on it. |
404
405
  | `sim_tap` · `sim_type_into` · `sim_scroll_to` · `sim_wait_for` · `sim_assert` | Single actions, for when you genuinely only have one step. Each is one `sim_do` step underneath. |
405
- | `sim_launch` · `sim_open_url` · `sim_permission` | Launch with arguments and environment; open a deep link; grant a privacy permission instead of tapping a system alert. |
406
+ | `sim_launch` · `sim_open_url` · `sim_permission` | Launch with arguments and environment; open a deep link; grant a privacy permission instead of tapping a system alert. A launch is **confirmed to have reached the front**, by comparing the pid `simctl` started against the pid the device reports as frontmost — so *"the process started"* is no longer reported as *"the app is on screen"*. |
406
407
  | `sim_wait` | Waits for the screen to change *and then* settle. |
407
408
  | `sim_look` | **The only tool that returns an image**, capped at 1024 px. For layout, colour, spacing — questions text cannot answer. |
408
409
  | `sim_recall` · `sim_strip` | Look backwards: a text timeline of what happened, or recent frames tiled into one image. |
@@ -994,11 +995,22 @@ said a word — the exact failure shape, found by the thing built to catch it.
994
995
  dramatically between visits will simply be rebuilt.
995
996
  - It speeds up *confirming* a fix, not *locating* one. A bug living in a memo
996
997
  comparator or a stale closure is not visible in any frame.
997
- - A switch is tapped at the centre of its frame, and a switch's frame is the
998
- whole row — so the tap lands on the label and the control, which sits at the
999
- trailing end, does not move. Use `@x,y` on the control for now. Filed with
1000
- the measurement in `docs/DEFERRED.md`; it is a role-specific tap point, not a
1001
- patch at one call site.
998
+ - A switch is tapped at its **activation point** when the app publishes one —
999
+ UIKit's `accessibilityActivationPoint`, which is what a switch answers with,
1000
+ and tapping it flipped a real switch 3 of 3 times where the frame centre
1001
+ managed 0 of 3. Only **4 of 75** elements on a measured screen publish one,
1002
+ though, so where the app says nothing the tap still goes to the centre of the
1003
+ frame — and a switch's frame is the whole row, so it lands on the label and
1004
+ the control at the trailing end does not move. `@x,y` remains the escape
1005
+ hatch there. Never a guessed offset: nil means the app did not answer.
1006
+ - A launch is confirmed to have fronted **on iOS only**. It compares the pid
1007
+ `simctl launch` printed against the pid the device reports as frontmost, in
1008
+ 2–5 ms. Android reports neither, so a launch there is not checked — `am start`
1009
+ fronts synchronously, which is why it has not bitten, but that is not a check
1010
+ and `doctor` says so. Note that a launch which starts a process without
1011
+ bringing it forward now **fails** rather than returning success: a flow that
1012
+ used to pass through such a launch and then assert on the previous app's
1013
+ screen will start failing, correctly.
1002
1014
  - The simulator's display pipeline stops rendering under rapid app relaunch —
1003
1015
  about six cycles, reproducibly — and every frame comes back black while
1004
1016
  `simctl` itself reports success. simframe now says so instead of reading a
@@ -62,6 +62,24 @@ public struct AXTree: Sendable {
62
62
  }
63
63
  }
64
64
 
65
+ /// The application the device is showing right now.
66
+ ///
67
+ /// Deliberately not a bundle id: nothing on this path publishes one, and a
68
+ /// display name that merely *looks* like an identifier would invite exactly the
69
+ /// string comparison this type exists to avoid.
70
+ public struct FrontmostApp: Sendable {
71
+ /// The guest pid, or nil when the frontmost application object would not
72
+ /// answer for it. Nil is "cannot say", never "not that app".
73
+ public let pid: Int32?
74
+ /// The application element's own title, for reporting.
75
+ public let title: String?
76
+
77
+ public init(pid: Int32?, title: String?) {
78
+ self.pid = pid
79
+ self.title = title
80
+ }
81
+ }
82
+
65
83
  public enum AccessibilityError: Error, CustomStringConvertible {
66
84
  case unavailable(String)
67
85
  case noFrontmostApplication
@@ -219,11 +237,54 @@ public final class AccessibilityBridge {
219
237
  }
220
238
  }
221
239
 
222
- /// The pid of the app currently frontmost, or nil when the bridge cannot say.
223
- public func frontmostPid() -> Int32? {
224
- guard let app = frontmostApplication() else { return nil }
225
- guard app.responds(to: NSSelectorFromString("pid")) else { return nil }
226
- return (app.value(forKey: "pid") as? NSNumber)?.int32Value
240
+ /// Who is in front, as the device itself reports it.
241
+ ///
242
+ /// This exists because `launch` could not tell "the app was already in
243
+ /// front" from "the app never came forward": both return ok and both leave
244
+ /// the screen unchanged. Screen change cannot settle it either way —
245
+ /// relaunching an app that is already frontmost legitimately lands on the
246
+ /// same screen — so the discriminator has to be identity.
247
+ ///
248
+ /// `pid` is the number to compare against, because it is the same one
249
+ /// `simctl launch` prints, so the caller tests equality rather than
250
+ /// matching a display name against a bundle id. `title` is for the human
251
+ /// reading the verdict and is a display name, not an identifier.
252
+ public func frontmostApp() throws -> FrontmostApp {
253
+ guard let app = frontmostApplication() else { throw AccessibilityError.noFrontmostApplication }
254
+ let pid = app.responds(to: NSSelectorFromString("pid"))
255
+ ? (app.value(forKey: "pid") as? NSNumber)?.int32Value
256
+ : nil
257
+ // Probing several names rather than trusting one, because a bare pid is
258
+ // not a diagnosis. A runner held the front at pid 7797 through nine
259
+ // consecutive failed launches and the whole question was whether that
260
+ // was SpringBoard or a lock screen — unanswerable from a number.
261
+ var title: String?
262
+ if let root = elementClass
263
+ .perform(NSSelectorFromString("platformElementWithTranslationObject:"), with: app)?
264
+ .takeUnretainedValue() as? NSObject {
265
+ // Reported RAW, generic answers included, and the judgement about
266
+ // them is made a layer up. Measured: in Settings this reads
267
+ // "Settings"; press home and the SAME pid is still frontmost while
268
+ // the answer degrades to the bare word "application". That
269
+ // degradation is a signal — an app frontmost by pid that has
270
+ // stopped naming itself is the shape item 171 is about — so
271
+ // swallowing it here would throw away the interesting half. This
272
+ // file's job is to say what the translator said.
273
+ for name in ["AXTitle", "AXDescription"] {
274
+ if let found = string(attribute(root, name)) { title = found; break }
275
+ }
276
+ }
277
+ // The translation object itself, if the element would not say. Keys, not
278
+ // selectors, because these are properties on a private class and a key
279
+ // that is absent is caught by `responds(to:)` rather than by an
280
+ // Objective-C exception Swift cannot catch.
281
+ if title == nil {
282
+ for key in ["bundleId", "bundleIdentifier", "displayName", "processName"]
283
+ where app.responds(to: NSSelectorFromString(key)) {
284
+ if let found = string(app.value(forKey: key)) { title = found; break }
285
+ }
286
+ }
287
+ return FrontmostApp(pid: pid, title: title)
227
288
  }
228
289
 
229
290
  private func frontmostApplication() -> NSObject? {
@@ -275,6 +275,10 @@ public final class CoreSimulatorPlatform: SimulatorPlatform {
275
275
  try bridge().tree()
276
276
  }
277
277
 
278
+ public func frontmostApp() throws -> FrontmostApp {
279
+ try bridge().frontmostApp()
280
+ }
281
+
278
282
  /// Serialised, because building a bridge installs a delegate on a
279
283
  /// **process-global** translator that holds it weakly.
280
284
  ///
@@ -171,6 +171,13 @@ public protocol SimulatorPlatform: AnyObject {
171
171
  /// An app still launching has no tree yet, and this reports that as it is —
172
172
  /// one node, no children — rather than retrying until it looks populated.
173
173
  func accessibilityTree() throws -> AXTree
174
+ /// Which application the device is showing, when the platform can say.
175
+ ///
176
+ /// Separate from `accessibilityTree()` even though the same bridge answers
177
+ /// both, because the caller that needs it — "did the app I launched come
178
+ /// forward?" — must not pay for a whole tree walk to ask a one-field
179
+ /// question, and must be able to ask it repeatedly while waiting.
180
+ func frontmostApp() throws -> FrontmostApp
174
181
 
175
182
  // MARK: App lifecycle. These are simctl, not private API — no HID needed.
176
183
 
@@ -14,6 +14,8 @@ public final class StubPlatform: SimulatorPlatform {
14
14
  /// What `accessibilityTree()` should answer. Empty by default, which is
15
15
  /// what a device with no app in the foreground genuinely looks like.
16
16
  public var stubTree: AXTree = AXTree(nodes: [])
17
+ /// What `frontmostApp()` should answer.
18
+ public var stubFrontmost = FrontmostApp(pid: 1234, title: "Stub")
17
19
 
18
20
  public init(width: Int = 1206, height: Int = 2622, tint: UInt8 = 0) {
19
21
  self.width = width
@@ -130,4 +132,9 @@ extension StubPlatform {
130
132
  recorded.append("accessibilityTree()")
131
133
  return stubTree
132
134
  }
135
+
136
+ public func frontmostApp() throws -> FrontmostApp {
137
+ recorded.append("frontmostApp()")
138
+ return stubFrontmost
139
+ }
133
140
  }
@@ -235,6 +235,16 @@ case "run":
235
235
  "scale": device.scale],
236
236
  "engine": "simframed",
237
237
  ])
238
+ case "frontmost":
239
+ // The one-field question, on its own action, because the
240
+ // caller asks it in a loop while waiting for a launch to
241
+ // land. Routing it through "ui" would pay for a tree walk
242
+ // and an OCR pass per poll.
243
+ let front = try platform.frontmostApp()
244
+ var out: [String: Any] = [:]
245
+ if let pid = front.pid { out["pid"] = Int(pid) }
246
+ if let title = front.title { out["title"] = title }
247
+ return done(out)
238
248
  case "ui":
239
249
  // The accessibility tree and OCR read the same instant of
240
250
  // the screen and neither needs the other, so they run
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simframe",
3
- "version": "0.14.3",
3
+ "version": "0.15.1",
4
4
  "mcpName": "io.github.lvlrSajjad/simframe",
5
5
  "description": "Always-warm iOS Simulator and Android emulator frames: agents read the screen in ~20ms instead of waiting on screenshots. MCP server + CLI.",
6
6
  "keywords": [
@@ -186,11 +186,7 @@ const report = {
186
186
 
187
187
  console.log('\nflow runs agent p50 human p50 HPI_time step_ratio');
188
188
  for (const f of report.flows) {
189
- console.log(
190
- `${f.flow.padEnd(24)} ${String(f.runs).padStart(5)} ${`${f.agent_ms.p50}ms`.padStart(9)} ` +
191
- `${(f.human_median_ms ? `${f.human_median_ms}ms` : '—').padStart(9)} ` +
192
- `${String(f.hpi_time ?? '—').padStart(8)} ${String(f.step_ratio ?? '—').padStart(10)}`,
193
- );
189
+ console.log(metrics.flowRow(f));
194
190
  }
195
191
  report.overall.hpi_time_median_of_passes = passTimes.length ? Number(metrics.median(passTimes).toFixed(3)) : null;
196
192
  const o = report.overall;
@@ -23,33 +23,8 @@
23
23
  // becomes visible instead of arguable.
24
24
  import { spawn } from 'node:child_process';
25
25
  import fs from 'node:fs';
26
+ import { deviceCause } from './device-state.mjs';
26
27
 
27
- /** Conditions that are the simulator, not the code. Each seen in a real run. */
28
- const DEVICE_STATE = [
29
- [/NSPOSIXErrorDomain.*code=?\s*60|Operation timed out/i, 'simctl stopped answering (NSPOSIXErrorDomain 60)'],
30
- [/did not produce a frame|produced no frame in \d+s/i, 'the daemon is up and the display renders nothing'],
31
- [/Timeout waiting for screen surfaces|display surface is not answering|display surface could not be read/i, 'the display surface is wedged'],
32
- [/no frames buffered|capture is wedged/i, 'capture stopped'],
33
- [/the second app never launched|could not be dispatched/i, 'an app would not launch'],
34
- // A launched app that never comes to the front, seen as the tour waiting for
35
- // one of its landmarks on a screen that is showing a clock and nothing else.
36
- //
37
- // Measured on a runner: `ok launch — launched com.apple.Preferences
38
- // (relaunched)` followed by `waited 8000ms for General: "General" is not on
39
- // this screen. Visible: 10:50, .?o (the screen has not moved for 6181ms)`.
40
- // Two labels, one of them a clock, on a still screen — the device is not
41
- // presenting the app, and the guard called that a check failing on its
42
- // merits and declined to revive.
43
- //
44
- // Deliberately narrow. It requires the wait to have failed AND the screen to
45
- // have been still AND almost nothing readable: a tour that genuinely asks for
46
- // the wrong label has a screen full of other labels, and must keep failing
47
- // rather than being retried into a pass.
48
- [
49
- /never arrived[\s\S]*?Visible:[^\n]{0,24}\(the screen has not moved for \d+ms/i,
50
- 'a launched app never came to the front (the screen shows a clock and nothing else)',
51
- ],
52
- ];
53
28
 
54
29
  const udid = process.argv[2];
55
30
  const sep = process.argv.indexOf('--');
@@ -74,8 +49,6 @@ const summary = (line) => {
74
49
  if (f) { try { fs.appendFileSync(f, `${line}\n`); } catch { /* summaries are a nicety */ } }
75
50
  };
76
51
 
77
- const deviceCause = (text) => DEVICE_STATE.find(([re]) => re.test(text))?.[1] ?? null;
78
-
79
52
  const first = await run(cmd);
80
53
  if (first.code === 0) process.exit(0);
81
54
 
@@ -0,0 +1,62 @@
1
+ // Is a failed CI step the device's fault or the check's?
2
+ //
3
+ // Its own module, and the reason is the same one that moved `classifyStray` out
4
+ // of `eval-fingerprint.mjs`: `ci-device-guard.mjs` reads `process.argv` and
5
+ // `process.exit(2)`s at import, so nothing could ever test this table there —
6
+ // and the only thing that exercised it was a hosted runner, at the end of a
7
+ // fifteen-minute job, in the middle of a report. Two runtime bugs in this
8
+ // project came from logic that was correct and had never executed.
9
+ //
10
+ // Every entry here has been seen in a real run. Adding one from imagination is
11
+ // how a guard starts reviving genuine failures into passes.
12
+
13
+ /** Conditions that are the simulator, not the code. Each seen in a real run. */
14
+ export const DEVICE_STATE = [
15
+ [/NSPOSIXErrorDomain.*code=?\s*60|Operation timed out/i, 'simctl stopped answering (NSPOSIXErrorDomain 60)'],
16
+ [/did not produce a frame|produced no frame in \d+s/i, 'the daemon is up and the display renders nothing'],
17
+ [/Timeout waiting for screen surfaces|display surface is not answering|display surface could not be read/i, 'the display surface is wedged'],
18
+ [/no frames buffered|capture is wedged/i, 'capture stopped'],
19
+ [/the second app never launched|could not be dispatched/i, 'an app would not launch'],
20
+ // A launched app that never comes to the front, seen as the tour waiting for
21
+ // one of its landmarks on a screen that is showing a clock and nothing else.
22
+ //
23
+ // Measured on a runner: `ok launch — launched com.apple.Preferences
24
+ // (relaunched)` followed by `waited 8000ms for General: "General" is not on
25
+ // this screen. Visible: 10:50, .?o (the screen has not moved for 6181ms)`.
26
+ // Two labels, one of them a clock, on a still screen — the device is not
27
+ // presenting the app, and the guard called that a check failing on its
28
+ // merits and declined to revive.
29
+ //
30
+ // Deliberately narrow. It requires the wait to have failed AND the screen to
31
+ // have been still AND almost nothing readable: a tour that genuinely asks for
32
+ // the wrong label has a screen full of other labels, and must keep failing
33
+ // rather than being retried into a pass.
34
+ [
35
+ /never arrived[\s\S]*?Visible:[^\n]{0,24}\(the screen has not moved for \d+ms/i,
36
+ 'a launched app never came to the front (the screen shows a clock and nothing else)',
37
+ ],
38
+ // The same condition, now said outright by the step that suffered it instead
39
+ // of inferred from the shape of the screen afterwards. Item 169 gave `launch`
40
+ // a pid to compare, so a launch that starts a process and never fronts it
41
+ // reports itself; this signature fires on the cause rather than on a
42
+ // consequence that had to be recognised by "two labels, one a clock".
43
+ //
44
+ // It cannot be triggered by a tour asking for the wrong label — only a failed
45
+ // launch emits this sentence — so it needs none of the narrowing above.
46
+ [
47
+ /never came to the front within \d+ms/i,
48
+ 'a launched app never came to the front (the launch said so itself, by pid)',
49
+ ],
50
+ // Seen on the v0.14.3 bench run: `could not launch com.apple.Preferences:
51
+ // The system shell (SpringBoard:36454) probably crashed.` The guest's window
52
+ // server going down is the device, not the check, and nothing here matched it.
53
+ [
54
+ /system shell \(SpringBoard[^)]*\) probably crashed/i,
55
+ "the guest's SpringBoard crashed, so nothing can be fronted",
56
+ ],
57
+ ];
58
+
59
+ /** The condition this output shows, or null when the check failed on its merits. */
60
+ export function deviceCause(text) {
61
+ return DEVICE_STATE.find(([re]) => re.test(String(text ?? '')))?.[1] ?? null;
62
+ }
package/src/actions.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // Waiting uses a baseline captured BEFORE each action, which is the whole
4
4
  // reason these scripts are reliable rather than racy.
5
5
  import * as api from './index.js';
6
+ import * as frontmost from './frontmost.js';
6
7
  import * as graph from './graph.js';
7
8
  import * as input from './input.js';
8
9
  import * as intent from './intent.js';
@@ -396,12 +397,15 @@ export async function runScript(
396
397
  // its coordinates from the screen size. Reading them back off the step
397
398
  // would diagnose a point nothing was ever aimed at.
398
399
  const aim = { at: null };
400
+ // Carried out of the dispatch the way `aim` is, because the note that
401
+ // reads it is built after the step has returned a string.
402
+ const landing = { verdict: null };
399
403
  let detail;
400
404
  // A selector that did not resolve gets the step's own alternatives before
401
405
  // the batch is abandoned. Anything else propagates: retrying from a screen
402
406
  // we did not expect to be on is not a retry, it is a second guess.
403
407
  try {
404
- detail = await runStep(deviceQuery, udid, step, { screen, options, frames, focus, aim });
408
+ detail = await runStep(deviceQuery, udid, step, { screen, options, frames, focus, aim, landing });
405
409
  } catch (thrown) {
406
410
  let err = thrown;
407
411
  // Ask the supervisor before anything is abandoned. It sits behind the
@@ -435,7 +439,7 @@ export async function runScript(
435
439
  options,
436
440
  }).catch(() => null);
437
441
  try {
438
- detail = await runStep(deviceQuery, udid, step, { screen, options, frames, focus, aim });
442
+ detail = await runStep(deviceQuery, udid, step, { screen, options, frames, focus, aim, landing });
439
443
  detail += ` [the local supervisor said ${ruling.decision}; it worked on the second attempt]`;
440
444
  ruled('recovered');
441
445
  continue;
@@ -475,7 +479,7 @@ export async function runScript(
475
479
  let last = err;
476
480
  for (const label of allowed) {
477
481
  try {
478
- detail = await runStep(deviceQuery, udid, stepWithTarget(step, label), { screen, options, frames, focus, aim });
482
+ detail = await runStep(deviceQuery, udid, stepWithTarget(step, label), { screen, options, frames, focus, aim, landing });
479
483
  detail += ` [after ${tried.map((t) => JSON.stringify(String(t))).join(', ')} did not resolve]`;
480
484
  last = null;
481
485
  break;
@@ -770,8 +774,14 @@ export async function runScript(
770
774
  // front an already-running app, so the first reading is the likely one —
771
775
  // but likely is not the same as said, and the step is the only place that
772
776
  // can say it.
777
+ // Item 169 closed the ambiguity this comment describes, so the note no
778
+ // longer has to hedge when the pid answered. `fronted` with an unmoved
779
+ // screen is now a *fact* about which reading was right, not a guess:
780
+ // the app is confirmed in front, so it was already there.
773
781
  const launchNote = step.action === 'launch' && settled?.noVisibleChange
774
- ? ' [the screen did not change, so this app was already in front — or it did not come forward]'
782
+ ? (landing.verdict === 'fronted'
783
+ ? ' [the screen did not change and this app is confirmed frontmost — it was already in front]'
784
+ : ' [the screen did not change, so this app was already in front — or it did not come forward]')
775
785
  : '';
776
786
  const filling = stillFillingIn(afterReading?.entry);
777
787
  // Item 120: when a gesture aimed at a coordinate does nothing, say what
@@ -2483,12 +2493,54 @@ async function runStep(deviceQuery, udid, step, ctx) {
2483
2493
  return `pressed key ${step.value ?? step.code}`;
2484
2494
  case 'launch': {
2485
2495
  const bundleId = step.value ?? step.bundleId;
2486
- await launchApp(udid, bundleId, {
2496
+ const started = await launchApp(udid, bundleId, {
2487
2497
  args: step.args ?? [],
2488
2498
  env: step.env ?? {},
2489
2499
  terminateFirst: step.relaunch === true,
2490
2500
  });
2491
- return `launched ${bundleId}${step.relaunch ? ' (relaunched)' : ''}`;
2501
+ // Item 169: simctl returning ok means the process started, not that the
2502
+ // app came forward, and the two came apart repeatedly on a loaded
2503
+ // runner — leaving the device on the previous app under a step that
2504
+ // reported success. The pid settles it; the screen cannot.
2505
+ let landed = await frontmost.check(udid, started?.pid ?? null);
2506
+ // One bounded retry, on a condition we have now *measured* rather than
2507
+ // guessed at. This was rejected in the first pass for a good reason —
2508
+ // retrying on "no visible change" papers over a signal that cannot tell
2509
+ // success from failure — and that objection does not apply to a known
2510
+ // `did-not-front`: the app is running, a second `launch` without a
2511
+ // terminate simply fronts it, and that is the 237ms path.
2512
+ //
2513
+ // It is deliberately NOT a terminate-and-relaunch. That is the gesture
2514
+ // this project already knows wedges the simulator's display pipeline
2515
+ // (README, "rapid app relaunch"), so the recovery must not be the thing
2516
+ // that causes the next failure.
2517
+ //
2518
+ // Reported, never silent: CI's own step vehicle has been doing this by
2519
+ // hand three times, invisibly, which is how the defect stayed at the
2520
+ // harness layer for so long. A retry that does not show up in the
2521
+ // summary is a rate nobody can argue with.
2522
+ let retried = null;
2523
+ if (landed.verdict === 'did-not-front') {
2524
+ retried = landed;
2525
+ await launchApp(udid, bundleId, { args: step.args ?? [], env: step.env ?? {} });
2526
+ landed = await frontmost.check(udid, started?.pid ?? null);
2527
+ }
2528
+ if (landed.verdict === 'did-not-front') {
2529
+ throw new Error(
2530
+ `launched ${bundleId} (pid ${started.pid}) but it never came to the front`
2531
+ + ` within ${landed.ms}ms${retried ? ', on either of two attempts' : ''} — ${landed.frontmost === null
2532
+ ? 'nothing is frontmost'
2533
+ : `${landed.holder} still is`}`
2534
+ + `. ${frontmost.describeHeld(landed.held, started.pid)}.`,
2535
+ );
2536
+ }
2537
+ if (ctx.landing) ctx.landing.verdict = landed.verdict;
2538
+ return `launched ${bundleId}${step.relaunch ? ' (relaunched)' : ''}`
2539
+ + (landed.verdict === 'fronted' ? ` (frontmost after ${landed.ms}ms)` : '')
2540
+ + (retried
2541
+ ? ` [it did not front on the first attempt — ${frontmost.describeHeld(retried.held, started.pid)}`
2542
+ + `; a second launch fronted it. The launch is unreliable on this host.]`
2543
+ : '');
2492
2544
  }
2493
2545
  case 'terminate':
2494
2546
  await terminateApp(udid, step.value ?? step.bundleId);
package/src/baseline.js CHANGED
@@ -175,7 +175,22 @@ export async function resetFor(udid, flow) {
175
175
  for (let attempt = 0; attempt <= (reset.maxBack ?? 4); attempt += 1) {
176
176
  await api.waitFor(udid, { mode: 'stable', stableMs: 350, timeoutMs: 3000 });
177
177
  try {
178
- await api.locate(udid, reset.rootMarker, { refresh: attempt > 0 });
178
+ // **Fresh on every attempt, the first one included.** This read
179
+ // `refresh: attempt > 0`, which is the exact pattern `actions.js`
180
+ // has a test forbidding — and the test only ever read actions.js, so
181
+ // the same defect sat here unguarded. It is not theoretical: the
182
+ // reset ran immediately after a launch, resolved `Accessibility`
183
+ // against the map remembered from the PREVIOUS run's end screen,
184
+ // did not find it, and went looking for a "back" control on a
185
+ // Settings root that has none:
186
+ //
187
+ // (reset: could not return com.apple.Preferences to its root
188
+ // screen: "back" is not on this screen. Visible: Settings,
189
+ // Apple Account, …, Accessibility, …)
190
+ //
191
+ // Accessibility is right there in that list. A check has no second
192
+ // opinion, so it may not resolve its first look from memory.
193
+ await api.locate(udid, reset.rootMarker, { refresh: true });
179
194
  break;
180
195
  } catch {
181
196
  const back = await api.locate(udid, 'back', { refresh: true });
package/src/cli.js CHANGED
@@ -1234,11 +1234,7 @@ async function main() {
1234
1234
  emit(flags, report, [
1235
1235
  flags.last ? `the last ${num(flags.last)} run(s) of each flow, of ${all.filter((f) => f.flow_name).length} named runs in the log` : null,
1236
1236
  'flow runs agent p50 human p50 HPI_time step_ratio turns esc',
1237
- ...report.flows.map((f) =>
1238
- `${f.flow.padEnd(24)} ${String(f.runs).padStart(5)} ${`${f.agent_ms.p50}ms`.padStart(9)} ` +
1239
- `${(f.human_median_ms ? `${f.human_median_ms}ms` : '—').padStart(9)} ` +
1240
- `${(f.hpi_time ?? '—').toString().padStart(8)} ${(f.step_ratio ?? '—').toString().padStart(10)} ` +
1241
- `${(f.model_turns ?? '—').toString().padStart(5)} ${String(f.escalations).padStart(3)}`),
1237
+ ...report.flows.map((f) => metrics.flowRow(f, { wide: true })),
1242
1238
  '',
1243
1239
  `HPI_accuracy ${report.overall.hpi_accuracy} (${report.overall.runs} runs, ` +
1244
1240
  `${report.overall.runs - runs.filter((r) => r.completed && !r.wrong_action_taken).length} not clean)`,
@@ -1704,6 +1700,15 @@ async function doctor({ json = false, strict = false, device, options = {} } = {
1704
1700
  }
1705
1701
  add(`text recognition (${d.name})`, 'ok',
1706
1702
  daemon ? 'simframed (in-process, off the framebuffer)' : 'sips + helper binary');
1703
+ // Before the `ax` early-out, so a backend without a tree still reports
1704
+ // this one: "is a launch checked?" is a question the user needs answered
1705
+ // on every platform, and item 169 is what an unanswered launch cost.
1706
+ const front = caps.frontmost ?? { supported: false, note: 'this backend does not say' };
1707
+ add(`launch verification (${d.name})`, front.supported ? 'ok' : 'optional',
1708
+ front.supported
1709
+ ? `${front.via} — a launch that never fronts is reported, not called success`
1710
+ : front.note,
1711
+ { key: 'launch.frontmost', value: front.supported ? front.via ?? 'yes' : null });
1707
1712
  if (!caps.ax.supported) {
1708
1713
  add(`accessibility tree (${d.name})`, 'optional', caps.ax.note, { key: 'ax.driver', value: null });
1709
1714
  continue;
package/src/control.js CHANGED
@@ -72,6 +72,8 @@ export const longPress = (udid, x, y, opts = {}) => request(udid, { action: 'lon
72
72
  export const drag = (udid, from, to, opts = {}) =>
73
73
  request(udid, { action: 'drag', x1: from.x, y1: from.y, x2: to.x, y2: to.y, ...opts });
74
74
  export const launch = (udid, bundleId, opts = {}) => request(udid, { action: 'launch', bundleId, ...opts });
75
+ /** Which app the device is showing, by pid — see src/frontmost.js. */
76
+ export const frontmost = (udid) => request(udid, { action: 'frontmost' });
75
77
  export const terminate = (udid, bundleId) => request(udid, { action: 'terminate', bundleId });
76
78
  export const openUrl = (udid, url) => request(udid, { action: 'openUrl', url });
77
79
  export const permission = (udid, permissionAction, service, bundleId) =>
@@ -0,0 +1,171 @@
1
+ // Did the app we launched actually come forward?
2
+ //
3
+ // `launch` could not answer that, and item 169 is what it cost: `launch
4
+ // com.apple.Preferences (relaunch: true)` returning ok, reporting `[no visible
5
+ // change]`, with the device still on the previous app. The CI workflow's own
6
+ // step vehicle retries `simctl launch` three times by hand for exactly this, so
7
+ // the failure was known at the harness layer and unhandled at the library
8
+ // layer, where every user meets it.
9
+ //
10
+ // **Screen change cannot settle it, and that dead end was checked first.**
11
+ // Relaunching an app that is already frontmost legitimately lands on the same
12
+ // screen, so "no visible change" is shared by the success and the failure. The
13
+ // discriminator has to be identity.
14
+ //
15
+ // The identity is a **pid**, not a name, and that is the whole reason this works
16
+ // cheaply: `simctl launch` prints the pid it started, and the daemon's
17
+ // `frontmost` action reports the pid of the application AXPTranslator says is in
18
+ // front. Measured on a real device — 10695/10695 for Preferences,
19
+ // 10762/10762 for Contacts — so the caller compares two integers instead of
20
+ // matching a display name against a bundle id.
21
+ //
22
+ // Its own module, with the device read injectable, because the only thing that
23
+ // ever exercises a launch is a device: a unit test replays pid sequences
24
+ // through `landed` and never boots anything.
25
+ import * as control from './control.js';
26
+
27
+ /**
28
+ * How long to wait for the app to reach the front.
29
+ *
30
+ * Measured, not chosen for feel: an already-running app fronts in ~240 ms and a
31
+ * cold switch to Contacts took 1552 ms on this machine. 5 s leaves room for a
32
+ * loaded runner — the same host where `simctl launch` itself has measured
33
+ * 47–55 s — while still being far below the point where a caller gives up.
34
+ */
35
+ export const FRONT_BUDGET_MS = 5000;
36
+ export const POLL_MS = 100;
37
+
38
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
39
+
40
+ /**
41
+ * Words the translator hands back when it has no name to give.
42
+ *
43
+ * Measured on a device: the application element's title is "Settings" while
44
+ * Settings is on screen, and after a press of home the **same pid** is still
45
+ * frontmost with the title degraded to the bare word "application". That is
46
+ * not a name, and printing it as one would be a confident wrong answer in
47
+ * precisely the state worth noticing — an app frontmost by pid that has
48
+ * stopped naming itself. So the word is kept (the daemon reports raw) and
49
+ * phrased honestly here, where it can be tested without a device.
50
+ */
51
+ const GENERIC_NAMES = new Set(['application', 'window', 'unknown', 'group', 'element']);
52
+
53
+ /** How to refer to whoever holds the front. */
54
+ export function nameHolder(pid, title) {
55
+ if (pid === null || pid === undefined) return 'nothing';
56
+ // Blank counts as absent, not as a degraded answer: whitespace is the
57
+ // translator saying nothing, and "no longer names itself" is a claim about
58
+ // an app that answered.
59
+ const said = typeof title === 'string' ? title.trim() : '';
60
+ if (!said) return `pid ${pid}`;
61
+ return GENERIC_NAMES.has(said.toLowerCase())
62
+ ? `pid ${pid} (an app that no longer names itself)`
63
+ : `pid ${pid} (${said})`;
64
+ }
65
+
66
+ /** Who is on screen — pid to compare, title to report. Nulls mean "cannot say". */
67
+ export async function read(udid) {
68
+ if (!udid || !control.available(udid)) return { pid: null, title: null };
69
+ try {
70
+ const r = await control.request(udid, { action: 'frontmost' });
71
+ return { pid: typeof r?.pid === 'number' ? r.pid : null, title: r?.title ?? null };
72
+ } catch {
73
+ // A daemon that cannot answer is "cannot say". Reporting it as "not that
74
+ // app" would turn a missing sensor into a failed launch, which is the
75
+ // false refusal item 161 was reverted for.
76
+ return { pid: null, title: null };
77
+ }
78
+ }
79
+
80
+ /** The pid alone, for callers that only compare. */
81
+ export const frontmostPid = async (udid) => (await read(udid)).pid;
82
+
83
+ /**
84
+ * Wait for `pid` to be the app in front.
85
+ *
86
+ * Three verdicts, and the third is not a failure:
87
+ *
88
+ * - `fronted` — the launched pid is the frontmost pid. The launch worked,
89
+ * whether or not the screen moved.
90
+ * - `did-not-front` — the budget expired with someone else in front. This is
91
+ * the defect, now visible.
92
+ * - `cannot-say` — no pid from the launch, or nothing on this platform reports
93
+ * who is frontmost. The caller keeps whatever it said before this existed.
94
+ *
95
+ * @param {object} o
96
+ * @param {number|null} o.pid what the launch reported
97
+ * @param {() => Promise<{pid: number|null, title: string|null}>} o.read who is in front now
98
+ */
99
+ export async function landed({
100
+ pid, read, budgetMs = FRONT_BUDGET_MS, pollMs = POLL_MS,
101
+ now = () => Date.now(), wait = sleep,
102
+ } = {}) {
103
+ if (typeof pid !== 'number') {
104
+ return { verdict: 'cannot-say', reason: 'the launch did not report a pid' };
105
+ }
106
+ const started = now();
107
+ let seen = null;
108
+ let asked = 0;
109
+ // Who held the front while we waited, in order. The instrument, not decoration:
110
+ // "the budget was too short" and "the app never went anywhere" produce the same
111
+ // verdict and want opposite remedies, and one list of pids tells them apart —
112
+ // a front that changed hands twice is a slow device, a single pid for the whole
113
+ // budget is a launch that did not happen. Widening a budget without this is the
114
+ // mistake item 146 was, twice.
115
+ const held = [];
116
+ let holder = null;
117
+ for (;;) {
118
+ const look = await read();
119
+ seen = look?.pid ?? null;
120
+ asked += 1;
121
+ if (held[held.length - 1] !== seen) held.push(seen);
122
+ if (seen !== null) holder = look;
123
+ if (seen === pid) return { verdict: 'fronted', ms: now() - started, polls: asked, held };
124
+ // Checked after at least one read, so a platform that cannot answer says so
125
+ // rather than spending the whole budget finding that out.
126
+ if (seen === null && asked === 1) {
127
+ return { verdict: 'cannot-say', reason: 'nothing on this device reports which app is frontmost' };
128
+ }
129
+ if (now() - started >= budgetMs) {
130
+ return {
131
+ verdict: 'did-not-front',
132
+ ms: now() - started,
133
+ polls: asked,
134
+ frontmost: seen,
135
+ held,
136
+ // Named, not just numbered. A runner held the front at pid 7797 through
137
+ // nine consecutive failed launches and the only question that mattered
138
+ // — *what* is 7797 — was the one a number could not answer.
139
+ holder: nameHolder(seen, holder?.pid === seen ? holder.title : null),
140
+ };
141
+ }
142
+ await wait(pollMs);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * `held` as a sentence, because a list of pids is not a diagnosis by itself.
148
+ *
149
+ * **Only meaningful for a `did-not-front`.** Handed a successful wait's `held`
150
+ * it says the launch never took effect about a launch that plainly did — I did
151
+ * exactly that while testing this — so `pid` is taken and the success is
152
+ * refused rather than described. Callers that hold the verdict gate on it;
153
+ * this is the belt for the one that forgets.
154
+ */
155
+ export function describeHeld(held = [], pid = null) {
156
+ const real = held.filter((p) => p !== null);
157
+ if (pid !== null && real[real.length - 1] === pid) {
158
+ return `pid ${pid} did reach the front — there is nothing to explain`;
159
+ }
160
+ if (real.length <= 1) {
161
+ return real.length === 1
162
+ ? `pid ${real[0]} held the front for the whole wait, so the launch never took effect`
163
+ : 'nothing held the front at any point';
164
+ }
165
+ return `the front changed hands ${real.length - 1} time(s) (${real.join(' → ')}),`
166
+ + ' so the device was switching apps and simply never reached this one';
167
+ }
168
+
169
+ /** `landed`, reading from the daemon. */
170
+ export const check = (udid, pid, opts = {}) =>
171
+ landed({ pid, read: () => read(udid), ...opts });
package/src/metrics.js CHANGED
@@ -601,6 +601,30 @@ export function harmonicMean(xs) {
601
601
  * a human baseline. A flow with no human baseline gets no HPI_time — reported
602
602
  * as null, never as 1.0, because a missing denominator is not parity.
603
603
  */
604
+ /**
605
+ * One flow's row in the HPI table, shared by `simframe hpi` and the bench
606
+ * script because they had the same row duplicated byte for byte.
607
+ *
608
+ * It lives here, next to the report it renders, for a reason the v0.15.0 tag
609
+ * paid for: making `agent_ms` null for a flow that timed nothing was correct,
610
+ * and both printers dereferenced `.p50` on it. `bench` died with exit 1 and no
611
+ * hpi.json, and `simframe hpi` would have done the same for any user whose
612
+ * flow never completed. The metric had a test; nothing rendered it. A format
613
+ * duplicated in two files is a format that gets fixed in one.
614
+ *
615
+ * `—` means "not measured", never zero.
616
+ */
617
+ export function flowRow(f, { wide = false } = {}) {
618
+ const cell = (v, unit = '') => (v === null || v === undefined ? '—' : `${v}${unit}`);
619
+ const row = `${f.flow.padEnd(24)} ${String(f.runs).padStart(5)} `
620
+ + `${cell(f.agent_ms?.p50, 'ms').padStart(9)} `
621
+ + `${cell(f.human_median_ms, 'ms').padStart(9)} `
622
+ + `${cell(f.hpi_time).padStart(8)} ${cell(f.step_ratio).padStart(10)}`;
623
+ return wide
624
+ ? `${row} ${cell(f.model_turns).padStart(5)} ${String(f.escalations).padStart(3)}`
625
+ : row;
626
+ }
627
+
604
628
  export function hpi({ flows, baselines = {} }) {
605
629
  const byName = new Map();
606
630
  for (const f of flows) {
@@ -610,13 +634,31 @@ export function hpi({ flows, baselines = {} }) {
610
634
  }
611
635
 
612
636
  const perFlow = [...byName.entries()].map(([name, runs]) => {
613
- const agent = quartiles(runs.map((r) => r.wall_time_ms));
637
+ // **Time is measured over runs that finished the flow.** This took every
638
+ // run's wall time, failures included, and a failure is fast — so a change
639
+ // that broke a flow registered as the agent getting quicker.
640
+ //
641
+ // Measured, not hypothesised: `settings-larger-text` failed all three runs
642
+ // at ~3.6 s against a 7799 ms human median and reported `hpi_time 2.163`,
643
+ // i.e. "twice as fast as a person", about a flow that never once reached
644
+ // its destination. The composite `hpi` survives that because accuracy
645
+ // divides it down — but the CI gate's threshold is written against
646
+ // `HPI_time`, so the one number the gate reads was the one being flattered
647
+ // by breakage.
648
+ //
649
+ // A flow where nothing completed reports no time at all rather than a
650
+ // flattering one. That is the same discipline as the suite's own "no
651
+ // comparable HPI was measured": a number that cannot be compared must not
652
+ // be offered as one.
653
+ const finished = runs.filter((r) => r.completed);
654
+ const agent = quartiles(finished.map((r) => r.wall_time_ms));
614
655
  const human = baselines[name]?.wall_time_ms ?? null;
615
656
  const humanMedian = human?.p50 ?? null;
616
657
  const stepRatios = runs.map((r) => r.step_ratio).filter((x) => Number.isFinite(x));
617
658
  return {
618
659
  flow: name,
619
660
  runs: runs.length,
661
+ timed_runs: finished.length,
620
662
  agent_ms: agent,
621
663
  human_median_ms: humanMedian,
622
664
  hpi_time: humanMedian && agent?.p50 ? Number((humanMedian / agent.p50).toFixed(3)) : null,
@@ -993,6 +993,16 @@ function capabilities() {
993
993
  supported: false,
994
994
  note: 'not built for Android yet — `uiautomator dump` costs ~2s a read; see docs/DEFERRED.md',
995
995
  },
996
+ // Not borrowed from iOS, and not claimed. iOS reads the frontmost pid off
997
+ // AXPTranslator and compares it with the pid `simctl launch` printed;
998
+ // neither half exists here — the emulator console launches by intent and
999
+ // reports no pid. `am start` does front the activity synchronously, which
1000
+ // is why this has not been the same problem, but "has not been" is not a
1001
+ // check and must not report as one.
1002
+ frontmost: {
1003
+ supported: false,
1004
+ note: 'no frontmost-app read on Android yet, so a launch is not confirmed to have fronted; `am start` fronts synchronously, which is why this has not bitten — see docs/DEFERRED.md',
1005
+ },
996
1006
  };
997
1007
  }
998
1008
 
@@ -264,6 +264,13 @@ async function screenshot(udid, outFile, { mask = 'ignored' } = {}) {
264
264
  * simctl passes launch arguments after the bundle id and environment through
265
265
  * `SIMCTL_CHILD_`-prefixed variables of its own process — which is why env has
266
266
  * to be set on the child rather than passed as flags.
267
+ *
268
+ * Returns the pid simctl started, because starting an app and *fronting* an app
269
+ * are different events and only the pid connects them: the layer above compares
270
+ * it against the pid the device says is in front (item 169). simctl prints
271
+ * `com.apple.Preferences: 10695` and has always printed it; nobody had read it.
272
+ * A shape we do not recognise gives null, which reads as "cannot say" upstairs
273
+ * and never as a failed launch.
267
274
  */
268
275
  async function launchApp(udid, bundleId, { args = [], env = {}, terminateFirst = false } = {}) {
269
276
  if (terminateFirst) {
@@ -279,10 +286,11 @@ async function launchApp(udid, bundleId, { args = [], env = {}, terminateFirst =
279
286
  const childEnv = { ...process.env };
280
287
  for (const [k, v] of Object.entries(env)) childEnv[`SIMCTL_CHILD_${k}`] = String(v);
281
288
  try {
282
- await run('xcrun', ['simctl', 'launch', udid, bundleId, ...args.map(String)], {
289
+ const { stdout } = await run('xcrun', ['simctl', 'launch', udid, bundleId, ...args.map(String)], {
283
290
  timeout: SIMCTL_TIMEOUT_MS,
284
291
  env: childEnv,
285
292
  });
293
+ return { pid: launchedPid(stdout) };
286
294
  } catch (err) {
287
295
  // execFile's message is just "Command failed: ..." with simctl's actual
288
296
  // complaint left in stderr. A CI run failed here and said nothing about
@@ -291,6 +299,12 @@ async function launchApp(udid, bundleId, { args = [], env = {}, terminateFirst =
291
299
  }
292
300
  }
293
301
 
302
+ /** The pid out of simctl's `<bundle-id>: <pid>`, or null if it said otherwise. */
303
+ export function launchedPid(stdout) {
304
+ const m = /:\s*(\d+)\s*$/.exec(String(stdout ?? '').trim());
305
+ return m ? Number(m[1]) : null;
306
+ }
307
+
294
308
  async function terminateApp(udid, bundleId) {
295
309
  try {
296
310
  await run('xcrun', ['simctl', 'terminate', udid, bundleId], { timeout: SIMCTL_TIMEOUT_MS });
@@ -567,6 +581,11 @@ function capabilities() {
567
581
  captureEngines: ['simframed', 'screenshot'],
568
582
  input: { supported: true, via: 'daemon' },
569
583
  ax: { supported: true },
584
+ // Whether a launch can be confirmed to have reached the front. Declared
585
+ // separately from `ax` even though the same bridge answers both, because
586
+ // the user-visible consequence is different: without it `launch` reports
587
+ // that a process started and calls that success (item 169).
588
+ frontmost: { supported: true, via: 'AXPTranslator, compared by pid' },
570
589
  };
571
590
  }
572
591