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.
Files changed (47) hide show
  1. package/README.md +334 -85
  2. package/native/simframed/Package.swift +16 -0
  3. package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
  4. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +523 -0
  5. package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
  6. package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
  7. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +149 -0
  8. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +112 -0
  9. package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
  10. package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
  11. package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
  12. package/native/simframed/Sources/SimframeCore/Element.swift +148 -0
  13. package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
  14. package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
  15. package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
  16. package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
  17. package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
  18. package/native/simframed/Sources/simframed/main.swift +485 -0
  19. package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +270 -0
  20. package/package.json +12 -4
  21. package/scripts/bench-flow.mjs +54 -0
  22. package/scripts/bench.sh +98 -0
  23. package/scripts/check-package.mjs +99 -0
  24. package/scripts/ci-memory.mjs +416 -0
  25. package/scripts/eval-fingerprint.mjs +192 -0
  26. package/scripts/smoke.mjs +76 -0
  27. package/scripts/sync-server-version.mjs +39 -0
  28. package/scripts/verify-baseline.mjs +65 -0
  29. package/skills/simframe/SKILL.md +173 -0
  30. package/src/actions.js +264 -18
  31. package/src/cli.js +561 -89
  32. package/src/control.js +77 -0
  33. package/src/daemon.js +8 -1
  34. package/src/engine.js +99 -0
  35. package/src/fingerprint.js +183 -0
  36. package/src/graph.js +411 -0
  37. package/src/index.js +351 -24
  38. package/src/input.js +179 -2
  39. package/src/matching.js +265 -0
  40. package/src/mcp.js +425 -112
  41. package/src/navigate.js +120 -0
  42. package/src/refs.js +141 -0
  43. package/src/regions.js +267 -0
  44. package/src/screenmap.js +119 -22
  45. package/src/simctl.js +74 -5
  46. package/src/store.js +8 -0
  47. package/src/view.js +342 -0
package/src/actions.js CHANGED
@@ -3,16 +3,28 @@
3
3
  // Waiting uses a baseline captured BEFORE each action, which is the whole
4
4
  // reason these scripts are reliable rather than racy.
5
5
  import * as api from './index.js';
6
+ import * as graph from './graph.js';
6
7
  import * as input from './input.js';
7
8
  import * as intent from './intent.js';
8
- import { launchApp, openUrl, setPasteboard, terminateApp } from './simctl.js';
9
+ import { launchApp, openUrl, setPasteboard, setPermission, terminateApp } from './simctl.js';
9
10
 
10
11
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
11
12
  const MAX_PAUSE_MS = 5000;
13
+ /** How long a text field needs after being tapped before it holds the keyboard focus. */
14
+ const FOCUS_SETTLE_MS = 150;
15
+ const POLL_MS = 250;
16
+ /** A list that has not produced the target in this many screens does not contain it. */
17
+ const MAX_SCROLLS = 20;
12
18
 
19
+ /**
20
+ * Steps that change the device. Only these get a settle wait and a verified
21
+ * edge in the graph — asserting something is on screen does not move it.
22
+ * `scrollTo` is here because it scrolls; `permission` because a granted
23
+ * permission can change what the app shows.
24
+ */
13
25
  const ACTION_STEPS = new Set([
14
- 'tap', 'tapAt', 'type', 'paste', 'swipe', 'scroll', 'button', 'key',
15
- 'launch', 'terminate', 'openUrl', 'confirm', 'chooseAny',
26
+ 'tap', 'tapAt', 'type', 'paste', 'swipe', 'scroll', 'scrollTo', 'button', 'key',
27
+ 'launch', 'terminate', 'openUrl', 'confirm', 'chooseAny', 'permission',
16
28
  ]);
17
29
 
18
30
  /** Accept both `{tap: "Save"}` shorthand and `{action: "tap", target: "Save"}`. */
