simframe 0.14.2 → 0.15.0
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/check-published.mjs +121 -0
- package/scripts/ci-device-guard.mjs +1 -10
- package/scripts/classify-stray.mjs +63 -0
- package/scripts/device-state.mjs +62 -0
- package/scripts/eval-fingerprint.mjs +115 -13
- package/src/actions.js +225 -13
- package/src/baseline.js +16 -1
- package/src/cli.js +9 -0
- package/src/control.js +2 -0
- package/src/frontmost.js +171 -0
- package/src/metrics.js +19 -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.0",
|
|
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": [
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Does the version on npm contain the code you think it does?
|
|
4
|
+
*
|
|
5
|
+
* **DEFERRED 149.** `0.14.1` was tagged at one commit and a change landed
|
|
6
|
+
* immediately after it, so `npm install simframe@0.14.1` shipped the previous
|
|
7
|
+
* `centerOf()`. An external tester was asked to evaluate that change, diffed
|
|
8
|
+
* the package against the checkout themselves, and wrote: *"I came within one
|
|
9
|
+
* command of filing this report against the wrong binary."*
|
|
10
|
+
*
|
|
11
|
+
* Nothing in the pipeline compared what was tagged against what was being asked
|
|
12
|
+
* for, and the release workflow cannot: it checks that the tag, `package.json`
|
|
13
|
+
* and `server.json` agree, which they did. The gap is between the tag and
|
|
14
|
+
* whatever arrived afterwards.
|
|
15
|
+
*
|
|
16
|
+
* So this is the check to run **before handing someone a version to test**, and
|
|
17
|
+
* before cutting the next one. It fetches the published tarball and compares
|
|
18
|
+
* every shipped source file against the working tree.
|
|
19
|
+
*
|
|
20
|
+
* It deliberately compares against the WORKING TREE rather than a tag: the
|
|
21
|
+
* question being asked is "is what I am about to ask someone to install the code
|
|
22
|
+
* I am looking at", and a tag cannot answer that.
|
|
23
|
+
*
|
|
24
|
+
* Usage:
|
|
25
|
+
* node scripts/check-published.mjs # the version in package.json
|
|
26
|
+
* node scripts/check-published.mjs 0.14.1 # any published version
|
|
27
|
+
* node scripts/check-published.mjs --latest # whatever npm serves as latest
|
|
28
|
+
*
|
|
29
|
+
* Exit 0 when every shipped file matches, 1 when any differs, 2 when the
|
|
30
|
+
* version is not published or npm could not be reached — which is a different
|
|
31
|
+
* answer and must not read as "it matches".
|
|
32
|
+
*/
|
|
33
|
+
import { execFileSync } from 'node:child_process';
|
|
34
|
+
import { createHash } from 'node:crypto';
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
import os from 'node:os';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
|
|
39
|
+
const md5 = (buf) => createHash('md5').update(buf).digest('hex');
|
|
40
|
+
const here = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
|
|
41
|
+
|
|
42
|
+
const arg = process.argv.slice(2).find((a) => !a.startsWith('-'));
|
|
43
|
+
const wantLatest = process.argv.includes('--latest');
|
|
44
|
+
const local = JSON.parse(fs.readFileSync(path.join(here, 'package.json'), 'utf8'));
|
|
45
|
+
|
|
46
|
+
let version = arg ?? local.version;
|
|
47
|
+
if (wantLatest) {
|
|
48
|
+
try {
|
|
49
|
+
version = execFileSync('npm', ['view', local.name, 'version'], { encoding: 'utf8' }).trim();
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error(`could not ask npm for the latest ${local.name}: ${err.message}`);
|
|
52
|
+
process.exit(2);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log(`comparing ${local.name}@${version} on npm against this working tree`);
|
|
57
|
+
|
|
58
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'simframe-published-'));
|
|
59
|
+
let tarball;
|
|
60
|
+
try {
|
|
61
|
+
tarball = execFileSync('npm', ['pack', `${local.name}@${version}`, '--silent', '--pack-destination', tmp], {
|
|
62
|
+
encoding: 'utf8',
|
|
63
|
+
}).trim().split('\n').pop();
|
|
64
|
+
} catch (err) {
|
|
65
|
+
// Not published, unpublished, or no network. None of those is a match.
|
|
66
|
+
console.error(`could not fetch ${local.name}@${version} from npm.`);
|
|
67
|
+
console.error('That is not the same answer as "it differs" — nothing was compared.');
|
|
68
|
+
console.error(String(err.stderr || err.message).trim().split('\n').slice(-3).join('\n'));
|
|
69
|
+
process.exit(2);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
execFileSync('tar', ['-xzf', path.join(tmp, tarball), '-C', tmp]);
|
|
73
|
+
const root = path.join(tmp, 'package');
|
|
74
|
+
|
|
75
|
+
/** Every file the package ships, relative to the package root. */
|
|
76
|
+
const shipped = [];
|
|
77
|
+
const walk = (dir) => {
|
|
78
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
79
|
+
const full = path.join(dir, e.name);
|
|
80
|
+
if (e.isDirectory()) walk(full);
|
|
81
|
+
else shipped.push(path.relative(root, full));
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
walk(root);
|
|
85
|
+
|
|
86
|
+
// Only compare what this repo is the source of. `package.json` is rewritten by
|
|
87
|
+
// npm on publish (it adds `_id`, `dist`, and normalises fields), so byte
|
|
88
|
+
// equality there is not the question and would be a permanent false alarm.
|
|
89
|
+
const comparable = shipped.filter((f) => /^(src|scripts|native)\//.test(f) && !f.includes('/.build/'));
|
|
90
|
+
|
|
91
|
+
const same = [];
|
|
92
|
+
const differ = [];
|
|
93
|
+
const missing = [];
|
|
94
|
+
for (const rel of comparable) {
|
|
95
|
+
const mine = path.join(here, rel);
|
|
96
|
+
if (!fs.existsSync(mine)) { missing.push(rel); continue; }
|
|
97
|
+
if (md5(fs.readFileSync(mine)) === md5(fs.readFileSync(path.join(root, rel)))) same.push(rel);
|
|
98
|
+
else differ.push(rel);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const publishedVersion = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
|
|
102
|
+
console.log(` published version: ${publishedVersion}`);
|
|
103
|
+
console.log(` files compared: ${comparable.length}`);
|
|
104
|
+
console.log(` identical: ${same.length}`);
|
|
105
|
+
|
|
106
|
+
if (missing.length) {
|
|
107
|
+
console.log(` shipped but absent here: ${missing.length}`);
|
|
108
|
+
for (const f of missing) console.log(` ${f}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!differ.length && !missing.length) {
|
|
112
|
+
console.log(`\nok — ${local.name}@${version} is the code in this tree`);
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.error(`\nFAIL ${differ.length} shipped file(s) differ from this tree:`);
|
|
117
|
+
for (const f of differ) console.error(` ${f}`);
|
|
118
|
+
console.error('\nIf you are about to ask someone to test a change, they would be testing');
|
|
119
|
+
console.error('the published bytes above, not what you are reading. Publish first, or point');
|
|
120
|
+
console.error('them at the checkout and say so.');
|
|
121
|
+
process.exit(1);
|
|
@@ -23,15 +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
|
-
];
|
|
35
28
|
|
|
36
29
|
const udid = process.argv[2];
|
|
37
30
|
const sep = process.argv.indexOf('--');
|
|
@@ -56,8 +49,6 @@ const summary = (line) => {
|
|
|
56
49
|
if (f) { try { fs.appendFileSync(f, `${line}\n`); } catch { /* summaries are a nicety */ } }
|
|
57
50
|
};
|
|
58
51
|
|
|
59
|
-
const deviceCause = (text) => DEVICE_STATE.find(([re]) => re.test(text))?.[1] ?? null;
|
|
60
|
-
|
|
61
52
|
const first = await run(cmd);
|
|
62
53
|
if (first.code === 0) process.exit(0);
|
|
63
54
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why does a fingerprint reading not resemble its own screen?
|
|
3
|
+
*
|
|
4
|
+
* Its own module, with no side effects, for one reason: this logic has failed
|
|
5
|
+
* at RUNTIME twice — once reaching for a variable local to another function,
|
|
6
|
+
* once on declaration order — while being correct both times. `eval-fingerprint.mjs`
|
|
7
|
+
* runs the whole eval on import, so nothing could test it there, and the only
|
|
8
|
+
* thing that ever exercised it was a hosted runner at the end of a
|
|
9
|
+
* fifteen-minute job, in the middle of a report. That is the most expensive
|
|
10
|
+
* place in this project to find a typo.
|
|
11
|
+
*
|
|
12
|
+
* Three causes, and they have different remedies — which is the whole reason to
|
|
13
|
+
* tell them apart rather than print one sentence about all three:
|
|
14
|
+
*
|
|
15
|
+
* - **collided** — neither reading carries a chrome label, so both are
|
|
16
|
+
* structure with no name and the fingerprint has nothing left to separate two
|
|
17
|
+
* list screens. That is this harness's own subject, and a real finding.
|
|
18
|
+
* - **wrongScreen** — the tokens are identical to another screen AND this
|
|
19
|
+
* screen reads differently in its other rounds, so it is demonstrably
|
|
20
|
+
* distinguishable and the tour was simply somewhere else. A tap that missed.
|
|
21
|
+
* - **underRead** — the reading stayed under the token floor and the screen
|
|
22
|
+
* cannot tell itself apart in any round, so we never looked long enough. Ours
|
|
23
|
+
* to fix, and nothing about the tour or the fingerprint.
|
|
24
|
+
*
|
|
25
|
+
* The middle case is the correction that prompted this. Sparseness alone used
|
|
26
|
+
* to claim `underRead`, and a wrong turn onto a screen that *legitimately*
|
|
27
|
+
* reads sparse — the Settings root, at 4 tokens on a runner — is flagged sparse
|
|
28
|
+
* too. So a genuine tour failure was reported as our instrument's fault, and
|
|
29
|
+
* the harness's original and correct message had been silenced by an
|
|
30
|
+
* "improvement".
|
|
31
|
+
*/
|
|
32
|
+
import * as fingerprint from '../src/fingerprint.js';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} o
|
|
36
|
+
* @param {object} o.reading the stray
|
|
37
|
+
* @param {object|null} o.match the other screen's reading it most resembles
|
|
38
|
+
* @param {number} o.bestOther how much it resembles that one, 0..1
|
|
39
|
+
* @param {object[]} o.siblings every reading of the stray's own screen
|
|
40
|
+
* @param {boolean} o.wasSparse did it stay under the token floor after retries
|
|
41
|
+
* @param {string[]} [o.named] chrome-labelled tokens in the stray
|
|
42
|
+
* @param {string[]} [o.matchNamed] chrome-labelled tokens in the match
|
|
43
|
+
*/
|
|
44
|
+
export function classifyStray({
|
|
45
|
+
reading, match, bestOther, siblings, wasSparse, named = [], matchNamed = [],
|
|
46
|
+
}) {
|
|
47
|
+
// Does any other round of this same screen read differently from the screen
|
|
48
|
+
// we collided with? If so this screen CAN be told apart, and a round that
|
|
49
|
+
// matched the other one exactly was somewhere else.
|
|
50
|
+
const distinguishable = (siblings ?? [])
|
|
51
|
+
.some((o) => o !== reading && fingerprint.similarity(o.tokens, match?.tokens ?? []) < 0.99);
|
|
52
|
+
const identical = bestOther >= 0.99;
|
|
53
|
+
// Checked first and exclusively: a reading with no names at all cannot be
|
|
54
|
+
// said to have gone anywhere, because there is nothing in it that would have
|
|
55
|
+
// named a destination.
|
|
56
|
+
const collided = identical && named.length === 0 && matchNamed.length === 0;
|
|
57
|
+
return {
|
|
58
|
+
collided,
|
|
59
|
+
wrongScreen: !collided && identical && distinguishable,
|
|
60
|
+
underRead: !collided && Boolean(wasSparse) && !distinguishable,
|
|
61
|
+
distinguishable,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -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
|
+
}
|