simframe 0.12.2 → 0.14.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
@@ -819,6 +819,22 @@ export async function runScript(
819
819
  fingerprint: beforeScreen?.hash ?? null,
820
820
  reason: 'verification_failed',
821
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,
822
838
  // `verification_failed` is the largest reason class in the log and it
823
839
  // was the only one carrying no intent, which made most of the corpus
824
840
  // useless for asking what kind of decision costs us. The step knows
@@ -854,6 +870,7 @@ export async function runScript(
854
870
  // Carried from the throw site where it exists, and otherwise the step's
855
871
  // own target — which is what was asked for either way.
856
872
  intent: why.intent ?? goalOf(step),
873
+ classified: why.classified,
857
874
  outcome: 'failed',
858
875
  wallMs: Date.now() - stepStart,
859
876
  detail: err.message,
@@ -2369,8 +2386,26 @@ async function runStep(deviceQuery, udid, step, ctx) {
2369
2386
  timeoutMs: step.timeoutMs ?? 8000,
2370
2387
  options: ctx.options,
2371
2388
  });
2372
- if (!w.satisfied) throw new Error(w.stalled ? w.live.note : `screen did not settle within ${w.waitedMs}ms`);
2373
- 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)` : '');
2374
2409
  }
2375
2410
  case 'waitText': {
2376
2411
  const target = step.value ?? step.text;
@@ -2385,10 +2420,12 @@ async function runStep(deviceQuery, udid, step, ctx) {
2385
2420
  // Same rule as `waitFor`, and `matchElement` says it in its own
2386
2421
  // words: a query that matched several elements has found them all
2387
2422
  // already.
2388
- if (/matched \d+ elements/.test(err.message)) {
2389
- throw new Error(
2390
- `${err.message}\n (not waiting: it is already on screen, and waiting cannot make it unique)`,
2391
- );
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';
2392
2429
  }
2393
2430
  }
2394
2431
  await sleep(250);
@@ -2518,7 +2555,7 @@ async function runStep(deviceQuery, udid, step, ctx) {
2518
2555
  for (let attempt = 0; ; attempt += 1) {
2519
2556
  for (const one of alternatives) {
2520
2557
  try {
2521
- const found = await api.locate(deviceQuery, one, { refresh: attempt > 0 });
2558
+ const found = await api.locate(deviceQuery, one, { refresh: step.refresh !== false, options: ctx.options });
2522
2559
  return `${JSON.stringify(one)} appeared at ${found.target.x},${found.target.y}`
2523
2560
  + ` (first of ${alternatives.length} awaited)`;
2524
2561
  } catch (err) {
@@ -2526,10 +2563,12 @@ async function runStep(deviceQuery, udid, step, ctx) {
2526
2563
  }
2527
2564
  }
2528
2565
  if (Date.now() >= limit) {
2566
+ const stillAny = await stillnessNote(deviceQuery, ctx);
2529
2567
  throw new Error(
2530
2568
  `none of ${alternatives.length} awaited strings appeared`
2531
2569
  + ` (${alternatives.map((a) => JSON.stringify(a)).join(', ')}) in ${step.timeoutMs ?? 8000}ms.`
2532
- + ` Last: ${lastError}`,
2570
+ + ` Last: ${lastError}`
2571
+ + (stillAny ? ` (${stillAny.note})` : ''),
2533
2572
  );
2534
2573
  }
2535
2574
  await api.waitFor(deviceQuery, { mode: 'stable', stableMs: 200, timeoutMs: 700, options: ctx.options })
@@ -2538,7 +2577,26 @@ async function runStep(deviceQuery, udid, step, ctx) {
2538
2577
  }
2539
2578
  for (let attempt = 0; ; attempt += 1) {
2540
2579
  try {
2541
- const found = await api.locate(deviceQuery, query, { index: step.index, refresh: attempt > 0 });
2580
+ // Fresh, like `assert` — and for the same reason, found the same way.
2581
+ //
2582
+ // This read `refresh: attempt > 0`, so the FIRST look resolved against
2583
+ // the remembered map. A wait that can be satisfied by memory is not a
2584
+ // wait: if recall hands back the wrong screen's map — which it does
2585
+ // when two screens collide on a layout hash — the target "appears"
2586
+ // without ever having been on screen, instantly, and the caller
2587
+ // proceeds against a screen it is not on.
2588
+ //
2589
+ // Caught by the fingerprint eval on CI, which is the only place it
2590
+ // could show: `settings-general` read the **Settings root** and its
2591
+ // `waitFor "About"` had passed. The signature is in the round numbers
2592
+ // — r2 and r3, never r1 — because memory has to be warm before it can
2593
+ // lie, and round 1 is always cold.
2594
+ //
2595
+ // `assert` was made fresh by default after this exact defect cost a
2596
+ // reported session; `waitFor` was left as it was. One perception pass
2597
+ // is the price, and it is the same trade: a read is cheaper than the
2598
+ // round trip a wrong verdict causes.
2599
+ const found = await api.locate(deviceQuery, query, { index: step.index, refresh: step.refresh !== false, options: ctx.options });
2542
2600
  return `"${found.target.label}" appeared at ${found.target.x},${found.target.y}`;
2543
2601
  } catch (err) {
2544
2602
  lastError = err.message;
@@ -2553,16 +2611,42 @@ async function runStep(deviceQuery, udid, step, ctx) {
2553
2611
  //
2554
2612
  // Distinguished by the tag at the throw site rather than by reading
2555
2613
  // the message, because "not on this screen" tags the same reason.
2614
+ //
2615
+ // And it **satisfies** the wait rather than failing it, which is the
2616
+ // correction. The paragraph above had the reasoning right — ambiguous
2617
+ // means the target is present, several times over — and then threw
2618
+ // anyway. A `waitFor` asks one question, *has it arrived*, and two
2619
+ // matches is a yes. Reported from the field: the batch was abandoned
2620
+ // and three queued steps discarded on a screen that was exactly where
2621
+ // the flow wanted to be, and the tester had to re-issue the lot with
2622
+ // an index. The ambiguity is real and belongs in the next step's
2623
+ // selector, not in this step's verdict.
2556
2624
  if (metrics.escalationOf(err)?.ambiguous) {
2557
- throw new Error(
2558
- `${err.message}\n (not waiting: it is already on screen, and waiting cannot make it unique)`,
2559
- );
2625
+ // The count comes from the message because the tag is a boolean —
2626
+ // it marks *which kind* of ambiguity, not how many.
2627
+ const n = err.message.match(/matches (\d+) things/)?.[1];
2628
+ return `${query} is on screen${n ? ` (${n} matches)` : ' more than once'}`
2629
+ + ' — the wait is satisfied; pass an index or a #ref to act on one of them';
2560
2630
  }
2561
2631
  }
2562
2632
  if (Date.now() >= limit) break;
2633
+ // Opt-in: stop early on a screen that has plainly stopped changing.
2634
+ if (Number.isFinite(step.failIfStillFor)) {
2635
+ const still = await stillnessNote(deviceQuery, ctx);
2636
+ if (still && still.ms >= step.failIfStillFor) {
2637
+ throw new Error(`gave up on ${query} after ${Date.now() - (limit - (step.timeoutMs ?? 8000))}ms:`
2638
+ + ` ${still.note}, which is past the ${step.failIfStillFor}ms you said to stop at.`
2639
+ + ` Last: ${lastError}`);
2640
+ }
2641
+ }
2563
2642
  await sleep(POLL_MS);
2564
2643
  }
2565
- throw new Error(`waited ${step.timeoutMs ?? 8000}ms for ${query}: ${lastError}`);
2644
+ // Say how long it had been still. A wait that burned three minutes on a
2645
+ // screen static for the last twelve seconds should not make the reader
2646
+ // work that out from a second command.
2647
+ const still = await stillnessNote(deviceQuery, ctx);
2648
+ throw new Error(`waited ${step.timeoutMs ?? 8000}ms for ${query}: ${lastError}`
2649
+ + (still ? ` (${still.note} — pass failIfStillFor to stop early next time)` : ''));
2566
2650
  }
2567
2651
 
2568
2652
  // One assert step for every condition, because `assertText` could only ask
@@ -2590,6 +2674,32 @@ async function runStep(deviceQuery, udid, step, ctx) {
2590
2674
  // is the opposite of what you want learned."*
2591
2675
  found = await api.locate(deviceQuery, query, { index: step.index, refresh: step.refresh !== false, options: ctx.options });
2592
2676
  } catch (err) {
2677
+ // Ambiguity answers "is it there", and it answers it *yes*.
2678
+ //
2679
+ // `locate` refuses a query that matches several elements, which is right
2680
+ // for `tap` — picking the wrong one of two taps the wrong thing — and
2681
+ // wrong for a question about presence. Reported from the field on
2682
+ // 0.13.0: `assert Administrator is visible` failed, and aborted the rest
2683
+ // of the batch, against a screen with **two** Administrators on it. The
2684
+ // assertion's own semantics were satisfied twice over. The message was
2685
+ // praised in the same breath — candidates, coordinates and scores — so
2686
+ // what was wrong was the verdict, not the diagnosis.
2687
+ //
2688
+ // The `gone` direction had the mirror-image bug and nobody had hit it
2689
+ // yet: any error at all returned "is gone", so a query matching two
2690
+ // visible elements would have reported them absent. Ambiguity is the one
2691
+ // error that is positive evidence of presence, and it was being read as
2692
+ // proof of absence.
2693
+ //
2694
+ // Strict single-match resolution stays for `enabled`, `disabled` and
2695
+ // `value`, where the question is about a *particular* element and
2696
+ // answering it from the wrong one is how a confident wrong answer gets
2697
+ // made.
2698
+ if (err.ambiguous && err.candidates?.length) {
2699
+ const n = err.candidates.length;
2700
+ if (want === 'visible') return `${query} is visible (${n} things match it here — a presence check does not have to choose)`;
2701
+ if (want === 'gone') throw new Error(`${query}: still here — ${n} things on this screen match it`);
2702
+ }
2593
2703
  if (want === 'gone') return `${query} is gone`;
2594
2704
  throw new Error(`${query}: ${err.message}`);
2595
2705
  }
@@ -2657,3 +2767,93 @@ async function runStep(deviceQuery, udid, step, ctx) {
2657
2767
  throw new Error(`unknown step "${step.action}"`);
2658
2768
  }
2659
2769
  }
2770
+
2771
+ /**
2772
+ * Why a settle gave up, in the numbers that decided it.
2773
+ *
2774
+ * `screen did not settle within 25008ms` names the one quantity that is never
2775
+ * the reason, and two CI cycles went into guessing what was behind it. A wait
2776
+ * turns on three things and the message carried none of them:
2777
+ *
2778
+ * - how long the screen had actually been still when we gave up
2779
+ * - how much stillness was being asked for
2780
+ * - whether any frame arrived during the call at all
2781
+ *
2782
+ * *"Still for 1,200 ms of the 1,400 required"* and *"still for 135,000 ms and no
2783
+ * frame ever arrived"* are opposite diagnoses — the first is a budget a hair too
2784
+ * tight, the second is a stalled pipeline wearing a calm screen — and they had
2785
+ * one sentence between them. So did *"something is animating in the top right"*.
2786
+ *
2787
+ * Ordered by what to do next: what is moving, then how close stillness got,
2788
+ * then whether anything was observed, then black frames.
2789
+ */
2790
+ export function settleEvidence(w) {
2791
+ const parts = [];
2792
+ const m = w.motion;
2793
+ if (m) parts.push(`the movement is ${m.where}${m.localised ? ` (${m.share}% of it)` : ''}`);
2794
+ if (Number.isFinite(w.stillForMs) && Number.isFinite(w.stableMsRequired)) {
2795
+ parts.push(`still for ${w.stillForMs}ms of the ${w.stableMsRequired}ms required`);
2796
+ }
2797
+ // Zero frames is the loud one: a wait that observed nothing has not measured
2798
+ // this screen, it has measured a file on disk.
2799
+ if (Number.isFinite(w.framesSeen)) {
2800
+ parts.push(w.framesSeen > 0
2801
+ ? `${w.framesSeen} frame(s) arrived while waiting`
2802
+ : 'NO frame arrived while waiting — capture, not the screen');
2803
+ }
2804
+ if (w.blackFrames > 0) parts.push(`${w.blackFrames} black frame(s) — see the capture wedge`);
2805
+ return (parts.length ? parts.join('; ') : 'nothing observed')
2806
+ + (m ? `\n${m.map}` : '');
2807
+ }
2808
+
2809
+ /**
2810
+ * The one-line flow summary, so `ok` and `FAIL` each mean exactly one thing.
2811
+ *
2812
+ * `FLOW FAILED — 3/3 steps` was reported from the field as reading like a
2813
+ * success, and the reporter had it exactly: the denominator means *attempted*
2814
+ * on failure and *succeeded* on success, so the same shape carries opposite
2815
+ * meanings. `flow completed — 5/5 steps` and `FLOW FAILED — 3/3 steps` differ
2816
+ * only in a word, and the numbers argue against the word.
2817
+ *
2818
+ * On failure it says how many worked and how many did not, which is the thing a
2819
+ * caller has to know to decide whether to resume or re-plan.
2820
+ */
2821
+ export function flowSummary(res, { withTime = true } = {}) {
2822
+ const time = withTime && Number.isFinite(res.totalMs) ? ` in ${res.totalMs}ms` : '';
2823
+ if (res.ok) return `flow completed — ${res.ranSteps}/${res.totalSteps} steps${time}`;
2824
+ const failed = (res.results ?? []).filter((r) => r.ok === false).length || 1;
2825
+ const worked = Math.max(0, res.ranSteps - failed);
2826
+ const unattempted = Math.max(0, res.totalSteps - res.ranSteps);
2827
+ return `FLOW FAILED — ${worked} ok, ${failed} failed`
2828
+ + (unattempted ? `, ${unattempted} not attempted` : '')
2829
+ + ` (of ${res.totalSteps})${time}`;
2830
+ }
2831
+
2832
+ /**
2833
+ * How long the screen has been still, for a wait that is about to give up.
2834
+ *
2835
+ * A field report: a 180-second wait for a control that never appeared, on a
2836
+ * screen that had been static for about twelve of those seconds — the app had
2837
+ * logged itself out and was sitting on a login form. The failure message was
2838
+ * praised for listing what *was* on screen; it arrived three minutes late.
2839
+ *
2840
+ * Stillness is already tracked and already printed by other commands, so the
2841
+ * information existed and this wait simply never asked for it. Reported on
2842
+ * every timeout, and — only when the caller opts in — allowed to end the wait
2843
+ * early. Opt-in and not default, because a still screen is exactly what a
2844
+ * pending network call looks like: the target may yet arrive, and a wait that
2845
+ * gave up on stillness alone would break the case waits exist for.
2846
+ *
2847
+ * This is only trustworthy because of 134. Before the animation threshold was
2848
+ * measured, a completely static screen claimed something was animating on 54%
2849
+ * of its frames, and "nothing has moved" could not be said with a straight face.
2850
+ */
2851
+ async function stillnessNote(deviceQuery, ctx) {
2852
+ try {
2853
+ const { state } = await api.getState(deviceQuery, { options: ctx.options });
2854
+ const ms = state?.stableForMs;
2855
+ return Number.isFinite(ms) ? { ms, note: `the screen has not moved for ${Math.round(ms)}ms` } : null;
2856
+ } catch {
2857
+ return null;
2858
+ }
2859
+ }
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('');