simframe 0.9.0 → 0.10.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/navigate.js CHANGED
@@ -41,12 +41,15 @@ export function stepFor(edge) {
41
41
  * count. The reason mapping lives in metrics.PLAN_REASONS so the five reasons
42
42
  * have one owner.
43
43
  */
44
- function refuse(udid, result, { detail = null } = {}) {
44
+ function refuse(udid, result, { detail = null, flowName = null } = {}) {
45
45
  try {
46
46
  const reason = metrics.PLAN_REASONS[result.reason];
47
47
  if (reason) {
48
48
  metrics.recordEscalation(udid, {
49
49
  reason,
50
+ // A refusal by `goto` is about a destination and one by `flow run` is
51
+ // about a named flow. Either is what a breakdown wants to group by.
52
+ flowName,
50
53
  fingerprint: metrics.fingerprintNow(udid, screenmap),
51
54
  outcome: 'escalated_to_model',
52
55
  detail: detail ?? result.reason,
@@ -70,8 +73,8 @@ export async function goto(deviceQuery, target, { options, ...runOptions } = {})
70
73
  const udid = device.udid;
71
74
 
72
75
  const found = graph.findScreen(udid, target);
73
- if (!found) return refuse(udid, { ok: false, reason: 'unknown-screen', known: knownScreens(udid) }, { detail: `no screen matches "${target}"` });
74
- if (found.ambiguous) return refuse(udid, { ok: false, reason: 'ambiguous', candidates: found.ambiguous }, { detail: `"${target}" fits ${found.ambiguous.length} screens` });
76
+ if (!found) return refuse(udid, { ok: false, reason: 'unknown-screen', known: knownScreens(udid) }, { detail: `no screen matches "${target}"`, flowName: `goto:${target}` });
77
+ if (found.ambiguous) return refuse(udid, { ok: false, reason: 'ambiguous', candidates: found.ambiguous }, { detail: `"${target}" fits ${found.ambiguous.length} screens`, flowName: `goto:${target}` });
75
78
 
76
79
  const here = await api.screenIdentity(udid, {});
77
80
  if (here.hash === found.node.hash) {
@@ -84,12 +87,12 @@ export async function goto(deviceQuery, target, { options, ...runOptions } = {})
84
87
  // `Cannot read properties of null (reading 'slice')` instead of answering.
85
88
  // Not hypothetical on Android, where README's own table puts the launcher at
86
89
  // one token.
87
- if (!here.hash) return refuse(udid, { ok: false, reason: 'no-identity', to: found.name });
90
+ if (!here.hash) return refuse(udid, { ok: false, reason: 'no-identity', to: found.name }, { flowName: `goto:${target}` });
88
91
  const path_ = graph.route(udid, { hash: here.hash, tokens: here.tokens }, found.node.hash);
89
- if (!path_) return refuse(udid, { ok: false, reason: 'no-route', from: here.hash.slice(0, 8), to: found.name });
92
+ if (!path_) return refuse(udid, { ok: false, reason: 'no-route', from: here.hash.slice(0, 8), to: found.name }, { flowName: `goto:${target}` });
90
93
 
91
94
  const steps = path_.map(stepFor);
92
- if (steps.some((s) => !s)) return refuse(udid, { ok: false, reason: 'unreplayable-edge', to: found.name });
95
+ if (steps.some((s) => !s)) return refuse(udid, { ok: false, reason: 'unreplayable-edge', to: found.name }, { flowName: `goto:${target}` });
93
96
 
94
97
  const result = await runScript(udid, { steps, stopOnUnexpected: true, ...runOptions });
95
98
  const arrived = await api.screenIdentity(udid, {});
@@ -148,7 +151,7 @@ export function listFlows(udid) {
148
151
  export async function runFlow(deviceQuery, name, { options, ...runOptions } = {}) {
149
152
  const { device } = await api.ensureDaemon(deviceQuery, options);
150
153
  const flow = loadFlow(device.udid, name);
151
- if (!flow) return refuse(device.udid, { ok: false, reason: 'unknown-flow', known: listFlows(device.udid).map((f) => f.name) }, { detail: `no saved flow "${name}"` });
154
+ if (!flow) return refuse(device.udid, { ok: false, reason: 'unknown-flow', known: listFlows(device.udid).map((f) => f.name) }, { detail: `no saved flow "${name}"`, flowName: name });
152
155
  // A replayed flow knows its own name, so its record can be compared against
153
156
  // a human doing the same thing. `minSteps` comes from the flow definition or
154
157
  // stays null — the step count of a recorded route is not a claim about the
@@ -827,7 +827,7 @@ async function launchApp(udid, bundleId, { args = [], env = {}, terminateFirst =
827
827
  async function terminateApp(udid, bundleId) {
828
828
  // `am` reports failure on stdout and still exits 0 — the same trap launchApp
829
829
  // and openUrl already check for. Without this, terminating a package that is
830
- // not installed answered "terminated com.typo.app".
830
+ // not installed answered "terminated com.example.mistyped".
831
831
  const { stdout, stderr } = await adb(udid, ['shell', 'am', 'force-stop', bundleId]);
832
832
  const error = /^Error:.*$/m.exec(`${stdout}${stderr}`);
833
833
  if (error) {
package/src/screenmap.js CHANGED
@@ -17,7 +17,7 @@ import * as regions from './regions.js';
17
17
  import { informative } from './refs.js';
18
18
  import * as store from './store.js';
19
19
 
20
- const MAP_VERSION = 8; // an OCR reading contained in a labelled ax element merges into it
20
+ const MAP_VERSION = 9; // ax targets carry value, selected and focused
21
21
 
22
22
  function mapDir(udid) {
23
23
  return path.join(store.deviceDir(udid), 'screens');
@@ -185,11 +185,30 @@ export async function build(udid, {
185
185
  if (!n.frame || !n.label || isContainer(n)) continue;
186
186
  targets.push({
187
187
  label: n.label,
188
+ // What the control *contains*, whether it is on, and whether it has
189
+ // focus. All three come off the accessibility tree, the daemon has
190
+ // asked for all three since 0.6.0, and all three were dropped before
191
+ // this — `value` here and the other two one layer up in
192
+ // `normalizeNode` — so nothing above this line ever saw them.
193
+ //
194
+ // The cost was not theoretical. A real session could not verify the
195
+ // contents of a text field at all: they reached a row only as the OCR
196
+ // alias, which made them as old as the map and unauthoritative. An
197
+ // assert failed against a field that did contain the string, the
198
+ // operator retyped, and the field ended up with a doubled value and a
199
+ // validation error.
200
+ //
201
+ // `focused` is worth naming separately: it is a direct answer to "did
202
+ // this field take focus", which the focus wait in actions.js infers
203
+ // from elapsed time because it had nothing better to use.
204
+ value: n.value ?? undefined,
188
205
  x: input.centerOf(n).x,
189
206
  y: input.centerOf(n).y,
190
207
  frame: n.frame,
191
208
  type: n.type,
192
209
  enabled: n.enabled,
210
+ selected: n.selected ?? undefined,
211
+ focused: n.focused ?? undefined,
193
212
  source: 'ax',
194
213
  });
195
214
  }
package/src/view.js CHANGED
@@ -202,9 +202,69 @@ export function rowsFor(entry, { screen, filter, interactive, all = false, limit
202
202
  return { rows: rows.slice(0, limit), truncated: Math.max(0, rows.length - limit), collapsed };
203
203
  }
204
204
 
205
+ /**
206
+ * Say when the rows below were remembered rather than looked at.
207
+ *
208
+ * Screen memory is deliberately keyed on the pixel layout hash, because a list
209
+ * with new rows is the same screen and re-perceiving it per step is the cost
210
+ * Phase 13 exists to remove. That is right for *identity* and wrong for
211
+ * *contents*, and the map made no distinction: a field's text reaches a row as
212
+ * an OCR alias, so a recalled map reports the text the field held when the map
213
+ * was built. Reported from a real session — a picker described the previous
214
+ * sheet's options and did it in 23 ms, which is the giveaway, because 23 ms is
215
+ * not enough time to have looked.
216
+ *
217
+ * This does not fix that. It stops it being invisible, which is the part that
218
+ * cost two wrong conclusions about an app.
219
+ *
220
+ * Only past a second, because a map built by this very call is not a
221
+ * recollection and saying so on every screen is how a real warning gets
222
+ * skimmed.
223
+ */
224
+ export const RECALL_NOTE_FLOOR_MS = 1000;
225
+
226
+ export function recalledNote(identity, now = Date.now()) {
227
+ const at = identity?.entry?.at;
228
+ if (!Number.isFinite(at)) return null;
229
+ const age = now - at;
230
+ if (age < RECALL_NOTE_FLOOR_MS) return null;
231
+ const ago = age < 60_000 ? `${Math.round(age / 1000)}s` : `${Math.round(age / 60_000)}m`;
232
+ return `elements recalled from ${ago} ago — pass refresh for what is there now`;
233
+ }
234
+
235
+ /**
236
+ * What a control *contains*, from the sensor that actually knows.
237
+ *
238
+ * A field's text reached a row only as the OCR alias, which means it was as old
239
+ * as the map and had no authoritative source at all. Reported from a real
240
+ * session: an `assert` on a field's contents failed against a field that did
241
+ * contain the string, the operator retyped, and the field ended up with a
242
+ * doubled value and a validation error. A character counter read `0/1000` in
243
+ * the map and `56/1000` in a screenshot of the same frame.
244
+ *
245
+ * The accessibility tree carries `value` and always has —
246
+ * `input.elementToNode` sets it on every node — and the renderer simply never
247
+ * printed it. Printed as `= <value>` and *alongside* the OCR alias rather than
248
+ * instead of it, so when the two disagree that is visible instead of resolved
249
+ * by whichever one the renderer preferred. Disagreement is the signal.
250
+ *
251
+ * Skipped when the label already says it, which is most switches and rows: iOS
252
+ * labels a settings row "Larger Text, Off" and printing `= Off` after that is
253
+ * noise.
254
+ */
255
+ function valueNote(r) {
256
+ if (r.value == null || r.value === '') return null;
257
+ const v = trim(String(r.value));
258
+ if (!v) return null;
259
+ const said = alnum(r.label);
260
+ if (said && alnum(v) && said.includes(alnum(v))) return null;
261
+ return `= ${v}`;
262
+ }
263
+
205
264
  function renderRow(r) {
206
265
  const name = [
207
266
  trim(r.label) || (matching.isAxTarget(r) ? '(unlabelled)' : '(no text)'),
267
+ valueNote(r),
208
268
  aliasNote(r),
209
269
  ].filter(Boolean).join(' ');
210
270
  const state = [
@@ -320,6 +380,7 @@ export function render({ device, identity, rows, truncated, collapsed, screen, n
320
380
  : 'screen unidentified',
321
381
  identity?.keyboard ? 'keyboard up' : null,
322
382
  identity?.settled === false ? 'STILL MOVING' : null,
383
+ recalledNote(identity),
323
384
  ].filter(Boolean).join(' · ');
324
385
 
325
386
  const lines = [head];