simframe 0.12.1 → 0.13.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/src/actions.js CHANGED
@@ -260,6 +260,8 @@ export async function runScript(
260
260
  expect,
261
261
  failure,
262
262
  outcome,
263
+ supervisor: supervisor.requested(options),
264
+ situation: ruling?.context?.situation ?? null,
263
265
  });
264
266
  } catch {
265
267
  /* instrumentation must not be able to fail a flow it is only watching */
@@ -354,12 +356,19 @@ export async function runScript(
354
356
  }),
355
357
  observedMs: null,
356
358
  };
359
+ // Where the hands actually went, filled in by the step that moved them.
360
+ //
361
+ // Item 120 needs the *resolved* point, not the one in the script: an
362
+ // image-space `tapAt` is converted inside the step, and `scroll` invents
363
+ // its coordinates from the screen size. Reading them back off the step
364
+ // would diagnose a point nothing was ever aimed at.
365
+ const aim = { at: null };
357
366
  let detail;
358
367
  // A selector that did not resolve gets the step's own alternatives before
359
368
  // the batch is abandoned. Anything else propagates: retrying from a screen
360
369
  // we did not expect to be on is not a retry, it is a second guess.
361
370
  try {
362
- detail = await runStep(deviceQuery, udid, step, { screen, options, frames, focus });
371
+ detail = await runStep(deviceQuery, udid, step, { screen, options, frames, focus, aim });
363
372
  } catch (thrown) {
364
373
  let err = thrown;
365
374
  // Ask the supervisor before anything is abandoned. It sits behind the
@@ -393,7 +402,7 @@ export async function runScript(
393
402
  options,
394
403
  }).catch(() => null);
