simframe 0.4.2 → 0.6.0-rc.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 +334 -85
- package/native/simframed/Package.swift +16 -0
- package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +523 -0
- package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
- package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +149 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +112 -0
- package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
- package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
- package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
- package/native/simframed/Sources/SimframeCore/Element.swift +148 -0
- package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
- package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
- package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
- package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
- package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
- package/native/simframed/Sources/simframed/main.swift +485 -0
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +270 -0
- package/package.json +12 -4
- package/scripts/bench-flow.mjs +54 -0
- package/scripts/bench.sh +98 -0
- package/scripts/check-package.mjs +99 -0
- package/scripts/ci-memory.mjs +416 -0
- package/scripts/eval-fingerprint.mjs +192 -0
- package/scripts/smoke.mjs +76 -0
- package/scripts/sync-server-version.mjs +39 -0
- package/scripts/verify-baseline.mjs +65 -0
- package/skills/simframe/SKILL.md +173 -0
- package/src/actions.js +264 -18
- package/src/cli.js +561 -89
- package/src/control.js +77 -0
- package/src/daemon.js +8 -1
- package/src/engine.js +99 -0
- package/src/fingerprint.js +183 -0
- package/src/graph.js +411 -0
- package/src/index.js +351 -24
- package/src/input.js +179 -2
- package/src/matching.js +265 -0
- package/src/mcp.js +425 -112
- package/src/navigate.js +120 -0
- package/src/refs.js +141 -0
- package/src/regions.js +267 -0
- package/src/screenmap.js +119 -22
- package/src/simctl.js +74 -5
- package/src/store.js +8 -0
- package/src/view.js +342 -0
package/src/graph.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// What happens when you do something here.
|
|
2
|
+
//
|
|
3
|
+
// Keyed by the same layout hash as screen memory, so a screen the map already
|
|
4
|
+
// recognises is a screen the graph already knows. Edges are observations, never
|
|
5
|
+
// predictions: an edge exists because an action was taken and the result was
|
|
6
|
+
// seen, and a screen with no edges is a screen we have nothing to say about.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { hashDistance } from './analyze.js';
|
|
10
|
+
import * as fingerprint from './fingerprint.js';
|
|
11
|
+
import * as matching from './matching.js';
|
|
12
|
+
import * as store from './store.js';
|
|
13
|
+
|
|
14
|
+
const GRAPH_VERSION = 2;
|
|
15
|
+
/**
|
|
16
|
+
* Screens are matched by structural hash, exactly, and then by how alike their
|
|
17
|
+
* token sets are — which tolerates one optional element appearing (a badge, a
|
|
18
|
+
* banner) without tolerating a different screen.
|
|
19
|
+
*
|
|
20
|
+
* The pixel layout hash is not used for identity here. Measured, a same-screen
|
|
21
|
+
* revisit with changed content reached 62 bits against a different-screen floor
|
|
22
|
+
* of 74; no threshold separates those. See docs/BENCHMARKS.md, Phase 6.
|
|
23
|
+
*
|
|
24
|
+
* Structurally the two distributions do separate, but not by much: measured
|
|
25
|
+
* with the screen map forced cold, revisits score 0.41 to 1.00 against a
|
|
26
|
+
* different-screen ceiling of 0.31. 0.36 is the middle of that gap.
|
|
27
|
+
*
|
|
28
|
+
* The gap is narrow because of one screen, and a settle gate did not fix it
|
|
29
|
+
* (docs/BENCHMARKS.md, Phase 6c): that screen loads its sections from different
|
|
30
|
+
* sources and genuinely has more than one settled structure. Two structures of
|
|
31
|
+
* one screen are as far apart as two different screens, so no threshold can
|
|
32
|
+
* express the difference — which is why a screen may hold several accepted
|
|
33
|
+
* fingerprints instead. See `variants` below.
|
|
34
|
+
*
|
|
35
|
+
* This still errs toward recording a duplicate screen, which costs a
|
|
36
|
+
* re-derivation, over merging two, which costs a tap on the wrong element.
|
|
37
|
+
*
|
|
38
|
+
* Most revisits match on a hash outright and never reach this at all.
|
|
39
|
+
*/
|
|
40
|
+
export const SIMILARITY_THRESHOLD = 0.36;
|
|
41
|
+
/**
|
|
42
|
+
* A screen with three async sections has a few settled structures, not endless
|
|
43
|
+
* ones. Capping this keeps a genuinely wrong merge bounded: if a node starts
|
|
44
|
+
* collecting variants without limit, that is a signal the action is
|
|
45
|
+
* non-deterministic, not that the screen has many faces.
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_VARIANTS = 4;
|
|
48
|
+
|
|
49
|
+
/** Only for the legacy pixel path, kept so old graphs still load. */
|
|
50
|
+
export const TOLERANCE = 20;
|
|
51
|
+
|
|
52
|
+
function graphDir(udid) {
|
|
53
|
+
return path.join(store.deviceDir(udid), 'graph');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A stable name for an action, so the same step matches its own history. */
|
|
57
|
+
export function actionSignature(step) {
|
|
58
|
+
if (!step || typeof step !== 'object') return String(step ?? '');
|
|
59
|
+
// Normalized steps carry `{action, value}`, not `{tap: "..."}`, and every
|
|
60
|
+
// step reaching the graph has been normalized. Without this the shorthand
|
|
61
|
+
// branches below never matched and everything fell to the generic tail, so a
|
|
62
|
+
// tap on "Contacts" and a type of "Contacts" produced the SAME signature —
|
|
63
|
+
// two different actions sharing one edge — and a stray `index: undefined`
|
|
64
|
+
// key made the tail throw outright.
|
|
65
|
+
if (step.action) {
|
|
66
|
+
// Bookkeeping is not part of what the action IS: the same tap with a longer
|
|
67
|
+
// timeout is the same edge.
|
|
68
|
+
const { action, timeoutMs, stableMs, autoSettle, ...rest } = step;
|
|
69
|
+
const value = rest.value ?? rest.target ?? rest.label;
|
|
70
|
+
if (value != null && typeof value !== 'object') return `${action}:${String(value).toLowerCase()}`;
|
|
71
|
+
// Shapes like tapAt and swipe are spread inline, so they have no `value` —
|
|
72
|
+
// their coordinates ARE their identity and must stay in the signature, or
|
|
73
|
+
// two taps at different points share one edge.
|
|
74
|
+
const keys = Object.keys(rest).filter((k) => rest[k] !== undefined).sort();
|
|
75
|
+
if (!keys.length) return String(action);
|
|
76
|
+
return `${action}:${stableValue(Object.fromEntries(keys.map((k) => [k, rest[k]])))}`;
|
|
77
|
+
}
|
|
78
|
+
if (step.tap != null) return `tap:${String(step.tap).toLowerCase()}`;
|
|
79
|
+
if (step.tapAt) return `tapAt:${Math.round(step.tapAt.x)},${Math.round(step.tapAt.y)}`;
|
|
80
|
+
if (step.swipe) {
|
|
81
|
+
const { from = [], to = [] } = step.swipe;
|
|
82
|
+
return `swipe:${from.join(',')}->${to.join(',')}`;
|
|
83
|
+
}
|
|
84
|
+
if (step.scroll) return `scroll:${step.scroll}`;
|
|
85
|
+
if (step.button) return `button:${step.button}`;
|
|
86
|
+
if (step.launch) return `launch:${step.launch}`;
|
|
87
|
+
if (step.openUrl) return `openUrl:${step.openUrl}`;
|
|
88
|
+
// Last resort. Skip keys whose value is undefined: JSON.stringify(undefined)
|
|
89
|
+
// is undefined, and calling .slice on it threw before any action was sent.
|
|
90
|
+
const key = Object.keys(step).find((k) => step[k] !== undefined);
|
|
91
|
+
if (!key) return '';
|
|
92
|
+
return `${key}:${stableValue(step[key])}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** JSON, but never undefined, and always short enough to use as a key. */
|
|
96
|
+
function stableValue(value) {
|
|
97
|
+
return String(JSON.stringify(value) ?? '').slice(0, 40);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Every fingerprint a node answers to: its canonical one, plus its variants. */
|
|
101
|
+
function fingerprintsOf(node) {
|
|
102
|
+
return [{ hash: node.hash, tokens: node.tokens ?? [] }, ...(node.variants ?? [])];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function load(udid, screen) {
|
|
106
|
+
const key = typeof screen === 'string' ? { hash: screen, tokens: [] } : screen;
|
|
107
|
+
const entry = store.readJson(path.join(graphDir(udid), `${key.hash}.json`));
|
|
108
|
+
if (entry?.version === GRAPH_VERSION) return entry;
|
|
109
|
+
// The hash may be a variant of a node filed under a different name.
|
|
110
|
+
const byVariant = allNodes(udid).find((n) => (n.variants ?? []).some((v) => v.hash === key.hash));
|
|
111
|
+
if (byVariant) return byVariant;
|
|
112
|
+
return { version: GRAPH_VERSION, hash: key.hash, tokens: key.tokens ?? [], variants: [], edges: [] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function save(udid, node) {
|
|
116
|
+
const dir = graphDir(udid);
|
|
117
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
118
|
+
store.writeAtomic(path.join(dir, `${node.hash}.json`), JSON.stringify(node));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function allNodes(udid) {
|
|
122
|
+
try {
|
|
123
|
+
return fs
|
|
124
|
+
.readdirSync(graphDir(udid))
|
|
125
|
+
.filter((f) => f.endsWith('.json'))
|
|
126
|
+
.map((f) => store.readJson(path.join(graphDir(udid), f)))
|
|
127
|
+
.filter((n) => n?.version === GRAPH_VERSION);
|
|
128
|
+
} catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The stored screen closest to `hash`, by layout rather than content. */
|
|
134
|
+
/**
|
|
135
|
+
* The stored screen matching this one.
|
|
136
|
+
*
|
|
137
|
+
* `screen` is `{ hash, tokens }` from the structural fingerprint. An exact hash
|
|
138
|
+
* match is the common case; the token comparison catches the screen that gained
|
|
139
|
+
* a badge since last time.
|
|
140
|
+
*/
|
|
141
|
+
export function nearestScreen(udid, screen, { threshold = SIMILARITY_THRESHOLD } = {}) {
|
|
142
|
+
const key = typeof screen === 'string' ? { hash: screen, tokens: null } : screen;
|
|
143
|
+
if (!key?.hash) return null;
|
|
144
|
+
const nodes = allNodes(udid);
|
|
145
|
+
// Any of a node's accepted fingerprints matching exactly is still an exact
|
|
146
|
+
// match: a screen with two settled structures is one screen.
|
|
147
|
+
const exact = nodes.find((n) => fingerprintsOf(n).some((f) => f.hash === key.hash));
|
|
148
|
+
if (exact) return { node: exact, similarity: 1 };
|
|
149
|
+
if (!key.tokens?.length) return null;
|
|
150
|
+
let best = null;
|
|
151
|
+
let bestSimilarity = 0;
|
|
152
|
+
for (const node of nodes) {
|
|
153
|
+
for (const f of fingerprintsOf(node)) {
|
|
154
|
+
if (!f.tokens?.length) continue;
|
|
155
|
+
const s = fingerprint.similarity(f.tokens, key.tokens);
|
|
156
|
+
if (s > bestSimilarity) {
|
|
157
|
+
bestSimilarity = s;
|
|
158
|
+
best = node;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return best && bestSimilarity >= threshold ? { node: best, similarity: bestSimilarity } : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Teach a node that it also looks like this.
|
|
167
|
+
*
|
|
168
|
+
* Called only when a known edge has landed somewhere its target does not
|
|
169
|
+
* recognise — the edge is the evidence. A screen whose sections arrive from
|
|
170
|
+
* different sources has several genuine settled structures, and this is how the
|
|
171
|
+
* second one stops being a screen of its own.
|
|
172
|
+
*/
|
|
173
|
+
function addVariant(node, reading) {
|
|
174
|
+
node.variants ??= [];
|
|
175
|
+
const existing = node.variants.find((v) => v.hash === reading.hash);
|
|
176
|
+
if (existing) {
|
|
177
|
+
existing.count += 1;
|
|
178
|
+
existing.lastSeen = Date.now();
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
if (node.variants.length >= MAX_VARIANTS) return false;
|
|
182
|
+
node.variants.push({ hash: reading.hash, tokens: reading.tokens ?? [], count: 1, lastSeen: Date.now() });
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Only actions worth replaying — a launch or a URL open is a flow's start, not a step within it. */
|
|
187
|
+
function replayable(step) {
|
|
188
|
+
if (!step || typeof step !== 'object') return null;
|
|
189
|
+
return step.launch != null || step.openUrl != null ? null : step;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* What to call this screen, for a human typing `goto`.
|
|
194
|
+
*
|
|
195
|
+
* Chrome labels are the only text in a fingerprint, which makes them the only
|
|
196
|
+
* thing available to name it by — and they are the right thing anyway: a screen
|
|
197
|
+
* is called what its nav bar says it is.
|
|
198
|
+
*/
|
|
199
|
+
export function describe(node) {
|
|
200
|
+
const labels = (pattern) => (node.tokens ?? [])
|
|
201
|
+
.filter((t) => pattern.test(t) && t.includes('"'))
|
|
202
|
+
.map((t) => t.slice(t.indexOf('"') + 1, t.lastIndexOf('"')))
|
|
203
|
+
.filter(Boolean);
|
|
204
|
+
// The nav title first, because that is what the screen is called. A button
|
|
205
|
+
// that happens to sit in the nav bar is not a name for anything.
|
|
206
|
+
const title = labels(/:nav-bar:@title:/);
|
|
207
|
+
if (title.length) return title.join(' ');
|
|
208
|
+
// Three at most. A screen named after seven tab-bar fragments — several of
|
|
209
|
+
// them OCR reading a divider — is not a name anybody can type into `goto`.
|
|
210
|
+
const tabs = labels(/:tab-bar:/);
|
|
211
|
+
if (tabs.length) return tabs.slice(0, 3).join(' / ');
|
|
212
|
+
const anyChrome = labels(/:(nav-bar|tab-bar):/);
|
|
213
|
+
if (anyChrome.length) return anyChrome.slice(0, 3).join(' ');
|
|
214
|
+
return node.hash.slice(0, 8);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Find a known screen by what a human would call it. */
|
|
218
|
+
export function findScreen(udid, query) {
|
|
219
|
+
const wanted = String(query ?? '').trim();
|
|
220
|
+
if (!wanted) return null;
|
|
221
|
+
const scored = allNodes(udid)
|
|
222
|
+
.map((node) => ({ node, name: describe(node) }))
|
|
223
|
+
.map((c) => ({ ...c, score: matching.nameScore(c.name, wanted) }))
|
|
224
|
+
.filter((c) => c.score > 0)
|
|
225
|
+
.sort((a, b) => b.score - a.score);
|
|
226
|
+
const [best, next] = scored;
|
|
227
|
+
if (!best) return null;
|
|
228
|
+
// Two screens that fit the query equally well is a question for the caller,
|
|
229
|
+
// not a coin flip that navigates somewhere wrong.
|
|
230
|
+
if (next && best.score - next.score < 0.08) {
|
|
231
|
+
return { ambiguous: [best, next].map((c) => ({ name: c.name, hash: c.node.hash })) };
|
|
232
|
+
}
|
|
233
|
+
return { node: best.node, name: best.name, score: best.score };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Remember that doing `action` on `from` led to `to`. */
|
|
237
|
+
export function record(udid, { from, action, to, kind }) {
|
|
238
|
+
const fromKey = typeof from === 'string' ? { hash: from } : from;
|
|
239
|
+
const toHash = typeof to === 'string' ? to : to?.hash;
|
|
240
|
+
if (!fromKey?.hash || !toHash) return null;
|
|
241
|
+
const node = nearestScreen(udid, fromKey)?.node ?? load(udid, fromKey);
|
|
242
|
+
// Never overwrite the canonical fingerprint with the one we happened to
|
|
243
|
+
// arrive as — that is what variants are for, and rewriting it here would let
|
|
244
|
+
// a node drift screen by screen into something it never was.
|
|
245
|
+
if (!node.tokens?.length && fromKey.tokens?.length) node.tokens = fromKey.tokens;
|
|
246
|
+
const to_ = toHash;
|
|
247
|
+
const signature = actionSignature(action);
|
|
248
|
+
const existing = node.edges.find((e) => e.action === signature);
|
|
249
|
+
if (existing) {
|
|
250
|
+
// A different outcome from the same action is worth knowing about: it is
|
|
251
|
+
// how a screen that looks the same but behaves differently shows up.
|
|
252
|
+
if (existing.to !== to_) {
|
|
253
|
+
// A known edge has landed somewhere its target does not recognise. Either
|
|
254
|
+
// the action is genuinely non-deterministic, or this is the same screen
|
|
255
|
+
// wearing a different structure — and the edge is the only evidence that
|
|
256
|
+
// can tell them apart. If some *other* stored screen claims this reading,
|
|
257
|
+
// believe it: that is a real change of destination. If nothing claims it,
|
|
258
|
+
// the screen at the end of this edge has grown a second face.
|
|
259
|
+
const reading = typeof to === 'string' ? { hash: to, tokens: [] } : to;
|
|
260
|
+
const claimant = nearestScreen(udid, reading)?.node;
|
|
261
|
+
const target = load(udid, existing.to);
|
|
262
|
+
const unclaimed = !claimant || claimant.hash === target.hash;
|
|
263
|
+
if (unclaimed && target.hash !== to_ && reading.tokens?.length) {
|
|
264
|
+
addVariant(target, reading);
|
|
265
|
+
save(udid, target);
|
|
266
|
+
existing.count += 1;
|
|
267
|
+
existing.lastSeen = Date.now();
|
|
268
|
+
save(udid, node);
|
|
269
|
+
return node;
|
|
270
|
+
}
|
|
271
|
+
existing.previousTo = existing.to;
|
|
272
|
+
existing.changedOutcomes = (existing.changedOutcomes ?? 0) + 1;
|
|
273
|
+
}
|
|
274
|
+
existing.to = to_;
|
|
275
|
+
existing.step = replayable(action) ?? existing.step;
|
|
276
|
+
existing.kind = kind ?? existing.kind;
|
|
277
|
+
existing.count += 1;
|
|
278
|
+
existing.lastSeen = Date.now();
|
|
279
|
+
} else {
|
|
280
|
+
node.edges.push({
|
|
281
|
+
action: signature,
|
|
282
|
+
// The signature is lossy — it lowercases labels and truncates. Routing
|
|
283
|
+
// has to replay the action exactly, so keep the step that produced it.
|
|
284
|
+
step: replayable(action),
|
|
285
|
+
to: to_,
|
|
286
|
+
kind,
|
|
287
|
+
count: 1,
|
|
288
|
+
lastSeen: Date.now(),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
save(udid, node);
|
|
292
|
+
return node;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** What this action did last time, if we have ever seen it here. */
|
|
296
|
+
export function predict(udid, from, action) {
|
|
297
|
+
const found = nearestScreen(udid, from);
|
|
298
|
+
if (!found) return null;
|
|
299
|
+
const signature = actionSignature(action);
|
|
300
|
+
const edge = found.node.edges.find((e) => e.action === signature);
|
|
301
|
+
return edge ? { ...edge, fromDistance: found.distance } : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function stats(udid) {
|
|
305
|
+
const nodes = allNodes(udid);
|
|
306
|
+
return { screens: nodes.length, edges: nodes.reduce((n, s) => n + s.edges.length, 0) };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function forget(udid) {
|
|
310
|
+
try {
|
|
311
|
+
fs.rmSync(graphDir(udid), { recursive: true, force: true });
|
|
312
|
+
} catch {
|
|
313
|
+
/* nothing to forget */
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* A route of actions from one screen to another through edges we have taken.
|
|
319
|
+
*
|
|
320
|
+
* Breadth-first over observed edges only. An unknown screen has no path — the
|
|
321
|
+
* graph never guesses, because a guessed route taps real controls.
|
|
322
|
+
*/
|
|
323
|
+
export function route(udid, fromHash, toHash, { maxDepth = 8 } = {}) {
|
|
324
|
+
const start = nearestScreen(udid, fromHash);
|
|
325
|
+
if (!start) return null;
|
|
326
|
+
const goal = (h) => h === toHash;
|
|
327
|
+
if (goal(start.node.hash)) return [];
|
|
328
|
+
|
|
329
|
+
const byHash = new Map(allNodes(udid).map((n) => [n.hash, n]));
|
|
330
|
+
const seen = new Set([start.node.hash]);
|
|
331
|
+
const queue = [{ hash: start.node.hash, path: [] }];
|
|
332
|
+
while (queue.length) {
|
|
333
|
+
const { hash, path: taken } = queue.shift();
|
|
334
|
+
if (taken.length >= maxDepth) continue;
|
|
335
|
+
const node = byHash.get(hash);
|
|
336
|
+
for (const edge of node?.edges ?? []) {
|
|
337
|
+
if (seen.has(edge.to)) continue;
|
|
338
|
+
const next = [...taken, edge];
|
|
339
|
+
if (goal(edge.to)) return next;
|
|
340
|
+
seen.add(edge.to);
|
|
341
|
+
queue.push({ hash: edge.to, path: next });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Verdicts a verified step can produce. */
|
|
348
|
+
// `unexpected-transition` was removed: see verdict(). The transition kind is
|
|
349
|
+
// reported inside an `ok` verdict now, because the classifier is not reliable
|
|
350
|
+
// enough for a correct navigation to be called wrong by it.
|
|
351
|
+
export const VERDICTS = ['ok', 'no-visible-change', 'unexpected-screen', 'unverified'];
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Compare what happened against what was expected.
|
|
355
|
+
*
|
|
356
|
+
* With no prediction the outcome is `unverified` rather than `ok`: not knowing
|
|
357
|
+
* what should have happened is not evidence that the right thing did.
|
|
358
|
+
*/
|
|
359
|
+
/**
|
|
360
|
+
* Do these two fingerprints mean the same screen?
|
|
361
|
+
*
|
|
362
|
+
* String equality was right when a screen had exactly one fingerprint. Now that
|
|
363
|
+
* a node can answer to several — a list with an alert over it is the same
|
|
364
|
+
* screen — comparing hashes directly reports a wrong turn every time the
|
|
365
|
+
* variant is the one on screen. The variant mechanism fired correctly on a real
|
|
366
|
+
* app and the verdict still said `unexpected-screen`, because the verdict never
|
|
367
|
+
* asked the graph.
|
|
368
|
+
*/
|
|
369
|
+
function sameScreen(udid, a, b) {
|
|
370
|
+
if (!a || !b) return false;
|
|
371
|
+
if (a === b) return true;
|
|
372
|
+
if (!udid) return false;
|
|
373
|
+
const nodeA = nearestScreen(udid, a)?.node;
|
|
374
|
+
const nodeB = nearestScreen(udid, b)?.node;
|
|
375
|
+
return Boolean(nodeA && nodeB && nodeA.hash === nodeB.hash);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function verdict({ udid, prediction, before, after, kind }) {
|
|
379
|
+
if (!before || !after) return { verdict: 'unverified', detail: 'no state to compare' };
|
|
380
|
+
const moved = before !== after;
|
|
381
|
+
if (!prediction) {
|
|
382
|
+
if (!moved) return { verdict: 'no-visible-change', detail: 'the screen did not change, and nothing predicted it would' };
|
|
383
|
+
return { verdict: 'unverified', detail: 'this action has not been seen on this screen before' };
|
|
384
|
+
}
|
|
385
|
+
const expectedMove = !sameScreen(udid, prediction.to, before);
|
|
386
|
+
if (!moved && expectedMove) {
|
|
387
|
+
return { verdict: 'no-visible-change', detail: `expected to reach a different screen (seen ${prediction.count}x)` };
|
|
388
|
+
}
|
|
389
|
+
if (!sameScreen(udid, prediction.to, after)) {
|
|
390
|
+
return {
|
|
391
|
+
verdict: 'unexpected-screen',
|
|
392
|
+
detail: `expected the screen this action reached ${prediction.count}x before, and landed somewhere else`,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
// The screen is where it was predicted to be. That is the reliable signal and
|
|
396
|
+
// it is what the verdict rests on.
|
|
397
|
+
//
|
|
398
|
+
// The transition *kind* is not reliable: Phase 4's classifier calls the same
|
|
399
|
+
// tab switch `replace` on one run and `pop` on the next, and measured against
|
|
400
|
+
// a real app it was the only thing producing non-ok verdicts on navigation
|
|
401
|
+
// that had gone exactly where predicted. A verdict that says something is
|
|
402
|
+
// wrong when nothing is wrong trains you to ignore verdicts, so a kind
|
|
403
|
+
// mismatch is reported alongside `ok` rather than overriding it.
|
|
404
|
+
const kindDiffers = Boolean(prediction.kind && kind && prediction.kind !== kind && kind !== 'none');
|
|
405
|
+
return {
|
|
406
|
+
verdict: 'ok',
|
|
407
|
+
detail: `matches the outcome seen ${prediction.count}x before`
|
|
408
|
+
+ (kindDiffers ? ` (transition looked like ${kind}, not ${prediction.kind} — the classifier is noisy)` : ''),
|
|
409
|
+
...(kindDiffers ? { kindDiffers: { predicted: prediction.kind, observed: kind } } : {}),
|
|
410
|
+
};
|
|
411
|
+
}
|