@@ -24,12 +36,63 @@ export function normalizeStep(raw) {
24
36
  // Siblings like timeoutMs sit alongside the shorthand key and must survive.
25
37
  const { [key]: value, ...rest } = raw;
26
38
  const inline = value && typeof value === 'object' && !Array.isArray(value) ? value : { value };
27
- return { ...rest, ...inline, action: key };
39
+ const step = { ...rest, ...inline, action: key };
40
+ // Drop keys that are present but undefined. `simframe tap X` used to pass
41
+ // `index: undefined`, which survived here and then crashed the signature
42
+ // builder before the tap was ever sent.
43
+ for (const k of Object.keys(step)) if (step[k] === undefined) delete step[k];
44
+ return step;
45
+ }
46
+
47
+ /**
48
+ * Does this verdict mean the flow went somewhere nobody intended?
49
+ *
50
+ * Only an unexpected *screen* does. `unexpected-transition` is not a verdict at
51
+ * all any more — a noisy classifier disagreeing about whether a tab switch was
52
+ * a push or a pop is not a reason to call a correct navigation wrong, and a
53
+ * verdict that cries wolf trains you to ignore verdicts.
54
+ */
55
+ export function wrongTurnFrom(verification) {
56
+ return verification?.verdict === 'unexpected-screen';
57
+ }
58
+
59
+ /**
60
+ * What a halted step does to the run as a whole.
61
+ *
62
+ * Both halves matter, and only one of them used to happen: the step is marked
63
+ * failed, AND so is the run. Without the second, `ok` meant no more than
64
+ * "nothing threw", so a flow stopped dead at step 0 by a wrong turn reported
65
+ * "flow completed" with no error — the exact shape of failure the verdict
66
+ * exists to make loud.
67
+ */
68
+ export function haltDecision({ verification, stopOnUnexpected = true, continueOnError = false } = {}) {
69
+ if (!wrongTurnFrom(verification) || !stopOnUnexpected || continueOnError) {
70
+ return { halt: false, failRun: false, error: null };
71
+ }
72
+ return { halt: true, failRun: true, error: `${verification.verdict}: ${verification.detail}` };
28
73
  }
29
74
 
