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 +18 -6
- package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +66 -5
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +4 -0
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +7 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +7 -0
- package/native/simframed/Sources/simframed/main.swift +10 -0
- package/package.json +1 -1
- package/scripts/bench-hpi.mjs +1 -5
- package/scripts/ci-device-guard.mjs +1 -28
- package/scripts/device-state.mjs +62 -0
- package/src/actions.js +58 -6
- package/src/baseline.js +16 -1
- package/src/cli.js +10 -5
- package/src/control.js +2 -0
- package/src/frontmost.js +171 -0
- package/src/metrics.js +43 -1
- package/src/platform/android.js +10 -0
- package/src/platform/ios.js +20 -1
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
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
-
///
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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.
|
|
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": [
|
package/scripts/bench-hpi.mjs
CHANGED
|
@@ -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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
package/src/frontmost.js
ADDED
|
@@ -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
|
-
|
|
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,
|
package/src/platform/android.js
CHANGED
|
@@ -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
|
|
package/src/platform/ios.js
CHANGED
|
@@ -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
|
|