simframe 0.12.1 → 0.13.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 +163 -9
- package/native/supervise.swift +63 -4
- package/package.json +1 -1
- package/scripts/article-md.mjs +185 -0
- package/scripts/ci-integration-local.sh +22 -1
- package/scripts/ci-memory.mjs +98 -15
- package/scripts/collect-rulings.mjs +14 -0
- package/scripts/eval-fingerprint.mjs +48 -3
- package/scripts/replay-rulings.mjs +206 -0
- package/scripts/score-rulings.mjs +19 -1
- package/src/actions.js +158 -18
- package/src/analyze.js +56 -0
- package/src/cli.js +215 -18
- package/src/fingerprint.js +10 -1
- package/src/index.js +337 -12
- package/src/input.js +4 -0
- package/src/mcp.js +7 -1
- package/src/metrics.js +68 -7
- package/src/navigate.js +28 -2
- package/src/ollama.js +232 -0
- package/src/platform/ios.js +30 -5
- package/src/refs.js +12 -1
- package/src/regions.js +54 -0
- package/src/screenmap.js +161 -6
- package/src/store.js +53 -0
- package/src/supervisor.js +108 -5
- package/src/view.js +84 -9
package/src/ollama.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A second supervisor arm, for measuring whether capacity matters.
|
|
3
|
+
*
|
|
4
|
+
* The owner's call, 2026-09-11: *"we can of course do our own test with a
|
|
5
|
+
* chosen model… and decide based on the numbers rather than speculations."*
|
|
6
|
+
* This exists to answer that and nothing else. It is **not** a recommendation,
|
|
7
|
+
* it is off unless asked for by name, and a model used to measure whether
|
|
8
|
+
* capacity matters is not a model we ship — conflating those is how a non-goal
|
|
9
|
+
* erodes.
|
|
10
|
+
*
|
|
11
|
+
* No dependency is added: Node has had a global `fetch` since 18, and Ollama
|
|
12
|
+
* is a process already on the machine, reached over the loopback interface.
|
|
13
|
+
*
|
|
14
|
+
* **The fairness condition, which is the whole reason this file is shaped the
|
|
15
|
+
* way it is.** Most small-model errors are invalid-output faults, and Apple's
|
|
16
|
+
* guided generation eliminates those at the sampling layer — constraints are
|
|
17
|
+
* enforced by logit masking, so a fourth word is unrepresentable rather than
|
|
18
|
+
* rejected afterwards (WWDC25 301; Tech Report §7). An unconstrained challenger
|
|
19
|
+
* would lose on formatting and we would read it as losing on judgement. So this
|
|
20
|
+
* arm passes a JSON schema whose `decision` is an enum of the same three words,
|
|
21
|
+
* and Ollama constrains sampling to it the same way.
|
|
22
|
+
*
|
|
23
|
+
* And the briefing is not a second copy. It is **read out of
|
|
24
|
+
* `native/supervise.swift`**, because two hand-maintained copies of a prompt is
|
|
25
|
+
* two arms answering different questions, and the difference would be invisible
|
|
26
|
+
* in the numbers. See `readBrief`.
|
|
27
|
+
*/
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
const SWIFT = path.join(HERE, '..', 'native', 'supervise.swift');
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_MODEL = 'qwen3:8b';
|
|
36
|
+
export const DEFAULT_HOST = 'http://127.0.0.1:11434';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The Apple arm's own instructions, read from its source.
|
|
40
|
+
*
|
|
41
|
+
* Not duplicated. Two arms of a comparison that are briefed differently are
|
|
42
|
+
* measuring two different things, and nothing in the output would say so — the
|
|
43
|
+
* numbers would simply be wrong and look fine. The Swift file is the canonical
|
|
44
|
+
* copy because it is the shipped one; this parses the `let instructions = """
|
|
45
|
+
* … """` block out of it and un-escapes Swift's trailing-backslash line
|
|
46
|
+
* continuations, which is how that literal is wrapped.
|
|
47
|
+
*
|
|
48
|
+
* Throws rather than falling back to a built-in string. A silent fallback here
|
|
49
|
+
* would produce exactly the invisible unfairness this function exists to
|
|
50
|
+
* prevent.
|
|
51
|
+
*/
|
|
52
|
+
export function readBrief(file = SWIFT, { mayAbstain = false } = {}) {
|
|
53
|
+
const src = fs.readFileSync(file, 'utf8');
|
|
54
|
+
const base = block(src, 'let instructions = """', file);
|
|
55
|
+
if (!mayAbstain) return base;
|
|
56
|
+
// The addendum, from the same file and joined the way the Swift joins it.
|
|
57
|
+
// The fourth word has to reach both arms identically or the comparison is
|
|
58
|
+
// measuring two different briefs, which is the failure this whole function
|
|
59
|
+
// exists to prevent.
|
|
60
|
+
return `${base}\n\n${block(src, 'let abstainInstructions = instructions + "\\n\\n" + """', file)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function block(src, opener, file) {
|
|
64
|
+
const open = src.indexOf(opener);
|
|
65
|
+
if (open === -1) throw new Error(`no ${JSON.stringify(opener)} block in ${file}`);
|
|
66
|
+
const bodyStart = src.indexOf('\n', open) + 1;
|
|
67
|
+
const close = src.indexOf('"""', bodyStart);
|
|
68
|
+
if (close === -1) throw new Error(`unterminated block in ${file}`);
|
|
69
|
+
return src
|
|
70
|
+
.slice(bodyStart, close)
|
|
71
|
+
.split('\n')
|
|
72
|
+
// A Swift multi-line literal strips the closing delimiter's indentation
|
|
73
|
+
// from every line; here that is four spaces.
|
|
74
|
+
.map((l) => l.replace(/^ {4}/, ''))
|
|
75
|
+
.join('\n')
|
|
76
|
+
// Swift's line continuation: a trailing backslash removes the newline and
|
|
77
|
+
// nothing else. Joining with a space instead would insert one after the
|
|
78
|
+
// space the line already ends with, and the two arms would be reading
|
|
79
|
+
// briefs that differ — invisibly, and in the one place this whole file
|
|
80
|
+
// exists to keep identical.
|
|
81
|
+
.replace(/\\\n/g, '')
|
|
82
|
+
.trim();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const clip = (t, n) => {
|
|
86
|
+
const s = String(t ?? '');
|
|
87
|
+
return s.length > n ? `${s.slice(0, n)}…` : s;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The situation, in the words the other arm gets.
|
|
92
|
+
*
|
|
93
|
+
* A line-for-line mirror of `main.swift`'s prompt assembly, including the clip
|
|
94
|
+
* lengths and the 14-label cap on the screen list. The field order matters as
|
|
95
|
+
* much as the content: the brief tells the model to weigh the plan's guidance
|
|
96
|
+
* first, and a prompt that presented it last would be testing a different
|
|
97
|
+
* instruction.
|
|
98
|
+
*/
|
|
99
|
+
export function promptFor(s = {}) {
|
|
100
|
+
let prompt = `Step: ${clip(s.step, 120)}\nIt failed with: ${clip(s.failure, 220)}`;
|
|
101
|
+
if (s.goal) prompt += `\nThe plan's guidance about this app: ${clip(s.goal, 300)}`;
|
|
102
|
+
if (s.expected) prompt += `\nExpected: ${clip(s.expected, 200)}`;
|
|
103
|
+
if (Number.isFinite(s.stillMs)) prompt += `\nThe screen has been still for ${s.stillMs}ms`;
|
|
104
|
+
if (s.note) prompt += `\nPerception note: ${clip(s.note, 160)}`;
|
|
105
|
+
if (s.screen?.length) {
|
|
106
|
+
prompt += `\nOn screen now: ${s.screen.slice(0, 14).map((l) => clip(l, 28)).join(', ')}`;
|
|
107
|
+
}
|
|
108
|
+
return prompt;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The schema. Three words and nothing else, enforced at sampling.
|
|
113
|
+
*
|
|
114
|
+
* Deliberately decision-only, matching `Judgement` in the Swift file: that
|
|
115
|
+
* struct has one field. Asking this arm for a rationale it would then be scored
|
|
116
|
+
* against would be a second difference between the arms, and the rationale is
|
|
117
|
+
* the one thing a supervisor is explicitly not trusted for.
|
|
118
|
+
*/
|
|
119
|
+
export const DECISION_WORDS = ['wait', 'retry', 'stop'];
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The fourth word, available on request.
|
|
123
|
+
*
|
|
124
|
+
* Not a new capability and it cannot become one: `abstain` means "behave as if
|
|
125
|
+
* there is no supervisor", which is the `null` every caller already handles on
|
|
126
|
+
* every failure path. It strictly *shrinks* what the model can cause to happen,
|
|
127
|
+
* which is why a component whose answer space is its safety property can afford
|
|
128
|
+
* to grow one.
|
|
129
|
+
*/
|
|
130
|
+
export const ABSTAIN = 'abstain';
|
|
131
|
+
|
|
132
|
+
export const schemaFor = ({ mayAbstain = false } = {}) => ({
|
|
133
|
+
type: 'object',
|
|
134
|
+
properties: {
|
|
135
|
+
decision: { type: 'string', enum: mayAbstain ? [...DECISION_WORDS, ABSTAIN] : DECISION_WORDS },
|
|
136
|
+
},
|
|
137
|
+
required: ['decision'],
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
export const SCHEMA = schemaFor();
|
|
141
|
+
|
|
142
|
+
/** Parse `ollama`, `ollama:qwen3:14b`, `ollama:qwen3:8b@http://host:port`. */
|
|
143
|
+
export function parseTarget(raw) {
|
|
144
|
+
const rest = String(raw ?? '').replace(/^ollama:?/, '');
|
|
145
|
+
const [model, host] = rest.split('@');
|
|
146
|
+
return { model: model || DEFAULT_MODEL, host: host || DEFAULT_HOST };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function post(host, route, body, timeoutMs) {
|
|
150
|
+
const control = new AbortController();
|
|
151
|
+
const timer = setTimeout(() => control.abort(), timeoutMs);
|
|
152
|
+
try {
|
|
153
|
+
const res = await fetch(`${host}${route}`, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: { 'content-type': 'application/json' },
|
|
156
|
+
body: JSON.stringify(body),
|
|
157
|
+
signal: control.signal,
|
|
158
|
+
});
|
|
159
|
+
if (!res.ok) return { kind: 'http', error: `${res.status} ${await res.text().catch(() => '')}`.slice(0, 200) };
|
|
160
|
+
return { json: await res.json() };
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return { kind: err.name === 'AbortError' ? 'timeout' : 'unreachable', error: String(err.message).slice(0, 200) };
|
|
163
|
+
} finally {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One judgement. Same shape as the Apple helper's answer, so `judge` in
|
|
170
|
+
* `supervisor.js` cannot tell which arm it asked — including the failure
|
|
171
|
+
* shapes, because "the supervisor did not answer" covering a timeout, a
|
|
172
|
+
* refusal and a model that was never installed is a bug this project has
|
|
173
|
+
* already paid for once.
|
|
174
|
+
*/
|
|
175
|
+
export async function ask(target, situation, timeoutMs = 2500) {
|
|
176
|
+
const { model, host } = target;
|
|
177
|
+
const mayAbstain = Boolean(situation?.mayAbstain);
|
|
178
|
+
const started = Date.now();
|
|
179
|
+
const out = await post(host, '/api/chat', {
|
|
180
|
+
model,
|
|
181
|
+
messages: [
|
|
182
|
+
{ role: 'system', content: readBrief(SWIFT, { mayAbstain }) },
|
|
183
|
+
{ role: 'user', content: promptFor(situation) },
|
|
184
|
+
],
|
|
185
|
+
stream: false,
|
|
186
|
+
format: schemaFor({ mayAbstain }),
|
|
187
|
+
// Qwen3 reasons out loud by default. Turned off for two reasons and both
|
|
188
|
+
// are about fairness rather than speed: the Apple arm does not deliberate
|
|
189
|
+
// either, and a supervisor that takes twenty seconds to answer has already
|
|
190
|
+
// lost the argument it is here to have — it sits in front of a 1.2s budget.
|
|
191
|
+
think: false,
|
|
192
|
+
options: { temperature: 0, num_predict: 32 },
|
|
193
|
+
}, timeoutMs);
|
|
194
|
+
if (!out.json) return { kind: out.kind, error: out.error };
|
|
195
|
+
const ms = Date.now() - started;
|
|
196
|
+
try {
|
|
197
|
+
return { ...JSON.parse(out.json.message?.content ?? ''), ms };
|
|
198
|
+
} catch {
|
|
199
|
+
return { kind: 'unparseable', error: clip(out.json.message?.content, 200), ms };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Load the weights before timing anything.
|
|
205
|
+
*
|
|
206
|
+
* The Apple arm calls `prewarm()` for exactly this reason, and it was worth
|
|
207
|
+
* ~280ms there. Here it is worth minutes: an 8B at 4-bit is 5.2 GB off disk,
|
|
208
|
+
* and the first `doctor` probe aborted at 20s and reported a working model as
|
|
209
|
+
* one that "did not answer" — a cold load reported as a fault. Ollama's
|
|
210
|
+
* documented preload is a chat with no messages.
|
|
211
|
+
*/
|
|
212
|
+
export async function preload(target, timeoutMs = 120_000) {
|
|
213
|
+
const { model, host } = target;
|
|
214
|
+
const started = Date.now();
|
|
215
|
+
const out = await post(host, '/api/chat', { model, messages: [], keep_alive: '10m' }, timeoutMs);
|
|
216
|
+
return out.json ? { ok: true, ms: Date.now() - started } : { ok: false, ...out };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** For `doctor`: is this arm actually there, and does it answer? */
|
|
220
|
+
export async function status(target, timeoutMs = 20_000) {
|
|
221
|
+
const { model, host } = target;
|
|
222
|
+
const tags = await post(host, '/api/show', { model }, 4000);
|
|
223
|
+
if (!tags.json) {
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
reason: tags.kind === 'unreachable'
|
|
227
|
+
? `no Ollama server at ${host} (start it, or pass a host with ollama:<model>@<url>)`
|
|
228
|
+
: `Ollama has no model "${model}" (${tags.error})`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
return { ok: true, model, host, timeoutMs };
|
|
232
|
+
}
|
package/src/platform/ios.js
CHANGED
|
@@ -178,17 +178,42 @@ function isBootedSync(udid) {
|
|
|
178
178
|
return false;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/**
|
|
182
|
+
* What went wrong with a `simctl io screenshot`, in one sentence.
|
|
183
|
+
*
|
|
184
|
+
* Extracted so it can be *tested* rather than reasoned about, for the same
|
|
185
|
+
* reason `pickDevice` and `decisionOf` were: this is the line where a wrong
|
|
186
|
+
* answer was expensive, and it was wrong for a day.
|
|
187
|
+
*/
|
|
188
|
+
export function screenshotFailure(err) {
|
|
189
|
+
const killed = err.killed || err.signal === 'SIGTERM';
|
|
190
|
+
// `simctl` opens with `Note: No display specified …` on every run, success or
|
|
191
|
+
// failure. When the display surface is dead the command does not fail, it
|
|
192
|
+
// *hangs* — so at kill time that Note is the only thing on stderr, and the
|
|
193
|
+
// tool reported a benign informational line as the reason a capture failed.
|
|
194
|
+
// That is how this wedge stayed nameless through five CI failures.
|
|
195
|
+
const lines = String(err.stderr || '').trim().split('\n').map((l) => l.trim()).filter(Boolean);
|
|
196
|
+
const real = lines.filter((l) => !/^Note:/i.test(l)).pop();
|
|
197
|
+
if (killed && !real) {
|
|
198
|
+
// Run to completion the device names it exactly:
|
|
199
|
+
// NSPOSIXErrorDomain code 60 — Timeout waiting for screen surfaces
|
|
200
|
+
// which is CoreSimulator saying the surface is gone, and the closest thing
|
|
201
|
+
// to a positive test for the wedge that exists.
|
|
202
|
+
return 'simctl screenshot did not return within 10s. The display surface is not answering'
|
|
203
|
+
+ ' — run to completion it reports "Timeout waiting for screen surfaces" (NSPOSIXErrorDomain 60).'
|
|
204
|
+
+ ' This is the device, not the capture loop: `simframe revive` restarts it.';
|
|
205
|
+
}
|
|
206
|
+
const detail = real ?? lines.pop();
|
|
207
|
+
return detail ? `simctl screenshot failed: ${detail}` : `simctl screenshot failed: ${err.message}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
181
210
|
async function screenshot(udid, outFile, { mask = 'ignored' } = {}) {
|
|
182
211
|
try {
|
|
183
212
|
await run('xcrun', ['simctl', 'io', udid, 'screenshot', '--type=png', `--mask=${mask}`, outFile], {
|
|
184
213
|
timeout: 10_000,
|
|
185
214
|
});
|
|
186
215
|
} catch (err) {
|
|
187
|
-
|
|
188
|
-
// whole command>" and simctl's actual complaint is in stderr. A CI failure
|
|
189
|
-
// here reported the command and nothing about why it did not work.
|
|
190
|
-
const detail = (err.stderr || '').trim().split('\n').filter(Boolean).pop();
|
|
191
|
-
throw new Error(detail ? `simctl screenshot failed: ${detail}` : `simctl screenshot failed: ${err.message}`);
|
|
216
|
+
throw new Error(screenshotFailure(err));
|
|
192
217
|
}
|
|
193
218
|
}
|
|
194
219
|
|
package/src/refs.js
CHANGED
|
@@ -149,7 +149,18 @@ export function resolveRef(udid, n, { structuralHash, layoutHash, screenKnown, s
|
|
|
149
149
|
// numbers. Refusing costs a re-read; guessing taps whatever is at those
|
|
150
150
|
// coordinates now.
|
|
151
151
|
if (screenKnown === false) {
|
|
152
|
-
|
|
152
|
+
// Flagged, like every other refusal in this function, and it was the one
|
|
153
|
+
// that was not.
|
|
154
|
+
//
|
|
155
|
+
// We tell callers to read `staleRef` rather than the sentence — the CI check
|
|
156
|
+
// for this very guard carries a comment saying it matched on prose twice and
|
|
157
|
+
// went red twice, so it reads the contract now. Then it went red a third
|
|
158
|
+
// time, on a refusal that was correct, well worded, and carried no field at
|
|
159
|
+
// all: `#1 cannot be trusted here — simframe does not recognise this
|
|
160
|
+
// screen`, reported by the harness as `no reason`. A refusal a human can
|
|
161
|
+
// read and a program cannot is the same defect as a failure that reads like
|
|
162
|
+
// a success, one level down.
|
|
163
|
+
throw staleError('simframe does not recognise this screen', 'unknown-screen');
|
|
153
164
|
}
|
|
154
165
|
// The pixel check stays, but only as a backstop, and only where it means
|
|
155
166
|
// something. A dark or near-uniform screen produces a layout hash of almost
|
package/src/regions.js
CHANGED
|
@@ -470,6 +470,60 @@ export function offViewport(t, screen) {
|
|
|
470
470
|
return false;
|
|
471
471
|
}
|
|
472
472
|
|
|
473
|
+
/**
|
|
474
|
+
* The smallest sliver of an element that is worth offering as a tap target.
|
|
475
|
+
*
|
|
476
|
+
* Apple's own minimum touch target is 44pt; this is deliberately smaller,
|
|
477
|
+
* because the question here is not "is this comfortable to tap" but "is this a
|
|
478
|
+
* real control a person can see and reach". A filter chip showing 29pt of
|
|
479
|
+
* itself at the edge of a horizontal strip is both. Below this, what is on
|
|
480
|
+
* screen is bleed rather than a control.
|
|
481
|
+
*/
|
|
482
|
+
export const MIN_VISIBLE_PT = 24;
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* How much of an element is actually on screen, and where to tap what is.
|
|
486
|
+
*
|
|
487
|
+
* `offViewport` answers a yes/no question about the element's *centre*, which
|
|
488
|
+
* is right for "should I scroll to reach this" and wrong for "is this here at
|
|
489
|
+
* all". A chip at the end of a horizontal strip showed **29pt of itself** on a
|
|
490
|
+
* 402pt screen — real, visible, tappable — while its centre sat at x=416, so it
|
|
491
|
+
* was dropped from the map entirely. What the caller got in its place was OCR's
|
|
492
|
+
* reading of the visible sliver: a `text` element labelled **"Flc"** at x=392,
|
|
493
|
+
* which passes every filter because its own box is inside the viewport.
|
|
494
|
+
*
|
|
495
|
+
* So the map did not merely omit a control. It offered a different, meaningless
|
|
496
|
+
* name for it, at a coordinate that looks perfectly ordinary. That is the shape
|
|
497
|
+
* of item 121 — a caller who cannot trust what the map says about the edge of
|
|
498
|
+
* the screen — and the item's own words are "say so or clamp". This does both.
|
|
499
|
+
*/
|
|
500
|
+
export function clipping(t, screen) {
|
|
501
|
+
const f = t?.frame;
|
|
502
|
+
const w = screen?.width;
|
|
503
|
+
const h = screen?.height;
|
|
504
|
+
if (!f || !Number.isFinite(w) || !Number.isFinite(h)) return null;
|
|
505
|
+
const left = Math.max(0, f.x);
|
|
506
|
+
const right = Math.min(w, f.x + f.width);
|
|
507
|
+
const top = Math.max(0, f.y);
|
|
508
|
+
const bottom = Math.min(h, f.y + f.height);
|
|
509
|
+
const visibleWidth = right - left;
|
|
510
|
+
const visibleHeight = bottom - top;
|
|
511
|
+
if (visibleWidth <= 0 || visibleHeight <= 0) {
|
|
512
|
+
return { visibleWidth: 0, visibleHeight: 0, clipped: true, usable: false, point: null };
|
|
513
|
+
}
|
|
514
|
+
const clipped = f.x < 0 || f.y < 0 || f.x + f.width > w || f.y + f.height > h;
|
|
515
|
+
return {
|
|
516
|
+
visibleWidth,
|
|
517
|
+
visibleHeight,
|
|
518
|
+
clipped,
|
|
519
|
+
usable: visibleWidth >= MIN_VISIBLE_PT && visibleHeight >= MIN_VISIBLE_PT,
|
|
520
|
+
// The centre of what can be seen, not the centre of the element. Tapping
|
|
521
|
+
// the latter would aim off the screen, which is the clamp the item asked
|
|
522
|
+
// for and the reason a caller could not simply be handed the real centre.
|
|
523
|
+
point: { x: Math.round((left + right) / 2), y: Math.round((top + bottom) / 2) },
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
473
527
|
/** Annotate a target list with region and nav slot. Mutates and returns it. */
|
|
474
528
|
export function annotate(targets, screen) {
|
|
475
529
|
const band = bands(targets, screen);
|
package/src/screenmap.js
CHANGED
|
@@ -151,6 +151,82 @@ const inside = (point, frame) =>
|
|
|
151
151
|
point.y >= frame.y &&
|
|
152
152
|
point.y <= frame.y + frame.height;
|
|
153
153
|
|
|
154
|
+
const frameArea = (f) => (f ? Math.max(1, f.width) * Math.max(1, f.height) : Infinity);
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* What the map says is at a point — everything containing it, smallest first.
|
|
158
|
+
*
|
|
159
|
+
* Item 120. Six consecutive `swipe [201,750] -> [201,250]` reported
|
|
160
|
+
* `no visible change` while a support banner sat at y≈753 swallowing every
|
|
161
|
+
* gesture. The banner was *in the element list the same call printed*; nothing
|
|
162
|
+
* connected "your swipe started at y=750" to "there is an element at y=753".
|
|
163
|
+
* The geometry was already in hand, so this is arithmetic over data we hold,
|
|
164
|
+
* not a new perception pass.
|
|
165
|
+
*
|
|
166
|
+
* Smallest first is a deliberately weaker claim than z-order. We do not know
|
|
167
|
+
* what is on top — the accessibility tree's order is not a paint order and OCR
|
|
168
|
+
* has none at all — and the honest statement is "these are the elements that
|
|
169
|
+
* cover that point", innermost first because the innermost is the one a
|
|
170
|
+
* gesture most often goes to. Saying "overlay" would be a guess wearing the
|
|
171
|
+
* clothes of a measurement.
|
|
172
|
+
*/
|
|
173
|
+
export function hitTest(entry, point) {
|
|
174
|
+
if (!entry?.targets || !Number.isFinite(point?.x) || !Number.isFinite(point?.y)) return [];
|
|
175
|
+
return entry.targets
|
|
176
|
+
.filter((t) => t.frame && inside(point, t.frame))
|
|
177
|
+
.sort((a, b) => frameArea(a.frame) - frameArea(b.frame));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const describeTarget = (t) => {
|
|
181
|
+
const name = t.label || t.text || (t.type ? `(unlabelled ${t.type})` : '(unlabelled)');
|
|
182
|
+
return t.region ? `"${name}" (${t.region})` : `"${name}"`;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* One line saying what a coordinate resolved to, for a gesture that did
|
|
187
|
+
* nothing visible.
|
|
188
|
+
*
|
|
189
|
+
* Returns `null` when there is no map for the screen, because "we did not
|
|
190
|
+
* look" and "we looked and found nothing" are different answers and only one
|
|
191
|
+
* of them is worth printing.
|
|
192
|
+
*/
|
|
193
|
+
export function describePoint(entry, point, { what = 'the point' } = {}) {
|
|
194
|
+
if (!entry?.targets?.length) return null;
|
|
195
|
+
const at = `${Math.round(point.x)},${Math.round(point.y)}`;
|
|
196
|
+
const hits = hitTest(entry, point);
|
|
197
|
+
if (!hits.length) {
|
|
198
|
+
return `${what} ${at} is not inside any element on the map`
|
|
199
|
+
+ ' — empty space, or a view with no label (nothing can be said about what caught it)';
|
|
200
|
+
}
|
|
201
|
+
// The contention clause only where there is contention. On one hit the
|
|
202
|
+
// element's name *is* the diagnosis and anything after it is noise — and a
|
|
203
|
+
// note that pads every case is how a real one stops being read.
|
|
204
|
+
const others = hits.length > 1
|
|
205
|
+
? `, the smallest of ${hits.length} elements covering it — a gesture goes to whatever is on top there`
|
|
206
|
+
: '';
|
|
207
|
+
return `${what} ${at} is inside ${describeTarget(hits[0])}${others}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The point a step is aimed at, when it is aimed at a coordinate at all.
|
|
212
|
+
*
|
|
213
|
+
* A swipe is captured by whatever sits under where the finger goes *down*, so
|
|
214
|
+
* the start point is the one worth diagnosing; the end point never decides who
|
|
215
|
+
* receives the gesture.
|
|
216
|
+
*/
|
|
217
|
+
export function aimedAt(step) {
|
|
218
|
+
if (!step) return null;
|
|
219
|
+
if (step.action === 'tapAt' && Number.isFinite(step.x) && Number.isFinite(step.y)) {
|
|
220
|
+
return { point: { x: step.x, y: step.y }, what: 'the tap point' };
|
|
221
|
+
}
|
|
222
|
+
if (step.action === 'swipe') {
|
|
223
|
+
const x = step.from?.[0] ?? step.from?.x;
|
|
224
|
+
const y = step.from?.[1] ?? step.from?.y;
|
|
225
|
+
if (Number.isFinite(x) && Number.isFinite(y)) return { point: { x, y }, what: 'the swipe start point' };
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
154
230
|
/**
|
|
155
231
|
* Build the map for the screen currently showing. Accessibility elements are the
|
|
156
232
|
* real hit targets, so they win where they exist; OCR fills in everything the
|
|
@@ -220,15 +296,64 @@ export async function build(udid, {
|
|
|
220
296
|
try {
|
|
221
297
|
// The tree is already in hand when the daemon answered; describeAll would
|
|
222
298
|
// only ask for it a second time.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
299
|
+
//
|
|
300
|
+
// And when the daemon answered *without* a tree, asking again is asking
|
|
301
|
+
// the thing that just failed. That path cost a red CI run and a wrong
|
|
302
|
+
// diagnosis: the daemon's ax read timed out, `describeAll` re-asked it
|
|
303
|
+
// (another 20s), got the same nothing, fell through to idb, and the map
|
|
304
|
+
// reported **"idb is not installed, so simframe can observe the screen but
|
|
305
|
+
// cannot touch it"**. A sentence about a tool this project deliberately
|
|
306
|
+
// does not use on CI, printed because a different tool had a bad read —
|
|
307
|
+
// and it points whoever reads it at installing idb, which would change
|
|
308
|
+
// nothing. The same read worked a minute later.
|
|
309
|
+
//
|
|
310
|
+
// The daemon has sent `axError` since it learned to time its own reads,
|
|
311
|
+
// and nothing here had ever read it. The OCR branch below reads
|
|
312
|
+
// `ocrError`, which is what makes the asymmetry visible: one sensor could
|
|
313
|
+
// say why it failed and the other borrowed a different tool's excuse.
|
|
314
|
+
let nodes;
|
|
315
|
+
if (daemonScreen?.sources?.includes('ax')) {
|
|
316
|
+
nodes = daemonScreen.elements.filter((e) => e.source?.includes('ax')).map(input.elementToNode);
|
|
317
|
+
} else if (daemonScreen) {
|
|
318
|
+
throw new Error(daemonScreen.axError ?? 'the daemon read the screen and the accessibility tree did not answer');
|
|
319
|
+
} else {
|
|
320
|
+
nodes = await input.describeAll(udid);
|
|
321
|
+
}
|
|
226
322
|
|
|
227
323
|
sources.push('ax');
|
|
324
|
+
// A control the tree gives no name to is still a control — item 122.
|
|
325
|
+
//
|
|
326
|
+
// This line used to read `!n.label`, on the reasoning that a row nobody
|
|
327
|
+
// can name is a row nobody can tap. The whole of item 122 is that the
|
|
328
|
+
// opposite is true, and three field reports in a row said so
|
|
329
|
+
// independently: icon-only overflow menus on every card, a bottom-sheet
|
|
330
|
+
// drag handle, a back chevron. Real, tappable, on screen, and absent from
|
|
331
|
+
// the map — so every one of them needed a raw `@x,y` read off a
|
|
332
|
+
// screenshot, which is the exact round trip the text map exists to
|
|
333
|
+
// remove. Knowing that *a hit target is there* is most of the value even
|
|
334
|
+
// with no semantics attached to it.
|
|
335
|
+
//
|
|
336
|
+
// Measured on the testbed before it was written, because the premise
|
|
337
|
+
// could have been false: on the list screen, of 39 accessibility nodes
|
|
338
|
+
// exactly **one** is nameless — and it is the one control on that screen
|
|
339
|
+
// no caller could reach. This does not flood the map.
|
|
340
|
+
//
|
|
341
|
+
// It also settles a thing the reports could not: those controls were
|
|
342
|
+
// called "absent from the tree", and AXPTranslator had them all along.
|
|
343
|
+
// What is genuinely absent is the drag handle, a plain view holding a
|
|
344
|
+
// responder that UIKit is never told is accessible. No filter here can
|
|
345
|
+
// recover that one; see 122's second half.
|
|
228
346
|
for (const n of nodes) {
|
|
229
|
-
if (!n.frame ||
|
|
347
|
+
if (!n.frame || isContainer(n)) continue;
|
|
348
|
+
if (nameless(n) && !namelessHitTarget(n, nodes)) continue;
|
|
230
349
|
targets.push({
|
|
231
|
-
label: n.label,
|
|
350
|
+
label: n.label ?? undefined,
|
|
351
|
+
// A `testID` arrives as AXIdentifier, and `tap` has matched on it
|
|
352
|
+
// since long before this — but only down the tree path, because the
|
|
353
|
+
// map never carried it. So the one name an unlabelled React Native
|
|
354
|
+
// control usually does have was invisible in the map and worked if
|
|
355
|
+
// you guessed it.
|
|
356
|
+
identifier: n.identifier ?? undefined,
|
|
232
357
|
// What the control *contains*, whether it is on, and whether it has
|
|
233
358
|
// focus. All three come off the accessibility tree, the daemon has
|
|
234
359
|
// asked for all three since 0.6.0, and all three were dropped before
|
|
@@ -430,6 +555,36 @@ export function isInteractive(target) {
|
|
|
430
555
|
return INTERACTIVE.test(target.type || '');
|
|
431
556
|
}
|
|
432
557
|
|
|
558
|
+
/** No name from any source: not a label, not an identifier. */
|
|
559
|
+
export const nameless = (n) => !n?.label && !n?.identifier;
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Whether a nameless accessibility node is worth printing as a hit target.
|
|
563
|
+
*
|
|
564
|
+
* Two guards, because "has no name" is not the same as "is a control".
|
|
565
|
+
*
|
|
566
|
+
* The role has to be one the tree calls interactive. A nameless `Other` is a
|
|
567
|
+
* layout view and a real screen has hundreds of them; emitting those would bury
|
|
568
|
+
* the one node that matters under the scenery it sits in.
|
|
569
|
+
*
|
|
570
|
+
* And it must not enclose two or more other elements. That is the same test the
|
|
571
|
+
* fingerprint uses for scenery, reused deliberately rather than invented here:
|
|
572
|
+
* a table cell holding its own labels is not the target, its labels are.
|
|
573
|
+
*/
|
|
574
|
+
export function namelessHitTarget(node, nodes) {
|
|
575
|
+
const f = node?.frame;
|
|
576
|
+
if (!f || !(f.width > 0) || !(f.height > 0)) return false;
|
|
577
|
+
if (!INTERACTIVE.test(node.type || '')) return false;
|
|
578
|
+
const encloses = nodes.filter((o) => {
|
|
579
|
+
const g = o.frame;
|
|
580
|
+
if (!g || o === node) return false;
|
|
581
|
+
const cx = g.x + (g.width ?? 0) / 2;
|
|
582
|
+
const cy = g.y + (g.height ?? 0) / 2;
|
|
583
|
+
return cx > f.x && cx < f.x + f.width && cy > f.y && cy < f.y + f.height;
|
|
584
|
+
}).length;
|
|
585
|
+
return encloses < 2;
|
|
586
|
+
}
|
|
587
|
+
|
|
433
588
|
/**
|
|
434
589
|
* Rank candidates for a label. Exact beats substring, and a real control beats
|
|
435
590
|
* a caption that happens to read the same — a screen title and a tab are often
|
|
@@ -438,7 +593,7 @@ export function isInteractive(target) {
|
|
|
438
593
|
export function rank(entry, query) {
|
|
439
594
|
if (!entry) return [];
|
|
440
595
|
const q = norm(query);
|
|
441
|
-
const names = (t) => [t.label, ...(t.aliases || [])].map(norm);
|
|
596
|
+
const names = (t) => [t.label, t.identifier, ...(t.aliases || [])].map(norm);
|
|
442
597
|
const exact = entry.targets.filter((t) => names(t).includes(q));
|
|
443
598
|
const pool = exact.length
|
|
444
599
|
? exact
|
package/src/store.js
CHANGED
|
@@ -34,10 +34,63 @@ export function paths(udid) {
|
|
|
34
34
|
// wedged. It cannot ride in state.json: that is written when a frame is
|
|
35
35
|
// recorded, and a stall is the absence of frames.
|
|
36
36
|
captureHealth: path.join(dir, 'capture-health.json'),
|
|
37
|
+
lastInput: path.join(dir, 'last-input'),
|
|
37
38
|
};
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/** What the capture loop last said about its own health, or null if it has no complaint. */
|
|
42
|
+
/**
|
|
43
|
+
* When input was last delivered to this device.
|
|
44
|
+
*
|
|
45
|
+
* Written by every input path and read by `liveness`, which needs it to catch
|
|
46
|
+
* the one wedge shape nothing else can see: a capture loop that is alive,
|
|
47
|
+
* incrementing its frame counter, and re-reading a **dead surface**. Field
|
|
48
|
+
* report, 0.12.2: `age=268ms` next to `stable=159753ms` while three screen
|
|
49
|
+
* transitions had just happened, and no warning fired because every signal we
|
|
50
|
+
* had was green. A screen that has not moved since before we last touched it,
|
|
51
|
+
* over and over, is a contradiction the daemon can notice locally.
|
|
52
|
+
*/
|
|
53
|
+
const INPUT_MEMORY = 8;
|
|
54
|
+
|
|
55
|
+
export function noteInput(udid, at = Date.now()) {
|
|
56
|
+
try {
|
|
57
|
+
writeAtomic(paths(udid).lastInput, [...inputTimes(udid), at].slice(-INPUT_MEMORY).join(','));
|
|
58
|
+
} catch {
|
|
59
|
+
/* a timestamp nothing depends on for correctness must not fail an action */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The last few input timestamps, oldest first.
|
|
65
|
+
*
|
|
66
|
+
* A list rather than a single timestamp, and that is the whole correction.
|
|
67
|
+
* The first version kept only the latest, so the only question it could ask was
|
|
68
|
+
* "has the screen been still for a long time?" — which needed a duration
|
|
69
|
+
* threshold, and the threshold is what defeated it. A field report caught a
|
|
70
|
+
* three-hour-stale frame on a screen that had been still for **8.2 seconds**,
|
|
71
|
+
* under a 20-second gate, so the check could not fire on the case it was
|
|
72
|
+
* written for.
|
|
73
|
+
*
|
|
74
|
+
* What actually says "dead surface" is not duration. It is **several gestures
|
|
75
|
+
* delivered with no pixel moving at all** — one tap that changes nothing is
|
|
76
|
+
* ordinary, and three in a row are not.
|
|
77
|
+
*/
|
|
78
|
+
export function inputTimes(udid) {
|
|
79
|
+
try {
|
|
80
|
+
return fs.readFileSync(paths(udid).lastInput, 'utf8')
|
|
81
|
+
.split(',')
|
|
82
|
+
.map(Number)
|
|
83
|
+
.filter(Number.isFinite);
|
|
84
|
+
} catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function lastInputAt(udid) {
|
|
90
|
+
const times = inputTimes(udid);
|
|
91
|
+
return times.length ? times[times.length - 1] : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
41
94
|
export function captureHealth(udid) {
|
|
42
95
|
return readJson(paths(udid).captureHealth);
|
|
43
96
|
}
|