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 CHANGED
@@ -255,10 +255,11 @@ nav-bar:
255
255
  #2 text 201,64 Inbox
256
256
  content:
257
257
  #3 cell 201,140 Weekly digest
258
- #4 cell 201,196 Payment received
258
+ #4 field 201,196 Search = weekly ~ weekly|
259
+ #5 switch 201,252 Notifications = 1
259
260
  tab-bar:
260
- #5 text 62,835 Inbox
261
- #6 text 201,835 Settings
261
+ #6 text 62,835 Inbox
262
+ #7 text 201,835 Settings
262
263
  ```
263
264
 
264
265
  Region first, because "Inbox" the title and "Inbox" the tab differ only by where
@@ -267,6 +268,18 @@ is a selector: whatever this calls `#3`, the next call can tap as `#3` without
267
268
  describing it. A ref is valid only while that screen is showing — used on a
268
269
  different screen it refuses rather than tapping whatever now sits there.
269
270
 
271
+ `= something` is what the control *contains*, from the accessibility tree, and
272
+ `~ something` is what OCR read off the pixels. Both are printed, and where they
273
+ disagree that is the point: one is authoritative and the other is what is
274
+ actually on screen, and a field mid-edit can legitimately differ. A row with no
275
+ `=` is a control that reports no value, not an empty one.
276
+
277
+ When the elements were recalled from screen memory rather than looked at just
278
+ now, the header says so and how long ago — `elements recalled from 41s ago —
279
+ pass refresh for what is there now`. Identity is cached on purpose, because a
280
+ list with new rows is the same screen; contents are exactly what changes without
281
+ the screen changing, so the age is worth seeing.
282
+
270
283
  Three ways to name a control, anywhere one is named:
271
284
 
272
285
  | | |
@@ -542,8 +555,10 @@ simframe frame --out=now.png # newest frame, native resolution, to a file
542
555
  simframe strip --count=6 # contact sheet, for an animation
543
556
  simframe doctor --strict # any degraded layer is a non-zero exit
544
557
  simframe escalations # why simframe still needs a model, by reason
558
+ simframe escalations --session # ...this agent only, not every agent on the device
545
559
  simframe hpi # speed and accuracy against a human baseline
546
560
  simframe baseline record settings-larger-text --runs=5 # record the human
561
+ simframe input reset # rebuild the HID session, without restarting anything
547
562
  simframe start / status / stop [--force] / devices
548
563
  simframe ui --device=emulator-5554 # or export SIMFRAME_DEVICE once