395
404
  try {
396
- detail = await runStep(deviceQuery, udid, step, { screen, options, frames, focus });
405
+ detail = await runStep(deviceQuery, udid, step, { screen, options, frames, focus, aim });
397
406
  detail += ` [the local supervisor said ${ruling.decision}; it worked on the second attempt]`;
398
407
  ruled('recovered');
399
408
  continue;
@@ -433,7 +442,7 @@ export async function runScript(
433
442
  let last = err;
434
443
  for (const label of allowed) {
435
444
  try {
436
- detail = await runStep(deviceQuery, udid, stepWithTarget(step, label), { screen, options, frames, focus });
445
+ detail = await runStep(deviceQuery, udid, stepWithTarget(step, label), { screen, options, frames, focus, aim });
437
446
  detail += ` [after ${tried.map((t) => JSON.stringify(String(t))).join(', ')} did not resolve]`;
438
447
  last = null;
439
448
  break;
@@ -729,10 +738,40 @@ export async function runScript(
729
738
  ? ' [the screen did not change, so this app was already in front — or it did not come forward]'
730
739
  : '';
731
740
  const filling = stillFillingIn(afterReading?.entry);
741
+ // Item 120: when a gesture aimed at a coordinate does nothing, say what
742
+ // that coordinate resolved to. The element that swallowed it is normally
743
+ // already in the list printed under this very verdict — the gap was
744
+ // never the data, it was that nobody connected "started at y=750" to
745
+ // "there is a banner at y=753".
746
+ //
747
+ // Diagnosed against the screen as it was *before* the action, because
748
+ // that is the screen the finger landed on. Using the after-reading would
749
+ // describe the world the gesture failed to change.
750
+ // Both signals, because they are not the same one and only one of them
751
+ // fires in the reported case. `settled.noVisibleChange` is the pixel
752
+ // detector saying the frame never moved; the `no-visible-change` verdict
753
+ // is the fingerprint saying we are on the screen we started on. A swipe
754
+ // into a search bar settled in 62ms and produced the second without the
755
+ // first — and six swipes reporting "no visible change" is what item 120
756
+ // was reported against. Keying on one of them would have shipped a fix
757
+ // that did not fire on its own bug report.
758
+ const wentNowhere = Boolean(settled?.noVisibleChange) || verification?.verdict === 'no-visible-change';
759
+ const aimNote = wentNowhere && aim.at
760
+ ? screenmap.describePoint(
761
+ // `beforeScreen` is null with verification off, and that is exactly
762
+ // the mode someone falls back to when coordinates are misbehaving.
763
+ // A stored map keyed on the pre-action frame costs one file read.
764
+ beforeScreen?.entry ?? screenmap.recall(udid, before),
765
+ aim.at.point,
766
+ { what: aim.at.what },
767
+ )
768
+ : null;
732
769
  const note = launchNote
733
770
  + (filling ? ` [settled, but ${filling} — waitFor content, do not act on this yet]` : '')
734
771
  + (settled?.smallChange ? ' [a small change, in one region only]' : '')
735
772
  + (settled?.noVisibleChange ? ' [no visible change]' : '')
773
+ // After the symptom, because it is the explanation of it.
774
+ + (aimNote ? ` [${aimNote}]` : '')
736
775
  + (settled?.staleBaseline ? ' [baseline had already settled; re-taken from the live screen]' : '')
737
776
  + (settled?.blackFrames
738
777
  ? ` [${settled.blackFrames} black frame(s) waited through${settled.blackMs ? `, still black after ${settled.blackMs}ms` : ''}]`
@@ -780,6 +819,22 @@ export async function runScript(
780
819
  fingerprint: beforeScreen?.hash ?? null,
781
820
  reason: 'verification_failed',
782
821
  candidates: [],
822
+ // Assumed, not read. A verdict says the step did not do what was
823
+ // expected; it does not say which faculty would have prevented that.
824
+ //
825
+ // Measured in the field and it matters: on an app whose controls are
826
+ // largely unlabeled, `no-visible-change` came overwhelmingly from
827
+ // tapping an inert text label whose real hit target was an invisible
828
+ // chevron — icon semantics, Phase 15 — while this line filed every
829
+ // one as evidence about sense of time, Phase 11. The tester reached
830
+ // the right conclusion from their own notes and our instrument
831
+ // disagreed with them. It was wrong.
832
+ //
833
+ // Not reclassified here, because `no-visible-change` also covers a
834
+ // switch moving 0.1% of the screen, which is neither faculty. Saying
835
+ // "assumed" is the honest answer; guessing a better-sounding reason
836
+ // would be the same mistake in the other direction.
837
+ classified: false,
783
838
  // `verification_failed` is the largest reason class in the log and it
784
839
  // was the only one carrying no intent, which made most of the corpus
785
840
  // useless for asking what kind of decision costs us. The step knows
@@ -815,6 +870,7 @@ export async function runScript(
815
870
  // Carried from the throw site where it exists, and otherwise the step's
816
871
  // own target — which is what was asked for either way.
817
872
  intent: why.intent ?? goalOf(step),
873
+ classified: why.classified,
818
874
  outcome: 'failed',
819
875
  wallMs: Date.now() - stepStart,
820
876
  detail: err.message,
@@ -1215,17 +1271,25 @@ async function superviseFailure(deviceQuery, { goal, step, expected, err, option
1215
1271
  try {
1216
1272
  timing = udid && map.identity ? graph.timingFor(udid, map.identity, step) : null;
1217
1273
  } catch { /* an unknown edge is a fact about the graph, not a failure here */ }
1218
- const ruling = await supervisor.judge({
1219
- goal,
1274
+ // The question, kept — not just the answer.
1275
+ //
1276
+ // Three items (101, 96, 106) want to ask "what would a different judge have
1277
+ // said about these same situations", and until now the log held the verdict
1278
+ // and threw away what was asked. So comparing two supervisors meant driving
1279
+ // the device once per arm, which is thirty minutes an arm and introduces
1280
+ // the device's own variance into a comparison that is supposed to be about
1281
+ // the judges. With this, one device pass produces a population every arm
1282
+ // can be asked, on identical inputs.
1283
+ const situation = {
1284
+ goal: goal ?? null,
1220
1285
  step: `${step.action} ${JSON.stringify(String(step.value ?? step.target ?? step.into ?? step.seek ?? '').slice(0, 60))}`,
1221
- expected,
1286
+ expected: expected ?? null,
1222
1287
  failure: err.message,
1223
1288
  screen: (map.rows ?? []).filter((r) => r.label).map((r) => r.label),
1224
1289
  stillMs,
1225
1290
  note: stillFillingIn(map.identity?.entry),
1226
- options,
1227
- detail,
1228
- });
1291
+ };
1292
+ const ruling = await supervisor.judge({ ...situation, options, detail });
1229
1293
  if (!ruling) return null;
1230
1294
  return {
1231
1295
  ...ruling,
@@ -1237,6 +1301,7 @@ async function superviseFailure(deviceQuery, { goal, step, expected, err, option
1237
1301
  stillMs,
1238
1302
  p95: timing?.p95 ?? null,
1239
1303
  samples: timing?.samples ?? null,
1304
+ situation,
1240
1305
  },
1241
1306
  };
1242
1307
  } catch {
@@ -2166,6 +2231,7 @@ async function runStep(deviceQuery, udid, step, ctx) {
2166
2231
  pointHeight: geo.pointHeight,
2167
2232
  }));
2168
2233
  }
2234
+ if (ctx.aim) ctx.aim.at = { point: { x, y }, what: 'the tap point' };
2169
2235
  await input.tapPoint(udid, x, y, { durationMs: step.durationMs });
2170
2236
  return `tapped ${x},${y}`;
2171
2237
  }
@@ -2253,6 +2319,9 @@ async function runStep(deviceQuery, udid, step, ctx) {
2253
2319
  case 'swipe': {
2254
2320
  const from = { x: step.from?.[0] ?? step.from?.x, y: step.from?.[1] ?? step.from?.y };
2255
2321
  const to = { x: step.to?.[0] ?? step.to?.x, y: step.to?.[1] ?? step.to?.y };
2322
+ // The start point only. A swipe is captured by whatever the finger goes
2323
+ // down on; where it lifts never decides who received it.
2324
+ if (ctx.aim) ctx.aim.at = { point: from, what: 'the swipe start point' };
2256
2325
  await input.swipe(udid, from, to, { durationMs: step.durationMs });
2257
2326
  return `swiped ${from.x},${from.y} -> ${to.x},${to.y}`;
2258
2327
  }
@@ -2269,6 +2338,7 @@ async function runStep(deviceQuery, udid, step, ctx) {
2269
2338
  right: [{ x: midX - span, y: midY }, { x: midX + span, y: midY }],
2270
2339
  };
2271
2340
  if (!moves[dir]) throw new Error(`unknown scroll direction "${dir}"`);
2341
+ if (ctx.aim) ctx.aim.at = { point: moves[dir][0], what: 'the scroll start point' };
2272
2342
  await input.swipe(udid, moves[dir][0], moves[dir][1], { durationMs: step.durationMs ?? 250 });
2273
2343
  return `scrolled ${dir}`;
2274
2344
  }
@@ -2316,8 +2386,26 @@ async function runStep(deviceQuery, udid, step, ctx) {
2316
2386
  timeoutMs: step.timeoutMs ?? 8000,
2317
2387
  options: ctx.options,
2318
2388
  });
2319
- if (!w.satisfied) throw new Error(w.stalled ? w.live.note : `screen did not settle within ${w.waitedMs}ms`);
2320
- return `settled after ${w.waitedMs}ms`;
2389
+ // Say where it is still moving — item 123.
2390
+ //
2391
+ // "did not settle within 25000ms" is true and unactionable, and a failed
2392
+ // step aborts the rest of the batch, so a spinner nobody cares about can
2393
+ // cost five steps that would have worked. The reporter who asked for this
2394
+ // had landed on `pause` plus `continueOnError` and called it *"strictly
2395
+ // worse than a settle that knows what to ignore"*. Knowing what to ignore
2396
+ // is the expensive half and is not built. Saying where is nearly free —
2397
+ // the signatures were already read to detect small changes — and it is
2398
+ // the difference between re-planning a flow and ignoring a corner of it.
2399
+ if (!w.satisfied) {
2400
+ if (w.stalled) throw new Error(w.live.note);
2401
+ throw new Error(`screen did not settle within ${w.waitedMs}ms — ${settleEvidence(w)}`);
2402
+ }
2403
+ // A settle that satisfied while part of the screen is still moving says
2404
+ // so. The daemon has boxed the moving part all along and nothing read it;
2405
+ // measured at 79s of claimed stillness on a screen animating at 85ms a
2406
+ // frame. Not treated as a failure — see the note on `animatingNow`.
2407
+ return `settled after ${w.waitedMs}ms`
2408
+ + (w.animating ? ` (a ${w.animating.width}x${w.animating.height} region is still animating)` : '');
2321
2409
  }
2322
2410
  case 'waitText': {
2323
2411
  const target = step.value ?? step.text;
@@ -2332,10 +2420,12 @@ async function runStep(deviceQuery, udid, step, ctx) {
2332
2420
  // Same rule as `waitFor`, and `matchElement` says it in its own
2333
2421
  // words: a query that matched several elements has found them all
2334
2422
  // already.
2335
- if (/matched \d+ elements/.test(err.message)) {
2336
- throw new Error(
2337
- `${err.message}\n (not waiting: it is already on screen, and waiting cannot make it unique)`,
2338
- );
2423
+ // Satisfies the wait, for the same reason as `waitFor` above: several
2424
+ // matches is an answer of "yes, it is here".
2425
+ const many = err.message.match(/matched (\d+) elements/);
2426
+ if (many) {
2427
+ return `"${target}" is on screen (${many[1]} matches)`
2428
+ + ' — the wait is satisfied; pass an index to act on one of them';
2339
2429
  }
2340
2430
  }
2341
2431
  await sleep(250);
@@ -2500,10 +2590,22 @@ async function runStep(deviceQuery, udid, step, ctx) {
2500
2590
  //
2501
2591
  // Distinguished by the tag at the throw site rather than by reading
2502
2592
  // the message, because "not on this screen" tags the same reason.
2593
+ //
2594
+ // And it **satisfies** the wait rather than failing it, which is the
2595
+ // correction. The paragraph above had the reasoning right — ambiguous
2596
+ // means the target is present, several times over — and then threw
2597
+ // anyway. A `waitFor` asks one question, *has it arrived*, and two
2598
+ // matches is a yes. Reported from the field: the batch was abandoned
2599
+ // and three queued steps discarded on a screen that was exactly where
2600
+ // the flow wanted to be, and the tester had to re-issue the lot with
2601
+ // an index. The ambiguity is real and belongs in the next step's
2602
+ // selector, not in this step's verdict.
2503
2603
  if (metrics.escalationOf(err)?.ambiguous) {
2504
- throw new Error(
2505
- `${err.message}\n (not waiting: it is already on screen, and waiting cannot make it unique)`,
2506
- );
2604
+ // The count comes from the message because the tag is a boolean —
2605
+ // it marks *which kind* of ambiguity, not how many.
2606
+ const n = err.message.match(/matches (\d+) things/)?.[1];
2607
+ return `${query} is on screen${n ? ` (${n} matches)` : ' more than once'}`
2608
+ + ' — the wait is satisfied; pass an index or a #ref to act on one of them';
2507
2609
  }
2508
2610
  }
2509
2611
  if (Date.now() >= limit) break;
@@ -2604,3 +2706,41 @@ async function runStep(deviceQuery, udid, step, ctx) {
2604
2706
  throw new Error(`unknown step "${step.action}"`);
2605
2707
  }
2606
2708
  }
2709
+
2710
+ /**
2711
+ * Why a settle gave up, in the numbers that decided it.
2712
+ *
2713
+ * `screen did not settle within 25008ms` names the one quantity that is never
2714
+ * the reason, and two CI cycles went into guessing what was behind it. A wait
2715
+ * turns on three things and the message carried none of them:
2716
+ *
2717
+ * - how long the screen had actually been still when we gave up
2718
+ * - how much stillness was being asked for
2719
+ * - whether any frame arrived during the call at all
2720
+ *
2721
+ * *"Still for 1,200 ms of the 1,400 required"* and *"still for 135,000 ms and no
2722
+ * frame ever arrived"* are opposite diagnoses — the first is a budget a hair too
2723
+ * tight, the second is a stalled pipeline wearing a calm screen — and they had
2724
+ * one sentence between them. So did *"something is animating in the top right"*.
2725
+ *
2726
+ * Ordered by what to do next: what is moving, then how close stillness got,
2727
+ * then whether anything was observed, then black frames.
2728
+ */
2729
+ export function settleEvidence(w) {
2730
+ const parts = [];
2731
+ const m = w.motion;
2732
+ if (m) parts.push(`the movement is ${m.where}${m.localised ? ` (${m.share}% of it)` : ''}`);
2733
+ if (Number.isFinite(w.stillForMs) && Number.isFinite(w.stableMsRequired)) {
2734
+ parts.push(`still for ${w.stillForMs}ms of the ${w.stableMsRequired}ms required`);
2735
+ }
2736
+ // Zero frames is the loud one: a wait that observed nothing has not measured
2737
+ // this screen, it has measured a file on disk.
2738
+ if (Number.isFinite(w.framesSeen)) {
2739
+ parts.push(w.framesSeen > 0
2740
+ ? `${w.framesSeen} frame(s) arrived while waiting`
2741
+ : 'NO frame arrived while waiting — capture, not the screen');
2742
+ }
2743
+ if (w.blackFrames > 0) parts.push(`${w.blackFrames} black frame(s) — see the capture wedge`);
2744
+ return (parts.length ? parts.join('; ') : 'nothing observed')
2745
+ + (m ? `\n${m.map}` : '');
2746
+ }
package/src/analyze.js CHANGED
@@ -65,6 +65,25 @@ export function signatureDiff(a, b) {
65
65
  */
66
66
  export const CELL_CHANGE = 0.012;
67
67
 
68
+ /**
69
+ * How far two *capture paths* may differ and still be looking at one screen.
70
+ *
71
+ * Not the change threshold, and the distinction matters. `signatureDiff > 0.004`
72
+ * asks whether a screen moved between two frames from the *same* path. This asks
73
+ * whether the daemon's frame and an independent `simctl` screenshot show the
74
+ * same thing — and they never match closely, because one is downscaled by the
75
+ * capture loop and the other is a full-resolution PNG scaled here.
76
+ *
77
+ * Measured on this device, which is the only reason a number appears:
78
+ *
79
+ * same screen, two paths 0.00123 (three runs, identical to five places)
80
+ * two different screens 0.68603 (before and after a home press)
81
+ *
82
+ * A separation of 550x, so the threshold is not delicate. 0.02 is sixteen times
83
+ * the scaling cost and thirty-four times under the signal.
84
+ */
85
+ export const PATHS_AGREE = 0.02;
86
+
68
87
  /** The largest single-region change between two signatures, 0-1. */
69
88
  export function maxCellDelta(a, b) {
70
89
  const deltas = regionDeltas(a, b);
@@ -100,6 +119,43 @@ export function regionMap(deltas, cols = REGION_COLS) {
100
119
  return lines.join('\n');
101
120
  }
102
121
 
122
+ /**
123
+ * Where the movement is, in words — item 123.
124
+ *
125
+ * A settle that gives up says only that it gave up, and the reporter who asked
126
+ * for this put the cost plainly: a streaming summary panel, a Lottie and a
127
+ * support widget never settle, so the settle fails, and because a failed step
128
+ * aborts the batch it takes the other five steps with it. Their workaround was
129
+ * `pause` plus `continueOnError`, which they called *"strictly worse than a
130
+ * settle that knows what to ignore"*. Knowing what to ignore is the expensive
131
+ * half. Saying **where** is nearly free, and it is what turns "did not settle"
132
+ * into "there is a spinner in the top-right and nobody cares about it".
133
+ *
134
+ * Deliberately coarse. Thirds of the screen in each axis, named the way a person
135
+ * would point at it, and a share so a caller can tell one spinner from a screen
136
+ * that is genuinely still in flight.
137
+ */
138
+ export function describeMotion(deltas, cols = REGION_COLS) {
139
+ const total = deltas.reduce((a, b) => a + b, 0);
140
+ if (!deltas.length || total <= 0) return null;
141
+ const rows = Math.ceil(deltas.length / cols);
142
+ const bands = new Map();
143
+ const third = (i, n) => (i < n / 3 ? 0 : i < (2 * n) / 3 ? 1 : 2);
144
+ const DOWN = ['top', 'middle', 'bottom'];
145
+ const ACROSS = ['left', 'centre', 'right'];
146
+ deltas.forEach((d, i) => {
147
+ const key = `${DOWN[third(Math.floor(i / cols), rows)]}-${ACROSS[third(i % cols, cols)]}`;
148
+ bands.set(key, (bands.get(key) ?? 0) + d);
149
+ });
150
+ const ranked = [...bands.entries()].sort((a, b) => b[1] - a[1]);
151
+ const [where, amount] = ranked[0];
152
+ const share = Math.round((amount / total) * 100);
153
+ // A single band holding most of the movement is a localised animation; spread
154
+ // evenly, the whole screen is in flight and naming a corner would mislead.
155
+ if (share < 40) return { where: 'spread across the screen', share, localised: false };
156
+ return { where: where.replace('-', ' '), share, localised: true };
157
+ }
158
+
103
159
  /** Signatures live in state.json, so they are stored as compact hex. */
104
160
  export function signatureToHex(sig) {
105
161
  return sig.map((v) => v.toString(16).padStart(2, '0')).join('');