30
75
  export async function runScript(
31
76
  deviceQuery,
32
- { steps, autoSettle = true, stableMs = 500, timeoutMs = 8000, continueOnError = false, options } = {},
77
+ {
78
+ steps,
79
+ autoSettle = true,
80
+ stableMs = 500,
81
+ timeoutMs = 8000,
82
+ confirmNovel = true,
83
+ continueOnError = false,
84
+ // Check each action against what it did last time, and remember what it
85
+ // does this time. On by default: a flow that cannot tell a wrong turn from
86
+ // a right one is worse than no flow.
87
+ verify = true,
88
+ // Stop when a verified step lands somewhere it should not have. A flow
89
+ // continuing past a wrong turn taps controls on a screen nobody intended.
90
+ stopOnUnexpected = true,
91
+ // Rebuild the HID session and retry once when a hardware button provably
92
+ // did nothing. Off only for a caller deliberately testing that path.
93
+ recoverInput = true,
94
+ options,
95
+ } = {},
33
96
  ) {
34
97
  if (!Array.isArray(steps) || !steps.length) throw new Error('a script needs at least one step');
35
98
  const { device } = await api.ensureDaemon(deviceQuery, options);
@@ -38,7 +101,11 @@ export async function runScript(
38
101
 
39
102
  const needsInput = steps.some((s) => ACTION_STEPS.has(normalizeStep(s).action));
40
103
  if (needsInput) {
41
- const driver = await input.detectDriver();
104
+ // driverFor, not detectDriver: detectDriver asks specifically whether idb
105
+ // is installed, so every batch flow demanded idb even on a machine where
106
+ // the daemon was doing the input perfectly well. Single-step `simframe tap`
107
+ // already went through driverFor, so `tap` worked and `do` did not.
108
+ const driver = await input.driverFor(udid);
42
109
  if (!driver.available) throw new Error(driver.reason);
43
110
  }
44
111
 
@@ -48,16 +115,37 @@ export async function runScript(
48
115
  const results = [];
49
116
  const frames = [];
50
117
  let failed = false;
118
+ let carriedScreen = null;
119
+ // The last reading of where we ended up, confirmed or not. The compact map
120
+ // the caller returns to Claude is rendered from this, so describing the end
121
+ // state costs nothing beyond the verification pass the flow already ran.
122
+ let endScreen = null;
123
+ // At most one recovery per run. Pressing home while already on the springboard
124
+ // moves nothing and is not a failure, so an unbounded retry would rebuild the
125
+ // session and press again on every such step for no reason.
126
+ let inputRecovered = false;
51
127
 
52
128
  for (const [i, raw] of steps.entries()) {
53
129
  const step = normalizeStep(raw);
54
130
  const stepStart = Date.now();
55
131
  // The baseline for "did the screen react" must predate the action itself.
56
- const before = (await api.getState(deviceQuery, { options })).state.hash;
132
+ const beforeState = (await api.getState(deviceQuery, { options })).state;
133
+ const before = beforeState.hash;
134
+ // Identity is structural: a list with new rows is the same screen, and the
135
+ // pixel hash cannot say so.
136
+ // The screen this step starts on is the screen the last one ended on —
137
+ // nothing happens in between. Recomputing it cost a full perception pass
138
+ // per step for an answer already in hand.
139
+ const beforeScreen = verify
140
+ ? (carriedScreen ?? await api.screenIdentity(deviceQuery, { options, settleMs: stableMs, timeoutMs, confirmNovel }))
141
+ : null;
142
+ carriedScreen = null;
143
+ // What this action did last time it was taken here, if ever.
144
+ const prediction = verify && beforeScreen?.hash ? graph.predict(udid, beforeScreen, step) : null;
57
145
  try {
58
- const detail = await runStep(deviceQuery, udid, step, { screen, options, frames });
59
- let settled = null;
60
- if (autoSettle && ACTION_STEPS.has(step.action)) {
146
+ let detail = await runStep(deviceQuery, udid, step, { screen, options, frames });
147
+ const settleFor = async () => {
148
+ if (!autoSettle || !ACTION_STEPS.has(step.action)) return null;
61
149
  const w = await api.waitFor(deviceQuery, {
62
150
  mode: 'settle',
63
151
  since: before,
@@ -65,23 +153,82 @@ export async function runScript(
65
153
  timeoutMs: step.timeoutMs ?? timeoutMs,
66
154
  options,
67
155
  });
68
- settled = {
156
+ return {
69
157
  ok: w.satisfied,
70
158
  waitedMs: w.waitedMs,
71
159
  sawChange: w.sawChange,
72
160
  stalled: Boolean(w.stalled),
73
161
  noVisibleChange: Boolean(w.noVisibleChange),
74
162
  };
163
+ };
164
+ let settled = await settleFor();
165
+
166
+ // A hardware button that moved nothing did not arrive.
167
+ //
168
+ // Input is the one path with no feedback, so a dispatched Indigo message
169
+ // reports success whether or not the device acted on it — measured, a
170
+ // long-running daemon returned `press in 66ms` with the screen frozen,
171
+ // and the same press worked on a fresh daemon. The frames are the only
172
+ // witness, and by here we have them.
173
+ //
174
+ // Only buttons, and only on no visible change. Home and lock always move
175
+ // the screen, so nothing moving is unambiguous; a tap that changes
176
+ // nothing is ordinary, and retrying one could act twice. Retrying an
177
+ // action that provably did nothing is not a repeat — it is the first
178
+ // attempt that counts.
179
+ if (recoverInput && !inputRecovered && step.action === 'button' && settled?.noVisibleChange) {
180
+ inputRecovered = true;
181
+ const reset = await input.resetSession(udid);
182
+ if (reset) {
183
+ detail += ' [input was not being delivered; HID session reset and retried]';
184
+ await runStep(deviceQuery, udid, step, { screen, options, frames });
185
+ settled = await settleFor();
186
+ }
187
+ }
188
+ // Verify against what was predicted, and remember what actually
189
+ // happened. Without this a step that moved the screen the wrong way
190
+ // reports success, and the flow carries on believing it worked.
191
+ let verification = null;
192
+ if (verify && ACTION_STEPS.has(step.action) && beforeScreen?.hash) {
193
+ const afterState = (await api.getState(deviceQuery, { options })).state;
194
+ const kind = afterState.transition?.kind;
195
+ const afterScreen = await api.screenIdentity(deviceQuery, { options, settleMs: stableMs, timeoutMs, confirmNovel });
196
+ verification = {
197
+ ...graph.verdict({ udid, prediction, before: beforeScreen.hash, after: afterScreen.hash, kind }),
198
+ predicted: prediction ? { to: prediction.to.slice(0, 10), kind: prediction.kind, seen: prediction.count } : null,
199
+ observed: { to: afterScreen.hash?.slice(0, 10), kind },
200
+ };
201
+ // Only remember what was seen on a settled screen: an edge recorded
202
+ // mid-transition points at a screen that never really existed.
203
+ // `confirmed` already means the fingerprint held still across two
204
+ // independent readings, which is the thing `settled` was standing in
205
+ // for. Requiring both meant a screen that settled slowly recorded
206
+ // nothing at all.
207
+ endScreen = afterScreen;
208
+ if (afterScreen.confirmed && afterScreen.hash) {
209
+ graph.record(udid, { from: beforeScreen, action: step, to: afterScreen, kind });
210
+ carriedScreen = afterScreen;
211
+ }
75
212
  }
213
+
214
+ const wrongTurn = wrongTurnFrom(verification);
76
215
  const note = settled?.noVisibleChange ? ' [no visible change]' : '';
77
216
  results.push({
78
217
  index: i,
79
218
  action: step.action,
219
+ verification,
80
220
  ok: true,
81
221
  ms: Date.now() - stepStart,
82
- detail: `${detail}${note}`,
222
+ detail: `${detail}${note}${wrongTurn ? ` [${verification.verdict}: ${verification.detail}]` : ''}`,
83
223
  settled,
84
224
  });
225
+ const halt = haltDecision({ verification, stopOnUnexpected, continueOnError });
226
+ if (halt.halt) {
227
+ results[results.length - 1].ok = false;
228
+ results[results.length - 1].error = halt.error;
229
+ failed = halt.failRun;
230
+ break;
231
+ }
85
232
  } catch (err) {
86
233
  results.push({ index: i, action: step.action, ok: false, ms: Date.now() - stepStart, error: err.message });
87
234
  failed = true;
@@ -91,6 +238,10 @@ export async function runScript(
91
238
 
92
239
  return {
93
240
  device,
241
+ // Returned so a run that verified end to end can be handed straight to
242
+ // navigate.saveFlow without the caller reassembling what it just ran.
243
+ steps,
244
+ endScreen,
94
245
  results,
95
246
  ok: !failed,
96
247
  totalMs: Date.now() - startedAt,
@@ -126,10 +277,14 @@ async function runStep(deviceQuery, udid, step, ctx) {
126
277
  }
127
278
  case 'type': {
128
279
  if (step.into) {
129
- const { node } = await input.tapLabel(udid, step.into, { index: step.index });
130
- await sleep(150);
280
+ // locate, not tapLabel: tapLabel asks the accessibility tree directly,
281
+ // so a field that only OCR can see was untypeable, and a selector
282
+ // (`#4`, `@x,y`) meant nothing here.
283
+ const found = await api.locate(deviceQuery, step.into, { index: step.index, refresh: step.refresh });
284
+ await input.tapPoint(udid, found.target.x, found.target.y);
285
+ await sleep(FOCUS_SETTLE_MS);
131
286
  await input.typeText(udid, step.text ?? step.value);
132
- return `typed into "${node.label ?? step.into}"`;
287
+ return `typed into "${found.target.label}" at ${found.target.x},${found.target.y}`;
133
288
  }
134
289
  await input.typeText(udid, step.text ?? step.value);
135
290
  return 'typed text';
@@ -168,9 +323,15 @@ async function runStep(deviceQuery, udid, step, ctx) {
168
323
  case 'key':
169
324
  await input.pressKey(udid, step.value ?? step.code);
170
325
  return `pressed key ${step.value ?? step.code}`;
171
- case 'launch':
172
- await launchApp(udid, step.value ?? step.bundleId);
173
- return `launched ${step.value ?? step.bundleId}`;
326
+ case 'launch': {
327
+ const bundleId = step.value ?? step.bundleId;
328
+ await launchApp(udid, bundleId, {
329
+ args: step.args ?? [],
330
+ env: step.env ?? {},
331
+ terminateFirst: step.relaunch === true,
332
+ });
333
+ return `launched ${bundleId}${step.relaunch ? ' (relaunched)' : ''}`;
334
+ }
174
335
  case 'terminate':
175
336
  await terminateApp(udid, step.value ?? step.bundleId);
176
337
  return `terminated ${step.value ?? step.bundleId}`;
@@ -228,6 +389,91 @@ async function runStep(deviceQuery, udid, step, ctx) {
228
389
  }
229
390
  throw new Error(`"${target}" is still on screen`);
230
391
  }
392
+ // Bring something into view. A control that scrolled off the bottom of a
393
+ // list is not missing, and "not on this screen" is the wrong answer to give
394
+ // about it.
395
+ case 'scrollTo': {
396
+ const query = step.value ?? step.target ?? step.label;
397
+ const dir = String(step.direction ?? 'down').toLowerCase();
398
+ const max = Math.min(MAX_SCROLLS, step.maxScrolls ?? 6);
399
+ for (let i = 0; i <= max; i += 1) {
400
+ try {
401
+ const found = await api.locate(deviceQuery, query, { index: step.index, refresh: i > 0 });
402
+ return `"${found.target.label}" is in view at ${found.target.x},${found.target.y}` +
403
+ (i ? ` after ${i} scroll${i === 1 ? '' : 's'}` : ' already');
404
+ } catch (err) {
405
+ if (i === max) throw new Error(`scrolled ${dir} ${max}x without finding ${query}: ${err.message}`);
406
+ }
407
+ await runStep(deviceQuery, udid, { action: 'scroll', value: dir }, ctx);
408
+ await api.waitFor(deviceQuery, { mode: 'stable', stableMs: 250, timeoutMs: 2500, options: ctx.options });
409
+ }
410
+ throw new Error(`could not bring ${query} into view`);
411
+ }
412
+
413
+ // Wait for a selector rather than a label, so it works on screens the
414
+ // accessibility tree never described.
415
+ case 'waitFor': {
416
+ const query = step.value ?? step.target ?? step.text;
417
+ const limit = Date.now() + (step.timeoutMs ?? 8000);
418
+ let lastError = 'never appeared';
419
+ for (let attempt = 0; ; attempt += 1) {
420
+ try {
421
+ const found = await api.locate(deviceQuery, query, { index: step.index, refresh: attempt > 0 });
422
+ return `"${found.target.label}" appeared at ${found.target.x},${found.target.y}`;
423
+ } catch (err) {
424
+ lastError = err.message;
425
+ }
426
+ if (Date.now() >= limit) break;
427
+ await sleep(POLL_MS);
428
+ }
429
+ throw new Error(`waited ${step.timeoutMs ?? 8000}ms for ${query}: ${lastError}`);
430
+ }
431
+
432
+ // One assert step for every condition, because `assertText` could only ask
433
+ // one question and the interesting ones are about state: is Save enabled
434
+ // yet, does the field hold what was typed into it.
435
+ case 'assert': {
436
+ const query = step.value ?? step.target ?? step.text;
437
+ const want = String(step.is ?? (step.gone ? 'gone' : 'visible')).toLowerCase();
438
+ let found = null;
439
+ try {
440
+ found = await api.locate(deviceQuery, query, { index: step.index, refresh: step.refresh });
441
+ } catch (err) {
442
+ if (want === 'gone') return `${query} is gone`;
443
+ throw new Error(`${query}: ${err.message}`);
444
+ }
445
+ const t = found.target;
446
+ switch (want) {
447
+ case 'visible':
448
+ return `${query} is on screen at ${t.x},${t.y}`;
449
+ case 'gone':
450
+ throw new Error(`${query} is still on screen at ${t.x},${t.y}`);
451
+ case 'enabled':
452
+ if (t.enabled === false) throw new Error(`"${t.label}" is disabled`);
453
+ return `"${t.label}" is enabled`;
454
+ case 'disabled':
455
+ if (t.enabled !== false) throw new Error(`"${t.label}" is not disabled`);
456
+ return `"${t.label}" is disabled`;
457
+ case 'value': {
458
+ const expected = String(step.equals ?? step.text ?? '');
459
+ const actual = [t.label, t.value, ...(t.aliases ?? [])].filter(Boolean).join(' ');
460
+ if (!actual.toLowerCase().includes(expected.toLowerCase())) {
461
+ throw new Error(`expected "${expected}" but read "${actual}"`);
462
+ }
463
+ return `"${expected}" is what ${query} reads`;
464
+ }
465
+ default:
466
+ throw new Error(`unknown assert condition "${want}" — visible, gone, enabled, disabled or value`);
467
+ }
468
+ }
469
+
470
+ // Answering a system permission alert is not a test of the app. Setting the
471
+ // permission is.
472
+ case 'permission': {
473
+ const service = step.value ?? step.service;
474
+ return await setPermission(udid, step.grant ?? step.action ?? 'grant', service, step.bundleId);
475
+ }
476
+
231
477
  case 'look': {
232
478
  const frame = await api.getFrame(deviceQuery, { detail: step.detail ?? 'normal', options: ctx.options });
233
479
  ctx.frames.push({ label: step.label ?? `step frame`, png: frame.png });