549
564
  ```
@@ -646,6 +661,16 @@ said a word — the exact failure shape, found by the thing built to catch it.
646
661
  dramatically between visits will simply be rebuilt.
647
662
  - It speeds up *confirming* a fix, not *locating* one. A bug living in a memo
648
663
  comparator or a stale closure is not visible in any frame.
664
+ - A switch is tapped at the centre of its frame, and a switch's frame is the
665
+ whole row — so the tap lands on the label and the control, which sits at the
666
+ trailing end, does not move. Use `@x,y` on the control for now. Filed with
667
+ the measurement in `docs/DEFERRED.md`; it is a role-specific tap point, not a
668
+ patch at one call site.
669
+ - The simulator's display pipeline stops rendering under rapid app relaunch —
670
+ about six cycles, reproducibly — and every frame comes back black while
671
+ `simctl` itself reports success. simframe now says so instead of reading a
672
+ black screen as a calm one, but it cannot fix it: restarting the device is
673
+ the cure that always works, and it usually recovers on its own.
649
674
 
650
675
  ## Roadmap
651
676
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simframe",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "mcpName": "io.github.lvlrSajjad/simframe",
5
5
  "description": "Always-warm iOS Simulator and Android emulator frames: agents read the screen in ~20ms instead of waiting on screenshots. MCP server + CLI.",
6
6
  "keywords": [
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ // No third-party app identifiers in this repository. Ever, from anyone.
3
+ //
4
+ // simframe is a general-purpose tool: you install it and Claude Code drives
5
+ // *your* app on the simulator. It has no relationship with any particular app,
6
+ // so no particular app's bundle id belongs in it — not in the source, not in
7
+ // the docs, and not in a secret either. A denylist of specific strings would
8
+ // assume there is one app to protect, which is the wrong shape for this.
9
+ //
10
+ // So the rule is a pattern, not a list, and it needs no configuration at all.
11
+ // Anything shaped like a reverse-DNS bundle id is flagged unless it is one of:
12
+ //
13
+ // * a platform's own com.apple.*, com.android.*, com.google.*
14
+ // * a documentation placeholder com.example.*, com.acme.*, com.mycompany.*
15
+ // * this project's own identifiers
16
+ //
17
+ // That works on a fresh clone, on a fork, and in a pull request from a stranger,
18
+ // which a secret does not. An optional `.private-strings` file (gitignored) or
19
+ // $SIMFRAME_PRIVATE_STRINGS still adds extra patterns for anyone who wants them,
20
+ // but nothing depends on either existing.
21
+ //
22
+ // How this got written: a 282-line field-notes file about a real third-party app
23
+ // was committed here by `git add -A` and pushed, an hour after the first version
24
+ // of this script was written to prevent exactly that. It could not fire, because
25
+ // it was waiting for a denylist nobody had supplied. A guard with a
26
+ // precondition is a guard that is off.
27
+ //
28
+ // Nothing here ever prints a match. It prints the file and the line number, so
29
+ // the output of a failed run is safe to paste into an issue, a CI log, or a
30
+ // conversation with an agent — which is where the last one would have gone.
31
+ import fs from 'node:fs';
32
+ import path from 'node:path';
33
+ import { execFileSync } from 'node:child_process';
34
+ import { fileURLToPath } from 'node:url';
35
+
36
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
37
+ const LIST_FILE = path.join(ROOT, '.private-strings');
38
+
39
+ /**
40
+ * Bundle-id-shaped strings, and the ones that are fine.
41
+ *
42
+ * The first segment is restricted to real reverse-DNS prefixes, which is what
43
+ * keeps ordinary property chains out: `res.state.seq`, `registry.paths.dir` and
44
+ * `import.meta.url` all look exactly like bundle ids until you require the head
45
+ * to be a TLD.
46
+ */
47
+ const BUNDLE = /\b(?:com|io|org|net|dev|co|app|me|xyz|uk|de|fr|jp|nl|se|ca|au)\.[A-Za-z][A-Za-z0-9_-]{1,30}(?:\.[A-Za-z][A-Za-z0-9_-]{0,30}){1,3}\b/g;
48
+
49
+ /** Platform-owned, placeholder, or ours. Anything else is somebody's app. */
50
+ export const ALLOWED = [
51
+ /^com\.apple\./i,
52
+ /^com\.android\./i,
53
+ /^com\.google\./i,
54
+ /^org\.swift\./i,
55
+ /^org\.json\./i,
56
+ /^com\.facebook\./i, // idb, a reference implementation named in the docs
57
+ /^com\.example\./i,
58
+ /^com\.acme\./i,
59
+ /^com\.mycompany\./i,
60
+ /^com\.yourcompany\./i,
61
+ /^io\.github\./i,
62
+ ];
63
+
64
+ export function isAllowedIdentifier(id) {
65
+ return ALLOWED.some((re) => re.test(id));
66
+ }
67
+
68
+ /** Extra patterns, for anyone who wants them. Nothing depends on this existing. */
69
+ export function patternsFrom({ env, file } = {}) {
70
+ const raw = [
71
+ ...String(env ?? '').split(/[\n,]/),
72
+ ...String(file ?? '').split(/\n/),
73
+ ];
74
+ return [...new Set(
75
+ raw
76
+ .map((s) => s.trim())
77
+ .filter((s) => s && !s.startsWith('#'))
78
+ // One character would match everything, which is a check that only ever
79
+ // fails and therefore only ever gets disabled.
80
+ .filter((s) => s.length >= 3)
81
+ .map((s) => s.toLowerCase()),
82
+ )];
83
+ }
84
+
85
+ /** Which lines of `text` are a problem. Line numbers only, never matches. */
86
+ export function offendingLines(text, patterns = []) {
87
+ const hits = [];
88
+ const lines = String(text).split('\n');
89
+ for (let i = 0; i < lines.length; i += 1) {
90
+ const lower = lines[i].toLowerCase();
91
+ const why = [];
92
+ const extra = patterns.filter((p) => lower.includes(p)).length;
93
+ if (extra) why.push(`${extra} denied pattern(s)`);
94
+ const ids = (lines[i].match(BUNDLE) ?? []).filter((id) => !isAllowedIdentifier(id));
95
+ // Reported as a count and a shape, never as the identifier: knowing which
96
+ // app leaked is worth less to a bug report than not restating it.
97
+ if (ids.length) why.push(`${ids.length} third-party bundle id(s)`);
98
+ if (why.length) hits.push({ line: i + 1, why: why.join(', ') });
99
+ }
100
+ return hits;
101
+ }
102
+
103
+ function trackedFiles() {
104
+ return execFileSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf8' })
105
+ .split('\0')
106
+ .filter(Boolean);
107
+ }
108
+
109
+ function main() {
110
+ const patterns = patternsFrom({
111
+ env: process.env.SIMFRAME_PRIVATE_STRINGS,
112
+ file: fs.existsSync(LIST_FILE) ? fs.readFileSync(LIST_FILE, 'utf8') : '',
113
+ });
114
+ const files = trackedFiles();
115
+ let failed = 0;
116
+ for (const rel of files) {
117
+ const full = path.join(ROOT, rel);
118
+ let text;
119
+ try {
120
+ const stat = fs.statSync(full);
121
+ if (!stat.isFile() || stat.size > 4_000_000) continue;
122
+ text = fs.readFileSync(full, 'utf8');
123
+ } catch {
124
+ continue;
125
+ }
126
+ // A NUL byte means this is not text, and a substring hit in it is noise.
127
+ if (text.includes('\0')) continue;
128
+ for (const hit of offendingLines(text, patterns)) {
129
+ console.error(`check-private: ${rel}:${hit.line} — ${hit.why}`);
130
+ failed += 1;
131
+ }
132
+ }
133
+ console.log(`check-private: ${files.length} tracked file(s); no third-party bundle ids`
134
+ + (patterns.length ? `, plus ${patterns.length} local pattern(s)` : '')
135
+ + ` — ${failed} line(s) flagged`);
136
+ if (failed) {
137
+ console.error('check-private: nothing above prints the match itself. '
138
+ + 'A third-party app identifier does not belong in a general-purpose tool.');
139
+ process.exitCode = 1;
140
+ }
141
+ }
142
+
143
+ if (import.meta.url === `file://${process.argv[1]}`) main();
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ // The perception eval harness. Deferred since Phase 5; four things wait on it.
3
+ //
4
+ // What it is, and the design decision behind it. The obvious harness replays
5
+ // stored frames through the perception path and diffs the element lists, which
6
+ // is what Phase 13 step 5 describes. That harness cannot exist: the element
7
+ // list is the accessibility tree fused with OCR, the tree is not in the frame,
8
+ // and OCR runs in the daemon against a live framebuffer. A frame on disk is
9
+ // half the input.
10
+ //
11
+ // So the split is different and, for what actually needs gating, better. The
12
+ // machine records the *input* — the fused element list for a screen, exactly as
13
+ // perception produced it. A person authors the *expected output*. Everything
14
+ // downstream of the element list is a pure function, so the check runs offline,
15
+ // deterministically, with no simulator and no daemon:
16
+ //
17
+ // resolution matching.resolve(targets, query) — which element a query picks
18
+ // identity fingerprint.tokens(targets) — which screen this is
19
+ // change analyze.signatureDiff(a, b) — whether a frame moved
20
+ //
21
+ // Those three are precisely the thresholds every blocked item wants to change.
22
+ // What it does *not* cover is whether perception found the elements at all,
23
+ // which is inherently live and stays with eval-fingerprint.mjs and the
24
+ // integration job. Said plainly here rather than implied, because a harness
25
+ // that is trusted for more than it measures is worse than no harness.
26
+ //
27
+ // Two rules from this repo:
28
+ // - The gate is an exit code. Nothing is verified through a pipe: this
29
+ // project has already shipped a check that could not fail because
30
+ // `node script.mjs | tail` reports tail's status.
31
+ // - A fixture with no authored expectations is reported as unauthored, never
32
+ // counted as a pass. An empty test suite is green.
33
+ import fs from 'node:fs';
34
+ import path from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+ import * as fingerprint from '../src/fingerprint.js';
37
+ import * as matching from '../src/matching.js';
38
+ import * as analyze from '../src/analyze.js';
39
+
40
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
41
+ const DIR = path.join(ROOT, 'test', 'perception', 'screens');
42
+ const arg = (n, d = null) => {
43
+ const hit = process.argv.find((a) => a.startsWith(`--${n}=`));
44
+ return hit ? hit.slice(n.length + 3) : d;
45
+ };
46
+ const has = (n) => process.argv.includes(`--${n}`);
47
+
48
+ export function fixtures(dir = DIR, only = null) {
49
+ if (!fs.existsSync(dir)) return [];
50
+ return fs.readdirSync(dir)
51
+ .filter((f) => f.endsWith('.json'))
52
+ .filter((f) => (only ? f.includes(only) : true))
53
+ .sort()
54
+ .map((f) => ({ file: f, ...JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) }));
55
+ }
56
+
57
+ /**
58
+ * Check one screen's authored expectations against the pure layers.
59
+ *
60
+ * Returns findings rather than printing, so the same function backs the CLI and
61
+ * the unit tests — and so a finding can be counted without being formatted.
62
+ */
63
+ export function checkScreen(fx) {
64
+ const findings = [];
65
+ const screen = fx.points ?? null;
66
+ const targets = fx.targets ?? [];
67
+ const expect = fx.expect ?? {};
68
+ const authored = (expect.resolutions?.length ?? 0)
69
+ + (expect.ambiguous?.length ?? 0)
70
+ + (expect.none?.length ?? 0)
71
+ + (fx.frame_pairs?.length ?? 0)
72
+ + (expect.identity ? 1 : 0);
73
+
74
+ for (const r of expect.resolutions ?? []) {
75
+ const out = matching.resolve(targets, r.query, { screen });
76
+ if (out.status !== 'ok') {
77
+ findings.push({
78
+ kind: 'resolution',
79
+ query: r.query,
80
+ want: r.label,
81
+ got: out.status === 'ambiguous'
82
+ ? `ambiguous between ${out.alternatives.map((a) => a.label).join(', ')}`
83
+ : 'nothing resolved',
84
+ });
85
+ continue;
86
+ }
87
+ const got = out.target.label ?? '(icon-only)';
88
+ // Compared on the label because that is what the author can see and mean.
89
+ // Coordinates would make a fixture break every time a row moved a point.
90
+ if (got !== r.label) {
91
+ findings.push({ kind: 'resolution', query: r.query, want: r.label, got: `"${got}" at ${out.target.x},${out.target.y}` });
92
+ }
93
+ }
94
+
95
+ // The other half of the contract, and the half with the wrong-tap risk in it:
96
+ // "when two things answer equally well it says so rather than guessing".
97
+ for (const q of expect.ambiguous ?? []) {
98
+ const out = matching.resolve(targets, typeof q === 'string' ? q : q.query, { screen });
99
+ if (out.status !== 'ambiguous') {
100
+ findings.push({
101
+ kind: 'should-ask',
102
+ query: typeof q === 'string' ? q : q.query,
103
+ want: 'ambiguous',
104
+ got: out.status === 'ok' ? `picked "${out.target.label}" at score ${out.score}` : 'nothing resolved',
105
+ });
106
+ }
107
+ }
108
+
109
+ for (const q of expect.none ?? []) {
110
+ const out = matching.resolve(targets, q, { screen });
111
+ if (out.status !== 'none') {
112
+ findings.push({
113
+ kind: 'should-find-nothing',
114
+ query: q,
115
+ want: 'none',
116
+ got: out.status === 'ok' ? `picked "${out.target.label}"` : 'ambiguous',
117
+ });
118
+ }
119
+ }
120
+
121
+ // Identity drift. A token-rule change that silently alters a screen's
122
+ // structural hash discards every stored map and graph for it, and the failure
123
+ // is invisible — an old hash is a well-formed hash that matches nothing.
124
+ if (expect.identity && screen) {
125
+ const now = fingerprint.fingerprint(targets, screen);
126
+ if (now.hash !== expect.identity.hash) {
127
+ const similarity = fingerprint.similarity(now.tokens, expect.identity.tokens ?? []);
128
+ findings.push({
129
+ kind: 'identity',
130
+ query: '(structural hash)',
131
+ want: `${expect.identity.hash?.slice(0, 12)} (${expect.identity.tokens?.length ?? 0} tokens)`,
132
+ got: `${now.hash?.slice(0, 12)} (${now.tokens.length} tokens), similarity ${similarity.toFixed(2)}`,
133
+ });
134
+ }
135
+ }
136
+
137
+ // Frame pairs, for the change detector. `changed: true` means an action that
138
+ // a person would call visible — a switch flipping, a radio dot moving.
139
+ for (const pair of fx.frame_pairs ?? []) {
140
+ const a = analyze.hexToSignature(pair.after);
141
+ const b = analyze.hexToSignature(pair.before);
142
+ // Two different questions about the same pair of frames: has the *screen*
143
+ // changed (the mean, which drives stillness) and has a *control* changed
144
+ // (the largest single region, which drives the verdict). A switch flip
145
+ // answers no to the first and yes to the second, which is the whole reason
146
+ // both exist.
147
+ const diff = pair.per_cell ? analyze.maxCellDelta(a, b) : analyze.signatureDiff(a, b);
148
+ const seen = diff > (pair.threshold ?? 0.004);
149
+ if (seen !== Boolean(pair.changed)) {
150
+ findings.push({
151
+ kind: 'change',
152
+ query: pair.note ?? '(frame pair)',
153
+ want: pair.changed ? 'a visible change' : 'no change',
154
+ got: `${pair.per_cell ? 'max cell' : 'mean'} ${diff.toFixed(5)} against a threshold of ${pair.threshold ?? 0.004}`,
155
+ });
156
+ }
157
+ }
158
+
159
+ return { authored, findings };
160
+ }
161
+
162
+ async function record() {
163
+ const api = await import('../src/index.js');
164
+ const device = arg('device');
165
+ const name = arg('name');
166
+ if (!name) throw new Error('--record needs --name=<app>/<screen>');
167
+ const id = await api.screenIdentity(device, { fresh: true, confirmNovel: false });
168
+ const entry = id.entry ?? {};
169
+ const out = {
170
+ app: name.split('/')[0],
171
+ screen: name.split('/').slice(1).join('/') || name,
172
+ note: arg('note') ?? null,
173
+ recorded_at: new Date().toISOString(),
174
+ points: id.points,
175
+ // The recorded input. Trimmed to what the pure layers read, so a fixture
176
+ // is reviewable by a person rather than a wall of machine state.
177
+ targets: (entry.targets ?? []).map((t) => ({
178
+ label: t.label ?? null,
179
+ value: t.value ?? null,
180
+ type: t.type ?? null,
181
+ x: t.x, y: t.y,
182
+ frame: t.frame ?? null,
183
+ region: t.region ?? null,
184
+ source: t.source ?? null,
185
+ aliases: t.aliases ?? undefined,
186
+ navSlot: t.navSlot ?? undefined,
187
+ enabled: t.enabled ?? undefined,
188
+ selected: t.selected ?? undefined,
189
+ })),
190
+ expect: {
191
+ identity: { hash: entry.structuralHash, tokens: entry.structuralTokens ?? [] },
192
+ // Authored by hand. The machine records what perception saw; a person
193
+ // says what it should mean. Deriving these from current behaviour would
194
+ // bake today's bugs in as the specification.
195
+ resolutions: [],
196
+ ambiguous: [],
197
+ none: [],
198
+ },
199
+ };
200
+ fs.mkdirSync(DIR, { recursive: true });
201
+ const file = path.join(DIR, `${name.replace(/\//g, '__')}.json`);
202
+ fs.writeFileSync(file, `${JSON.stringify(out, null, 2)}\n`);
203
+ console.log(`recorded ${out.targets.length} element(s) from ${name} -> ${path.relative(ROOT, file)}`);
204
+ console.log(' now author expect.resolutions / expect.ambiguous / expect.none by hand');
205
+ }
206
+
207
+ function check() {
208
+ const all = fixtures(DIR, arg('only'));
209
+ if (!all.length) {
210
+ console.error('check-perception: no fixtures. Record some with --record --name=<app>/<screen>');
211
+ process.exitCode = 1;
212
+ return;
213
+ }
214
+ let failed = 0;
215
+ let unauthored = 0;
216
+ let checks = 0;
217
+ const apps = new Set();
218
+ for (const fx of all) {
219
+ apps.add(fx.app);
220
+ const { authored, findings } = checkScreen(fx);
221
+ checks += authored;
222
+ if (!authored) {
223
+ unauthored += 1;
224
+ console.log(` ?? ${fx.app}/${fx.screen} recorded, no expectations authored`);
225
+ continue;
226
+ }
227
+ if (!findings.length) {
228
+ console.log(` ok ${fx.app}/${fx.screen} ${authored} expectation(s)`);
229
+ continue;
230
+ }
231
+ failed += findings.length;
232
+ console.log(`FAIL ${fx.app}/${fx.screen}`);
233
+ for (const f of findings) {
234
+ console.log(` ${f.kind}: ${f.query}\n want ${f.want}\n got ${f.got}`);
235
+ }
236
+ }
237
+ console.log(`\n${all.length} screen(s) across ${apps.size} app(s), ${checks} expectation(s), ${failed} failure(s)`
238
+ + (unauthored ? `, ${unauthored} unauthored` : ''));
239
+ // An unauthored fixture is not a pass. A suite that counts recordings as
240
+ // successes is a suite that goes green by adding files.
241
+ if (failed || unauthored) process.exitCode = 1;
242
+ }
243
+
244
+ if (import.meta.url === `file://${process.argv[1]}`) {
245
+ if (has('record')) await record();
246
+ else if (has('list')) for (const f of fixtures()) console.log(`${f.app}/${f.screen} ${f.targets?.length ?? 0} elements`);
247
+ else check();
248
+ }