simframe 0.18.0 → 0.19.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/src/actions.js CHANGED
@@ -16,6 +16,7 @@ import * as planner from './planner.js';
16
16
  import * as metrics from './metrics.js';
17
17
  import * as screenmap from './screenmap.js';
18
18
  import { launchApp, openUrl, setPermission, terminateApp } from './platform/index.js';
19
+ import { shellCrashed } from './device-state.js';
19
20
 
20
21
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
22
  const MAX_PAUSE_MS = 5000;
@@ -164,6 +165,59 @@ export function normalizeStep(raw) {
164
165
  return step;
165
166
  }
166
167
 
168
+ /**
169
+ * How long to keep waiting for a guest shell that has just crashed.
170
+ *
171
+ * Measured: six crashes on `326464A4`, every one of them recovered by waiting
172
+ * and launching again, in 5.7-7.9 s. 20 s is that with room, and it is bounded
173
+ * so a device that is genuinely gone still fails rather than hanging.
174
+ */
175
+ export const SHELL_PATIENCE_MS = 20_000;
176
+
177
+ /** How often to try again while waiting for it. */
178
+ export const SHELL_POLL_MS = 1500;
179
+
180
+ /**
181
+ * Launch, and survive the guest's window server dying under the attempt.
182
+ *
183
+ * `simctl` reports a SpringBoard crash as a launch failure, which is true and
184
+ * unhelpful: the shell is back a few seconds later and the same launch works.
185
+ * Until now the only remedy in the project was `simframe revive`, a ~40 s
186
+ * device restart — and item 173 notes that waiting and retrying "has never been
187
+ * tried". It was tried on 2026-09-18 and recovered 6 of 6.
188
+ *
189
+ * Deliberately narrow. Only the shell-crash signature is retried; every other
190
+ * device failure, including `simctl did not return within 90s`, fails on the
191
+ * first attempt as before, because none of them has been observed to heal and a
192
+ * retry on that one costs another 90 s to learn nothing.
193
+ *
194
+ * `report` is filled in rather than returned so the step can say it happened.
195
+ * A retry that does not show up in the summary is a rate nobody can argue with.
196
+ */
197
+ export async function launchThroughShellCrash(
198
+ udid,
199
+ bundleId,
200
+ options,
201
+ report = {},
202
+ { launch = launchApp, wait = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now } = {},
203
+ ) {
204
+ const started = now();
205
+ for (;;) {
206
+ report.attempts = (report.attempts ?? 0) + 1;
207
+ try {
208
+ const out = await launch(udid, bundleId, options);
209
+ report.waitedMs = now() - started;
210
+ return out;
211
+ } catch (err) {
212
+ if (!shellCrashed(err.message) || now() - started >= SHELL_PATIENCE_MS) {
213
+ report.waitedMs = now() - started;
214
+ throw err;
215
+ }
216
+ await wait(SHELL_POLL_MS);
217
+ }
218
+ }
219
+ }
220
+
167
221
  /**
168
222
  * Does this verdict mean the flow went somewhere nobody intended?
169
223
  *
@@ -740,6 +794,11 @@ export async function runScript(
740
794
  verification = await confirmNoChange(deviceQuery, verification, {
741
795
  beforeScreen, options, stableMs, timeoutMs,
742
796
  });
797
+ // And the same courtesy before a wrong turn throws away the rest of
798
+ // the batch, which is the more expensive of the two mistakes.
799
+ verification = await confirmWrongTurn(deviceQuery, verification, {
800
+ udid, prediction, beforeScreen, step, options, stableMs, timeoutMs,
801
+ });
743
802
  if (verification.lateArrival) {
744
803
  // The reading the verdict was taken from is now known to be stale, so
745
804
  // nothing downstream may learn a screen or an edge from it.
@@ -880,6 +939,10 @@ export async function runScript(
880
939
  action: step.action,
881
940
  verification,
882
941
  ok: true,
942
+ // Either sensor saying nothing moved, and no state change explaining
943
+ // it. The verdict alone missed the case where only the settle
944
+ // detector saw it: `ok … [no visible change] (never settled)`.
945
+ unconfirmed: wentNowhere,
883
946
  ms: Date.now() - stepStart,
884
947
  detail: `${detail}${note}${wrongTurn ? ` [${verification.verdict}: ${verification.detail}]` : ''}`,
885
948
  settled,
@@ -1775,6 +1838,77 @@ async function confirmNoChange(deviceQuery, verification, { beforeScreen, option
1775
1838
  };
1776
1839
  }
1777
1840
 
1841
+ /**
1842
+ * What a second look at a wrong turn establishes.
1843
+ *
1844
+ * Pure, and separate from the read that feeds it, because the read needs a
1845
+ * device and this needs to be provable without one. `stillOnPlan` is tested by
1846
+ * asserting against its own source text, which is what you write when the
1847
+ * decision cannot be reached in a test, and it checks that the code says what
1848
+ * it says rather than that it decides what it should.
1849
+ *
1850
+ * The rule: a second reading that *also* says wrong turn confirms the first,
1851
+ * and the halt stands on two reads instead of one. Any other second reading
1852
+ * replaces it, because the first was taken of a screen that had not finished
1853
+ * being itself. The disagreement is kept either way — it is the evidence that
1854
+ * this gate is flaky rather than protective, and it is what a supervisor would
1855
+ * be asked to rule on.
1856
+ */
1857
+ export function afterSecondLook(verification, second, again) {
1858
+ if (!second?.verdict || second.verdict === 'unexpected-screen') return verification;
1859
+ const was = String(verification?.observed?.to ?? '').slice(0, 8);
1860
+ const now = String(again?.hash ?? '').slice(0, 8);
1861
+ return {
1862
+ ...verification,
1863
+ verdict: second.verdict,
1864
+ detail: `${second.detail} — read again after settling, because the first read said`
1865
+ + ` "${verification.detail}"${was && now && was !== now ? ` (${was} → ${now})` : ''}`,
1866
+ // Both answers, so the log can count how often perception contradicts
1867
+ // itself here rather than only how often it halted a run.
1868
+ disagreed: { first: verification.verdict, second: second.verdict, from: was || null, to: now || null },
1869
+ lateArrival: again,
1870
+ };
1871
+ }
1872
+
1873
+ /**
1874
+ * One more look before a wrong turn is allowed to stop the batch.
1875
+ *
1876
+ * The sibling of `confirmNoChange`, for the same reason and against the same
1877
+ * class of mistake: a verdict taken from a screen that was still arriving. The
1878
+ * reported symptom is that the gate is **non-deterministic** — a reporter
1879
+ * re-issued the identical call with no state change and it passed — and a gate
1880
+ * that fails once and passes on retry is flaky, not protective. Asked twice,
1881
+ * the perception disagrees with itself, and the first answer was never
1882
+ * evidence.
1883
+ *
1884
+ * This keeps the verify barrier rather than softening it. The barrier asks for
1885
+ * confirmed perception before acting on a screen an `unexpected-*` verdict has
1886
+ * touched; a halt on one premature read is not confirmed perception either.
1887
+ * Both directions now cost a second, settled read, and a wrong turn that is
1888
+ * real still halts — on better evidence than before.
1889
+ *
1890
+ * Deliberately not gated on a supervisor being configured. The escape hatch the
1891
+ * item proposed was a supervisor ruling, which `doctor` reports as
1892
+ * `none — not requested` on a default install, so that fix would have reached
1893
+ * only callers who opted in. A second read needs no model and no opt-in; a
1894
+ * supervisor, when there is one, still gets asked about what survives it.
1895
+ */
1896
+ async function confirmWrongTurn(deviceQuery, verification, { udid, prediction, beforeScreen, step, options, stableMs, timeoutMs }) {
1897
+ if (verification?.verdict !== 'unexpected-screen' || !prediction || !beforeScreen?.hash) return verification;
1898
+ await api.waitFor(deviceQuery, {
1899
+ mode: 'settle', stableMs: 250, timeoutMs: LATE_CHANGE_MS, options,
1900
+ }).catch(() => null);
1901
+ const again = await api.screenIdentity(deviceQuery, { options, settleMs: stableMs, timeoutMs }).catch(() => null);
1902
+ // Nothing looked, so nothing was established and the first answer stands.
1903
+ // A failed read may never be read as agreement.
1904
+ if (!again?.hash) return verification;
1905
+ // `kind` is deliberately not passed: the transition classifier's reading
1906
+ // belongs to the moment of the first look, and it only ever decorates an
1907
+ // `ok`. Re-using it here would date-stamp this answer with that one.
1908
+ const second = graph.verdict({ udid, prediction, before: beforeScreen, after: again, action: step.action });
1909
+ return afterSecondLook(verification, second, again);
1910
+ }
1911
+
1778
1912
  /**
1779
1913
  * A control that changed state is not a step that did nothing.
1780
1914
  *
@@ -2716,11 +2850,12 @@ async function runStep(deviceQuery, udid, step, ctx) {
2716
2850
  return `pressed key ${step.value ?? step.code}`;
2717
2851
  case 'launch': {
2718
2852
  const bundleId = step.value ?? step.bundleId;
2719
- const started = await launchApp(udid, bundleId, {
2853
+ const shell = { waitedMs: 0, attempts: 0 };
2854
+ const started = await launchThroughShellCrash(udid, bundleId, {
2720
2855
  args: step.args ?? [],
2721
2856
  env: step.env ?? {},
2722
2857
  terminateFirst: step.relaunch === true,
2723
- });
2858
+ }, shell);
2724
2859
  // Item 169: simctl returning ok means the process started, not that the
2725
2860
  // app came forward, and the two came apart repeatedly on a loaded
2726
2861
  // runner — leaving the device on the previous app under a step that
@@ -2760,6 +2895,10 @@ async function runStep(deviceQuery, udid, step, ctx) {
2760
2895
  if (ctx.landing) ctx.landing.verdict = landed.verdict;
2761
2896
  return `launched ${bundleId}${step.relaunch ? ' (relaunched)' : ''}`
2762
2897
  + (landed.verdict === 'fronted' ? ` (frontmost after ${landed.ms}ms)` : '')
2898
+ + (shell.attempts > 1
2899
+ ? ` [the guest's SpringBoard crashed and came back; launched again after ${shell.waitedMs}ms`
2900
+ + `, on attempt ${shell.attempts}]`
2901
+ : '')
2763
2902
  + (retried
2764
2903
  ? ` [it did not front on the first attempt — ${frontmost.describeHeld(retried.held, started.pid)}`
2765
2904
  + `; a second launch fronted it. The launch is unreliable on this host.]`
@@ -2873,25 +3012,74 @@ async function runStep(deviceQuery, udid, step, ctx) {
2873
3012
  // `direction` still wins, for a caller who knows better.
2874
3013
  const asked = step.direction ? String(step.direction).toLowerCase() : null;
2875
3014
  const max = Math.min(MAX_SCROLLS, step.maxScrolls ?? 6);
3015
+ // **The screen's size, in scope.** It was not, and that single omission is
3016
+ // the whole of item 190 and of the field report's Defect 2.
3017
+ //
3018
+ // `points` existed only inside `offsetSays`'s own destructure, so every
3019
+ // other reference to it — `inViewport`, and the message that reports a
3020
+ // target as out of view — was an undeclared identifier. `inViewport` runs
3021
+ // on **every successful locate**, so the moment this step found what it
3022
+ // was looking for it threw `ReferenceError: points is not defined`, the
3023
+ // loop's own `catch` swallowed it as "not found", and it scrolled on until
3024
+ // the budget ran out and it reported the target unreachable.
3025
+ //
3026
+ // So `scrollTo` could fail on a target that was on screen, could fail
3027
+ // after a scroll that had just brought the target into view, and could
3028
+ // burn its whole budget doing it — all three of the reported symptoms,
3029
+ // and all three are this. Measured from the loop, i is the iteration:
3030
+ //
3031
+ // i=0 locate threw: "Developer" is not on this screen (true, it was above)
3032
+ // i=1 locate threw: points is not defined (it had FOUND it)
3033
+ // i=2 locate threw: points is not defined (again)
3034
+ //
3035
+ // Optional chaining does not help: `points?.width` on an undeclared name
3036
+ // is still a ReferenceError, which is why nothing caught this by reading.
3037
+ const geo = await ctx.screen();
3038
+ const points = { width: geo.pointWidth, height: geo.pointHeight };
2876
3039
  let dir = asked ?? 'down';
2877
3040
  let reversed = false;
3041
+ // Whether any scroll in this step changed what was showing. A step whose
3042
+ // every scroll moved nothing is not evidence about the target at all.
3043
+ let movedOnce = false;
2878
3044
  // Where the target is, when the tree knows. `null` means no evidence, and
2879
3045
  // that distinction is load bearing: guessing "up" without it scrolls to
2880
3046
  // the top of a web page, which **triggers pull-to-refresh**, reloads the
2881
3047
  // page and changes the screen hash — defeating the end-detection below
2882
3048
  // and reading, from outside, as an endless loop. Observed live.
2883
- const offsetSays = async () => {
2884
- if (asked) return asked;
3049
+ //
3050
+ // **And it answers `null` far more often than this was written for.**
3051
+ // Measured on 2026-09-18, which is item 190: iOS puts only the *visible*
3052
+ // rows of a table in the accessibility tree. A scrolled-away row is not
3053
+ // there with an out-of-bounds coordinate — it is absent.
3054
+ //
3055
+ // Settings at the top, resolve("Developer") -> status=none, y=undefined
3056
+ // Settings at the bottom, resolve("Accessibility") -> status=none, y=undefined
3057
+ //
3058
+ // Zero elements whose label even contains the query, both times. So on a
3059
+ // native list this returns `null` in precisely the case it exists to
3060
+ // answer, and the y<0 / y>height reasoning below only ever fires on the
3061
+ // frameworks that do keep off-screen rows in the tree — React Native, and
3062
+ // the `y = -693` case this was written for.
3063
+ // One read answers both questions, so asking them costs what asking one
3064
+ // used to: which way is the target, and what is on screen right now.
3065
+ const lookAround = async () => {
2885
3066
  try {
2886
- const { entry, points } = await api.readScreenWith(deviceQuery, { useOcr: false, options: ctx.options });
2887
- const hit = matching.resolve(entry.targets ?? [], String(query));
3067
+ const { entry } = await api.readScreenWith(deviceQuery, { useOcr: false, options: ctx.options });
3068
+ const targets = entry.targets ?? [];
3069
+ // What is visible, as one comparable string. This is the end detector
3070
+ // now, and the frame hash is not — see `scrolledNowhere`.
3071
+ const signature = targets.map((t) => String(t.label ?? '')).filter(Boolean).sort().join('\u0000');
3072
+ if (asked) return { says: asked, signature };
3073
+ const hit = matching.resolve(targets, String(query));
2888
3074
  const y = hit?.target?.y;
2889
- if (!Number.isFinite(y)) return null;
2890
- if (y < 0) return 'up';
2891
- if (y > (points?.height ?? Infinity)) return 'down';
2892
- return dir;
3075
+ let says = null;
3076
+ if (!Number.isFinite(y)) says = null;
3077
+ else if (y < 0) says = 'up';
3078
+ else if (y > (points?.height ?? Infinity)) says = 'down';
3079
+ else says = dir;
3080
+ return { says, signature };
2893
3081
  } catch {
2894
- return null;
3082
+ return { says: asked ?? null, signature: null };
2895
3083
  }
2896
3084
  };
2897
3085
  /**
@@ -2924,25 +3112,62 @@ async function runStep(deviceQuery, udid, step, ctx) {
2924
3112
  // rather than the act — reported as "after 1 scroll down" on a request
2925
3113
  // for "up".
2926
3114
  const scrolled = [];
3115
+ const arrivedHow = () => (scrolled.length
3116
+ ? ` after ${scrolled.length} scroll${scrolled.length === 1 ? '' : 's'} `
3117
+ + (new Set(scrolled).size === 1 ? scrolled[0] : scrolled.join(' then '))
3118
+ : ' already');
3119
+ /**
3120
+ * Is it here now? Asked without throwing, so a give-up path can use it.
3121
+ *
3122
+ * **A frame hash is a whole-screen measure and a strip is a small part of
3123
+ * the screen**, so "the frame did not change" is not "nothing moved".
3124
+ * Reported from the field: a horizontally-scrolling tab strip had the
3125
+ * target scrolled into view — the visible tabs demonstrably changed — and
3126
+ * this step reported `not reachable by scrolling` anyway, because the
3127
+ * whole-frame hash barely moved. A confident wrong *failure* after the
3128
+ * action succeeded, which costs what a silent success costs: the caller
3129
+ * is handed a false fact and a round trip.
3130
+ *
3131
+ * Same principle as the recall path (DEFERRED 184): a miss may not be the
3132
+ * final word until something has looked.
3133
+ */
3134
+ const nowInView = async () => {
3135
+ try {
3136
+ const found = await api.locate(deviceQuery, query, { index: step.index, refresh: true });
3137
+ return inViewport(found.target) ? found : null;
3138
+ } catch {
3139
+ return null;
3140
+ }
3141
+ };
3142
+ // What the last look actually established, so the give-up message can say
3143
+ // what it knows instead of guessing. `absent` means the resolver did not
3144
+ // find it; `off-view` means it did and the target sits outside the
3145
+ // viewport — and in that case claiming it may not be in the tree is
3146
+ // flatly contradicted by the tree we just read.
3147
+ let lastMiss = null;
2927
3148
  for (let i = 0; i <= max; i += 1) {
2928
3149
  try {
2929
3150
  const found = await api.locate(deviceQuery, query, { index: step.index, refresh: i > 0 });
2930
- if (!inViewport(found.target)) throw new Error(
2931
- `"${query}" is in the tree but not in view (at ${found.target.x},${found.target.y}`
2932
- + ` on a ${Math.round(points?.width ?? 0)}x${Math.round(points?.height ?? 0)}pt screen)`);
2933
- const how = scrolled.length
2934
- ? ` after ${scrolled.length} scroll${scrolled.length === 1 ? '' : 's'} `
2935
- + (new Set(scrolled).size === 1 ? scrolled[0] : scrolled.join(' then '))
2936
- : ' already';
2937
- return `"${found.target.label ?? query}" is in view at ${found.target.x},${found.target.y}${how}`;
3151
+ if (!inViewport(found.target)) {
3152
+ lastMiss = 'off-view';
3153
+ throw new Error(
3154
+ `"${query}" is in the tree but not in view (at ${found.target.x},${found.target.y}`
3155
+ + ` on a ${Math.round(points?.width ?? 0)}x${Math.round(points?.height ?? 0)}pt screen)`);
3156
+ }
3157
+ return `"${found.target.label ?? query}" is in view at ${found.target.x},${found.target.y}${arrivedHow()}`;
2938
3158
  } catch (err) {
3159
+ if (lastMiss !== 'off-view') lastMiss = 'absent';
2939
3160
  if (i === max) {
2940
3161
  throw new Error(`scrolled ${dir} ${max}x without finding ${query}: ${err.message}`);
2941
3162
  }
2942
3163
  }
2943
- const evidence = await offsetSays();
2944
- if (evidence) dir = evidence;
2945
- const wasAt = await hashNow(deviceQuery, ctx.options);
3164
+ const { says: evidence, signature: wasShowing } = await lookAround();
3165
+ // Not after a reversal. The stall that caused it is a measurement that
3166
+ // this direction is exhausted, and letting the tree's hint overrule it
3167
+ // sent the step back the way it had just failed — reported on 0.19.0 as
3168
+ // `it stopped moving both ways (down, down)`, a message naming two
3169
+ // attempts in one direction and claiming both.
3170
+ if (evidence && !reversed) dir = evidence;
2946
3171
  scrolled.push(dir);
2947
3172
  await runStep(deviceQuery, udid, { action: 'scroll', value: dir }, ctx);
2948
3173
  // A scroll either moves immediately or not at all, so it does not need a
@@ -2956,17 +3181,71 @@ async function runStep(deviceQuery, udid, step, ctx) {
2956
3181
  // page"*. Reverse once — the target may be behind us, and on a page
2957
3182
  // whose fields never enter the tree there is no offset to follow — then
2958
3183
  // stop rather than thrash.
2959
- const nowAt = await hashNow(deviceQuery, ctx.options);
2960
- if (wasAt && nowAt && wasAt === nowAt) {
2961
- // Reverse only on evidence. Without it we do not know the target is
2962
- // behind us, and scrolling blindly the other way is how a web page
2963
- // gets pulled to refresh.
2964
- if (reversed || !evidence) {
3184
+ // **What "it stopped moving" is measured on, and the frame hash was the
3185
+ // wrong thing.** At the end of a list iOS rubber-bands: the content does
3186
+ // not advance and the pixels do, so a whole-frame comparison answers
3187
+ // "yes, it moved" on every attempt and the end is never detected. Seen
3188
+ // on 2026-09-18 — `scrolled down 6x without finding Accessibility` on a
3189
+ // list that had been against its bottom stop the whole time, 21.5s for
3190
+ // nothing.
3191
+ //
3192
+ // The visible labels do not bounce. If the same rows are on screen after
3193
+ // a scroll as before it, the scroll achieved nothing, whatever the
3194
+ // framebuffer did.
3195
+ const { signature: nowShowing } = await lookAround();
3196
+ if (wasShowing && nowShowing && wasShowing !== nowShowing) movedOnce = true;
3197
+ if (wasShowing && nowShowing && wasShowing === nowShowing) {
3198
+ // Before believing the hash, look. See `nowInView` — an unchanged
3199
+ // whole-frame hash is weak evidence about a strip, and this step has
3200
+ // reported failure on a scroll that worked.
3201
+ const arrived = await nowInView();
3202
+ if (arrived) {
3203
+ return `"${arrived.target.label ?? query}" is in view at ${arrived.target.x},${arrived.target.y}`
3204
+ + `${arrivedHow()} (the frame hash did not register the scroll — looked again rather than`
3205
+ + ' reporting a failure the screen contradicts)';
3206
+ }
3207
+ // **A stall is evidence, and treating it as none is item 190.**
3208
+ //
3209
+ // This used to reverse only when the *tree* said where the target
3210
+ // was — and on a native list the tree never does, because scrolled-
3211
+ // away rows are not in it. So the common case was: no evidence, the
3212
+ // default direction, and if that direction happened to be the wrong
3213
+ // one the step burned its budget moving away from the target and then
3214
+ // refused, having never looked the other way. Field-measured at 12.6s
3215
+ // and 18.4s for zero progress, the largest single latency cost in
3216
+ // that round.
3217
+ //
3218
+ // Having scrolled and moved nothing, we know this direction is
3219
+ // exhausted. That is a fact about the screen rather than a guess, and
3220
+ // it is a different claim from the one the rule above was protecting
3221
+ // against: the danger there was *opening* with an unfounded "up",
3222
+ // which walks a web page to the top and pulls it to refresh. Here we
3223
+ // are at one end and the only place left to look is the other. Still
3224
+ // exactly once, still bounded by `max`.
3225
+ if (reversed) {
2965
3226
  throw new Error(
2966
- `${query} is not reachable by scrolling: ${dir} stopped moving after ${i + 1} attempt(s)`
2967
- + (evidence ? ' and so did the other way.' : ' and the tree does not say where it is,'
2968
- + ' so there is no direction to try.')
2969
- + ' It may not be in the accessibility tree at all — read the screen, or aim at a coordinate.',
3227
+ // Both ways, always — this branch is now only reached after a
3228
+ // reversal, so "there is no direction to try" (which it used to
3229
+ // say when the tree was silent) is no longer true and would be
3230
+ // the same kind of unchecked assertion as the sentence below it.
3231
+ `${query} is not reachable by scrolling: it stopped moving`
3232
+ + (new Set(scrolled).size > 1 ? ' both ways' : ` scrolling ${scrolled[0]}`)
3233
+ + ` (${scrolled.join(', ')}) after ${i + 1} attempt(s).`
3234
+ // Reported on 0.19.0: a React Native ScrollView that a plain swipe
3235
+ // from another tool scrolled at once, and that none of these moved.
3236
+ + (movedOnce ? '' : ' No scroll moved the screen at all, so this says nothing about'
3237
+ + ' where the target is — if this view does scroll, the gesture is not reaching it'
3238
+ + ' (try sim_do with a swipe that starts inside the list, or check input with sim_state).')
3239
+ // Say what was established, not what would be convenient. This
3240
+ // used to assert "It may not be in the accessibility tree at all"
3241
+ // in every case — a claim the function never checks, and one the
3242
+ // `off-view` branch has already disproved by reading the element
3243
+ // out of the tree a line earlier.
3244
+ + (lastMiss === 'off-view'
3245
+ ? ' It IS in the tree and outside the viewport, so this is a scrolling problem'
3246
+ + ' rather than a perception one — try a different container, or aim at a coordinate.'
3247
+ : ' The resolver did not find it here either, so it may not be on this screen at all —'
3248
+ + ' read the screen (sim_ui), or aim at a coordinate.'),
2970
3249
  );
2971
3250
  }
2972
3251
  reversed = true;
@@ -3337,6 +3616,25 @@ export function settleEvidence(w) {
3337
3616
  + (m ? `\n${m.map}` : '');
3338
3617
  }
3339
3618
 
3619
+ /**
3620
+ * The mark a step result is printed under: `ok `, `WARN` or `FAIL`.
3621
+ *
3622
+ * `ok` used to mean only "nothing threw". A tap whose own verdict was
3623
+ * `no-visible-change` printed `ok … [no visible change]`, and a field report
3624
+ * (0.19.0) counted three such taps in a row that had not landed, each caught
3625
+ * only by an outside screenshot, while the escalation log recorded every one
3626
+ * of them as a mis-tap. The verdict was right and the mark contradicted it.
3627
+ *
3628
+ * WARN rather than FAIL, and no automatic retry. `no-visible-change` also
3629
+ * fires on changes too small to register — a field gaining focus was measured
3630
+ * reading as no change on 2026-10-01 — and retrying on a false one is how a
3631
+ * tap fires twice, which the verify barrier forbids.
3632
+ */
3633
+ export function stepMark(r) {
3634
+ if (!r?.ok) return 'FAIL';
3635
+ return r.unconfirmed || metrics.ESCALATING_VERDICTS.has(r.verification?.verdict) ? 'WARN' : 'ok ';
3636
+ }
3637
+
3340
3638
  /**
3341
3639
  * The one-line flow summary, so `ok` and `FAIL` each mean exactly one thing.
3342
3640
  *
@@ -3361,7 +3659,15 @@ export function flowSummary(res, { withTime = true } = {}) {
3361
3659
  const skips = skipped
3362
3660
  ? ` (${skipped} optional step${skipped === 1 ? '' : 's'} skipped as absent)`
3363
3661
  : '';
3364
- if (res.ok) return `flow completed — ${res.ranSteps}/${res.totalSteps} steps${time}${skips}`;
3662
+ if (res.ok) {
3663
+ // "Completed" over a step that did nothing visible is the same one word
3664
+ // arguing with its numbers that the failure branch below was fixed for.
3665
+ const unconfirmed = (res.results ?? []).filter((r) => stepMark(r) === 'WARN').length;
3666
+ if (unconfirmed) {
3667
+ return `flow ran — ${res.ranSteps}/${res.totalSteps} steps, ${unconfirmed} not confirmed to have landed${time}${skips}`;
3668
+ }
3669
+ return `flow completed — ${res.ranSteps}/${res.totalSteps} steps${time}${skips}`;
3670
+ }
3365
3671
  const failed = (res.results ?? []).filter((r) => r.ok === false).length || 1;
3366
3672
  const worked = Math.max(0, res.ranSteps - failed);
3367
3673
  const unattempted = Math.max(0, res.totalSteps - res.ranSteps);
package/src/cli.js CHANGED
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { runDaemon, DEFAULTS } from './daemon.js';
6
- import { bootedDevices, capabilitiesFor, listDevices, PLATFORMS, resolveDevice, restartDevice, screenshot, toolchainChecks } from './platform/index.js';
6
+ import { bootedDevices, capabilitiesFor, listDevices, PLATFORMS, resolveDevice, screenshot, toolchainChecks } from './platform/index.js';
7
7
  import * as actions from './actions.js';
8
8
  import * as analyze from './analyze.js';
9
9
  import * as api from './index.js';
@@ -12,11 +12,14 @@ import * as baseline from './baseline.js';
12
12
  import * as metrics from './metrics.js';
13
13
  import * as navigate from './navigate.js';
14
14
  import * as wedge from './wedge.js';
15
+
15
16
  import { decodePng } from './png.js';
16
17
  import * as storage from './storage.js';
17
18
  import * as store from './store.js';
18
19
  import * as view from './view.js';
19
20
 
21
+
22
+
20
23
  const USAGE = `simframe — always-warm iOS Simulator frames
21
24
 
22
25
  simframe mcp run the MCP server on stdio (for agents)
@@ -265,7 +268,7 @@ async function lineReader() {
265
268
  const stepLine = (r) => {
266
269
  const settle = r.settled ? (r.settled.ok ? ` (settled ${r.settled.waitedMs}ms)` : ' (never settled)') : '';
267
270
  const verdict = r.verification && r.verification.verdict !== 'ok' ? ` [${r.verification.verdict}]` : '';
268
- return `${r.ok ? 'ok ' : 'FAIL'} [${r.index}] ${r.action}: ${r.ok ? r.detail : r.error}${settle}${verdict}`;
271
+ return `${actions.stepMark(r)} [${r.index}] ${r.action}: ${r.ok ? r.detail : r.error}${settle}${verdict}`;
269
272
  };
270
273
 
271
274
  async function main() {
@@ -421,31 +424,31 @@ async function main() {
421
424
  case 'revive': {
422
425
  const dev = await resolveDevice(device);
423
426
  const say = (line) => { if (!flags.json) console.log(line); };
424
- const steps = [];
425
- const did = async (what, fn) => {
426
- try { await fn(); steps.push({ step: what, ok: true }); say(` ok ${what}`); } catch (err) {
427
- steps.push({ step: what, ok: false, error: err.message });
428
- say(` .. ${what} — ${err.message.split('\n')[0]}`);
429
- }
430
- };
431
427
  say(`reviving ${dev.name}`);
432
- // Forced: the point of this command is that the device is wedged, so
433
- // something is certainly still holding it.
434
- await did('stopped the daemon', async () => { api.stopDaemon(dev.udid, { force: true }); });
435
- // Through the boundary, which is the whole point of the boundary: the
436
- // first version of this shelled out to `xcrun` from here and the test
437
- // that forbids it failed immediately, correctly.
438
- await did('restarted the device, and waited for the boot to finish',
439
- () => restartDevice(dev.udid));
440
- await did('started capture', () => api.ensureDaemon(dev.udid));
441
- await did('rebuilt the HID session', () => input.resetSession(dev.udid));
442
- const health = await api.getState(dev.udid).then((s) => s?.state ?? null).catch(() => null);
443
- const alive = Boolean(health?.hash);
444
- emit(flags, { ok: alive, device: dev.udid, steps }, alive
445
- ? `\n${dev.name} is producing frames again`
446
- : `\n${dev.name} is still not producing frames. This is past what simframe can do —`
447
- + ' check Simulator.app is not showing an error, and see docs/DEFERRED.md item 95.');
448
- if (!alive) process.exitCode = 1;
428
+ // The sequence itself lives in `wedge.js`, because `bench-hpi` needs the
429
+ // same recovery between passes and a second copy of it here is how this
430
+ // project has repeatedly ended up fixing one symptom in three places.
431
+ const revived = await wedge.revive(dev.udid, {
432
+ options,
433
+ device: dev,
434
+ onStep: ({ step, ok, error }) => say(ok ? ` ok ${step}` : ` .. ${step} — ${String(error).split('\n')[0]}`),
435
+ });
436
+ const { steps } = revived;
437
+ const diag = { verdict: revived.verdict };
438
+ const state = diag?.verdict?.state ?? 'read-failed';
439
+ const usable = !wedge.UNUSABLE.has(state);
440
+ const degraded = usable && state !== 'healthy';
441
+ emit(flags, { ok: usable, device: dev.udid, steps, verdict: diag?.verdict ?? null }, usable
442
+ ? (degraded
443
+ ? `\n${dev.name} is back and usable, but ${state}:\n ${diag.verdict.detail}`
444
+ + '\nNot a failure — it taps and reads.'
445
+ : `\n${dev.name} is healthy again — ${diag.verdict.detail}`)
446
+ : `\n${dev.name} came back ${state}, which is not usable.`
447
+ + `\n ${diag?.verdict?.detail ?? 'nothing could be read from it'}`
448
+ + '\nA second revive sometimes clears it. If it does not, this is past what simframe'
449
+ + ' can do — check Simulator.app is not showing an error, and see docs/DEFERRED.md'
450
+ + ' items 95 and 183.');
451
+ if (!usable) process.exitCode = 1;
449
452
  return;
450
453
  }
451
454
 
@@ -461,7 +464,23 @@ async function main() {
461
464
  '',
462
465
  ` frame seq ${r.frame?.seq ?? '-'}, ${r.frame?.ageMs ?? '-'}ms old, still for ${r.frame?.stableForMs ?? '-'}ms, ${r.frame?.size ?? '-'}`,
463
466
  ` elements ${r.elements.total} total — ${r.elements.ax} by tree, ${r.elements.ocr} by OCR, ${r.elements.fused} by both`,
464
- ` fusion ${r.agreement ?? 'n/a'} of elements seen by both sensors (measured healthy ${wedge.HEALTHY_FUSION}; at or below ${wedge.DISAGREEMENT} the frame is stale)`,
467
+ // **No band here, and that is the second version of this fix.**
468
+ //
469
+ // A reporter saw `healthy` printed beside "measured healthy 0.66-0.93"
470
+ // over a reading of 0.567 and asked, reasonably, which to believe. The
471
+ // first fix widened the band to 0.57-0.93 — and the very next device
472
+ // read returned **0.471** on an ordinary Settings screen, which is the
473
+ // same contradiction one decimal place down. Any fixed band will be
474
+ // contradicted by the next screen, because fusion tracks how much of a
475
+ // screen's content is OCR-only and that is a property of the app.
476
+ //
477
+ // So the line prints the number and the one threshold the verdict
478
+ // actually turns on. The observed range lives in `docs/BENCHMARKS.md`,
479
+ // where it is evidence about screens rather than a standard a device is
480
+ // being held to. `HEALTHY_FUSION` is still printed by the `stale-frame`
481
+ // verdict, where a reading of 0.03 genuinely wants the contrast.
482
+ ` fusion ${r.agreement ?? 'n/a'} of elements seen by both sensors`
483
+ + ` — stale at or below ${wedge.DISAGREEMENT}, which is what this verdict turns on`,
465
484
  ` frontmost ${r.frontmost?.pid ?? 'unknown'}${r.frontmost?.title ? ` (${r.frontmost.title})` : ''}`,
466
485
  ...(r.verdict.revive ? ['', ' `simframe revive` is the recovery. Keep this output — item 173 needs it.'] : []),
467
486
  ]);
@@ -49,6 +49,18 @@ export const DEVICE_STATE = [
49
49
  /never came to the front within \d+ms/i,
50
50
  'a launched app never came to the front (the launch said so itself, by pid)',
51
51
  ],
52
+ // simctl itself stopped answering, and said so in simframe's own words: the
53
+ // process was killed at the timeout rather than refusing the request.
54
+ //
55
+ // Three runs of the 2026-09-17 bench died this way — 95.6 s each — and every
56
+ // one of them was written to `flows.jsonl` with `device_cause: null`, so the
57
+ // number CI gates on counted a simulator that had stopped answering as the
58
+ // code getting things wrong. That is the same fault item 173 recorded as
59
+ // fixed, still open for this signature because nothing here matched it.
60
+ [
61
+ /did not return within \d+s \(killed by simframe/i,
62
+ 'simctl stopped answering and had to be killed at the timeout',
63
+ ],
52
64
  // Seen on the v0.14.3 bench run: `could not launch com.apple.Preferences:
53
65
  // The system shell (SpringBoard:36454) probably crashed.` The guest's window
54
66
  // server going down is the device, not the check, and nothing here matched it.
@@ -62,3 +74,28 @@ export const DEVICE_STATE = [
62
74
  export function deviceCause(text) {
63
75
  return DEVICE_STATE.find(([re]) => re.test(String(text ?? '')))?.[1] ?? null;
64
76
  }
77
+
78
+ /**
79
+ * The one device failure that is known to heal by itself.
80
+ *
81
+ * The guest's window server dies, the launch that was in flight fails, and
82
+ * SpringBoard comes back a few seconds later. Item 173 spent a dozen
83
+ * occurrences unable to observe it for exactly that reason — by the time
84
+ * anything looked, `diagnose` said `healthy`, fusion 0.857.
85
+ *
86
+ * Measured on `326464A4` on 2026-09-18, six occurrences in one run: waiting and
87
+ * launching again recovered **6 of 6**, in 5.7-7.9 s (median ~6.3 s). The
88
+ * remedy in use until now was `simframe revive`, a ~40 s device restart — six
89
+ * times the cost, for a fault that was already over.
90
+ *
91
+ * Separate from `deviceCause` on purpose. Every entry there says "this is the
92
+ * device"; this one says "and it will be back". `simctl did not return within
93
+ * 90s` is the device too and is deliberately NOT here: it was never once
94
+ * observed to recover, and a retry costs another 90 s to find that out.
95
+ */
96
+ export const SHELL_CRASH = /system shell \(SpringBoard[^)]*\) probably crashed/i;
97
+
98
+ /** Did this failure come from the guest shell dying under us? */
99
+ export function shellCrashed(text) {
100
+ return SHELL_CRASH.test(String(text ?? ''));
101
+ }