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/README.md +28 -3
- package/package.json +1 -1
- package/scripts/check-private.mjs +143 -0
- package/scripts/eval-perception.mjs +248 -0
- package/src/actions.js +150 -11
- package/src/analyze.js +70 -0
- package/src/cli.js +83 -6
- package/src/graph.js +104 -4
- package/src/index.js +218 -3
- package/src/input.js +49 -5
- package/src/matching.js +55 -2
- package/src/metrics.js +105 -8
- package/src/navigate.js +10 -7
- package/src/platform/android.js +1 -1
- package/src/screenmap.js +20 -1
- package/src/view.js +61 -0
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
|
package/src/platform/android.js
CHANGED
|
@@ -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.
|
|
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 =
|
|
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];
|