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
@@ -0,0 +1,76 @@
1
+ // End-to-end smoke test: speaks JSON-RPC to `simframe mcp` over stdio.
2
+ import { spawn } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const CLI = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'cli.js');
7
+ const child = spawn(process.execPath, [CLI, 'mcp'], { stdio: ['pipe', 'pipe', 'inherit'] });
8
+
9
+ let buf = '';
10
+ const pending = new Map();
11
+ child.stdout.on('data', (d) => {
12
+ buf += d;
13
+ let i;
14
+ while ((i = buf.indexOf('\n')) >= 0) {
15
+ const line = buf.slice(0, i).trim();
16
+ buf = buf.slice(i + 1);
17
+ if (!line) continue;
18
+ const msg = JSON.parse(line);
19
+ if (msg.id && pending.has(msg.id)) {
20
+ pending.get(msg.id)(msg);
21
+ pending.delete(msg.id);
22
+ }
23
+ }
24
+ });
25
+
26
+ let nextId = 1;
27
+ function send(method, params) {
28
+ const id = nextId++;
29
+ return new Promise((resolve) => {
30
+ pending.set(id, resolve);
31
+ child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
32
+ });
33
+ }
34
+ function notify(method, params) {
35
+ child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`);
36
+ }
37
+
38
+ const summarize = (res) =>
39
+ (res.result?.content || []).map((c) =>
40
+ c.type === 'image' ? `[image ${Math.round(c.data.length * 0.75 / 1024)}KB]` : c.text,
41
+ );
42
+
43
+ const init = await send('initialize', {
44
+ protocolVersion: '2024-11-05',
45
+ capabilities: {},
46
+ clientInfo: { name: 'smoke', version: '0' },
47
+ });
48
+ console.log('initialize ->', init.result.serverInfo);
49
+ notify('notifications/initialized');
50
+
51
+ const tools = await send('tools/list', {});
52
+ console.log('tools ->', tools.result.tools.map((t) => t.name).join(', '));
53
+
54
+ for (const [name, args] of [
55
+ ['sim_devices', {}],
56
+ ['sim_state', {}],
57
+ ['sim_look', { detail: 'low' }],
58
+ ['sim_strip', { count: 4 }],
59
+ ['sim_recall', { action: 'timeline' }],
60
+ ['sim_recall', { action: 'at', msAgo: 8000 }],
61
+ ['sim_capture', { action: 'status' }],
62
+ ]) {
63
+ const t0 = Date.now();
64
+ const res = await send('tools/call', { name, arguments: args });
65
+ console.log(`\n--- ${name} (${Date.now() - t0}ms)${res.result?.isError ? ' ERROR' : ''}`);
66
+ console.log(summarize(res).join('\n'));
67
+ }
68
+
69
+ const t0 = Date.now();
70
+ const w = await send('tools/call', {
71
+ name: 'sim_wait',
72
+ arguments: { mode: 'stable', stableMs: 400, timeoutMs: 5000, includeImage: false },
73
+ });
74
+ console.log(`\n--- sim_wait (${Date.now() - t0}ms)\n${summarize(w).join('\n')}`);
75
+
76
+ child.kill();
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ // Keep server.json's version in step with package.json's.
3
+ //
4
+ // `npm version` only knows about package.json, and the MCP registry manifest
5
+ // carries the version twice — once at the top level and once inside the package
6
+ // entry. Every release therefore depended on remembering to hand-edit a second
7
+ // file between two commands, and the release that did not remember failed at
8
+ // the workflow's own agreement check.
9
+ //
10
+ // npm runs this as the `version` lifecycle script: after the bump, before the
11
+ // commit. It stages server.json so the version commit contains both files.
12
+ import { execFileSync } from 'node:child_process';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
+ const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
19
+ const file = path.join(ROOT, 'server.json');
20
+ const server = JSON.parse(fs.readFileSync(file, 'utf8'));
21
+
22
+ const before = { top: server.version, pkg: server.packages?.[0]?.version };
23
+ server.version = pkg.version;
24
+ if (!Array.isArray(server.packages) || !server.packages.length) {
25
+ console.error('server.json has no packages[] entry to version — has its shape changed?');
26
+ process.exit(1);
27
+ }
28
+ server.packages[0].version = pkg.version;
29
+
30
+ fs.writeFileSync(file, `${JSON.stringify(server, null, 2)}\n`);
31
+ console.log(`server.json ${before.top} / ${before.pkg} -> ${pkg.version} / ${pkg.version}`);
32
+
33
+ // Stage it, so `npm version` commits both files together. Harmless when run
34
+ // with --no-git-tag-version; the file is still correct either way.
35
+ try {
36
+ execFileSync('git', ['add', '--', file], { cwd: ROOT, stdio: 'pipe' });
37
+ } catch {
38
+ console.log('(could not stage server.json — commit it yourself)');
39
+ }
@@ -0,0 +1,65 @@
1
+ // Proves the agent-facing flow: look, act, ask again — and get a true answer
2
+ // without threading any baseline through by hand.
3
+ import { spawn, execFileSync } from 'node:child_process';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const UDID = process.argv[2];
8
+ const CLI = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'cli.js');
9
+ const child = spawn(process.execPath, [CLI, 'mcp'], { stdio: ['pipe', 'pipe', 'inherit'] });
10
+
11
+ let buf = '';
12
+ const pending = new Map();
13
+ child.stdout.on('data', (d) => {
14
+ buf += d;
15
+ let i;
16
+ while ((i = buf.indexOf('\n')) >= 0) {
17
+ const line = buf.slice(0, i).trim();
18
+ buf = buf.slice(i + 1);
19
+ if (line) {
20
+ const m = JSON.parse(line);
21
+ if (pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
22
+ }
23
+ }
24
+ });
25
+ let id = 1;
26
+ const send = (method, params) => new Promise((r) => {
27
+ const i = id++;
28
+ pending.set(i, r);
29
+ child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: i, method, params })}\n`);
30
+ });
31
+ const call = async (name, args = {}) => {
32
+ const t0 = Date.now();
33
+ const r = await send('tools/call', { name, arguments: args });
34
+ const txt = (r.result?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('\n');
35
+ const imgs = (r.result?.content || []).filter((c) => c.type === 'image').length;
36
+ return { ms: Date.now() - t0, txt, imgs };
37
+ };
38
+ const launch = (bundle) => execFileSync('xcrun', ['simctl', 'launch', UDID, bundle], { stdio: 'ignore' });
39
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
40
+
41
+ await send('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'v', version: '0' } });
42
+ child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })}\n`);
43
+
44
+ launch('com.apple.springboard'); await sleep(1500);
45
+
46
+ console.log('--- 1. agent looks at the screen (implicitly sets its baseline)');
47
+ let r = await call('sim_look', { detail: 'low' });
48
+ console.log(` ${r.ms}ms, ${r.imgs} image\n ${r.txt.split('\n').join('\n ')}`);
49
+
50
+ console.log('\n--- 2. agent acts, then waits (no baseline passed by hand)');
51
+ launch('com.apple.Preferences');
52
+ r = await call('sim_wait', { includeImage: false, stableMs: 700 });
53
+ console.log(` ${r.ms}ms\n ${r.txt.split('\n').join('\n ')}`);
54
+
55
+ console.log('\n--- 3. agent polls in text, seconds later — the peer\'s failing case');
56
+ await sleep(2500);
57
+ r = await call('sim_state');
58
+ console.log(` ${r.ms}ms, ${r.imgs} images\n ${r.txt.split('\n').join('\n ')}`);
59
+
60
+ console.log('\n--- 4. nothing happens; asking again must say so');
61
+ await sleep(1200);
62
+ r = await call('sim_state');
63
+ console.log(` ${r.ms}ms\n ${r.txt.split('\n').join('\n ')}`);
64
+
65
+ child.kill();
@@ -0,0 +1,173 @@
1
+ ---
2
+ name: simframe
3
+ description: Drive and inspect the iOS Simulator with eyes, hands and memory. Use for any task that involves running, testing, navigating or verifying an iOS app on a simulator — "does this screen look right", "tap through the signup flow", "why is this button not working", "is the list loading". Reads screens as text rather than screenshots, batches whole flows into one command, and verifies each step against what it did last time.
4
+ ---
5
+
6
+ # simframe
7
+
8
+ A background daemon keeps the simulator's framebuffer warm, reads the screen
9
+ through the accessibility tree and on-device OCR, and remembers which action
10
+ leads from which screen to which. So the three things that make simulator work
11
+ expensive — waiting for screenshots, spending tokens on images, and re-deriving
12
+ the same screen every time — are already paid for.
13
+
14
+ ## Read the screen as text, not as an image
15
+
16
+ ```bash
17
+ simframe ui
18
+ ```
19
+
20
+ ```
21
+ iPhone 17 Pro · 402x874pt · screen a1b2c3d4 "Inbox" (known, 3 known exits)
22
+ nav-bar:
23
+ #1 button 24,64 Back
24
+ #2 text 201,64 Inbox
25
+ content:
26
+ #3 cell 201,140 Weekly digest
27
+ #4 cell 201,196 Payment received
28
+ tab-bar:
29
+ #5 text 62,835 Inbox
30
+ #6 text 201,835 Settings
31
+ ```
32
+
33
+ That is the whole screen: region, a number, type, tap point in points, label.
34
+ Measured against the same screen as an image: **~460 tokens of text versus
35
+ ~1,600 for a correctly-handled image**, and 10–40× worse than that if the MCP
36
+ image path degrades to base64-as-text. The text also says what is *tappable*
37
+ and where, which an image does not.
38
+
39
+ The numbers are selectors. Whatever `ui` calls `#3`, you can tap as `#3`.
40
+
41
+ **Reach for an image only when the text genuinely cannot answer the question:**
42
+ visual layout, colour, spacing, an animation, or something neither the
43
+ accessibility tree nor OCR can see. Then `simframe frame --out=/tmp/s.png`, or
44
+ `sim_look` over MCP.
45
+
46
+ ## Run the whole flow in one command
47
+
48
+ One command, not one per tap. Each step waits for the screen to settle against
49
+ a baseline captured *before* it, so steps cannot race the UI.
50
+
51
+ ```bash
52
+ cat > /tmp/flow.json <<'JSON'
53
+ [{"tap": "Inbox tab"},
54
+ {"assert": {"value": "Weekly digest", "is": "visible"}},
55
+ {"tap": "#3"},
56
+ {"type": {"into": "Reply", "text": "on it"}},
57
+ {"scrollTo": "Send"},
58
+ {"tap": "Send"},
59
+ {"waitFor": {"value": "Sent", "timeoutMs": 5000}}]
60
+ JSON
61
+ simframe do /tmp/flow.json
62
+ ```
63
+
64
+ Steps stop at the first failure and say which step and why. Measured: **a
65
+ 10-step flow is one command, ~5 seconds, ~460 tokens, zero images.**
66
+
67
+ Steps — every place a control is named accepts a selector:
68
+
69
+ | Act | Check |
70
+ | --- | --- |
71
+ | `{"tap": "Save"}` · add `"index"` if a label is ambiguous | `{"assert": {"value": "Saved", "is": "visible"}}` |
72
+ | `{"type": {"into": "Name", "text": "..."}}` | `is`: `visible` · `gone` · `enabled` · `disabled` · `value` (with `equals`) |
73
+ | `{"paste": {"into": "Notes", "text": "long text"}}` | `{"waitFor": {"value": "Saved", "timeoutMs": 5000}}` |
74
+ | `{"scroll": "down"}` · `{"scrollTo": "Delete account"}` | `{"settle": {"stableMs": 600}}` |
75
+ | `{"swipe": {"from": [x,y], "to": [x,y]}}` | `{"pause": 300}` |
76
+ | `{"button": "HOME"}` | |
77
+ | `{"launch": {"value": "com.example.app", "relaunch": true, "args": ["-uiTest","1"]}}` | |
78
+ | `{"openUrl": "myapp://path"}` | |
79
+ | `{"permission": {"value": "photos", "grant": "grant", "bundleId": "com.example.app"}}` | |
80
+
81
+ ## Selectors
82
+
83
+ | | |
84
+ | --- | --- |
85
+ | `#3` | the number `simframe ui` gave it. Cheapest, and unambiguous. |
86
+ | `"Save"` · `the Assets tab` · `back` | resolved by intent — verbs, typos, synonyms, icon-only controls by their common name |
87
+ | `@120,400` | raw point coordinates. Last resort; it cannot tell you it missed. |
88
+
89
+ A ref is valid only while that screen is showing. Use one on a different screen
90
+ and it refuses rather than tapping whatever now sits at those coordinates.
91
+
92
+ ## Every step is verified, and the verdict means something
93
+
94
+ simframe records which action led from which screen to which, so it can check
95
+ each step against what that action did here last time.
96
+
97
+ | Verdict | What it means | What to do |
98
+ | --- | --- | --- |
99
+ | `ok` | landed where this action has landed before | nothing |
100
+ | `unverified` | this action has not been taken on this screen before | nothing — it is learning. Run the flow again and it becomes `ok`. |
101
+ | `no-visible-change` | the screen is stable and nothing moved | the tap may have missed, or its effect may be invisible (a checkbox, a button state). Check with `simframe ui`, not by waiting longer. |
102
+ | `unexpected-screen` | it went somewhere it has not gone before from here | the flow **stops here**. Read the map it returns: either the app changed, or the tap hit the wrong thing. |
103
+
104
+ A first run through a new part of an app is mostly `unverified`, and a
105
+ transition-kind mismatch is reported inside `ok` rather than failing — that
106
+ classifier is noisy and a verdict that cries wolf teaches you to ignore
107
+ verdicts.
108
+
109
+ ## Navigate by memory
110
+
111
+ Once simframe has been somewhere, getting back is a search over remembered
112
+ transitions — no reasoning, no images.
113
+
114
+ ```bash
115
+ simframe screens # what it knows, and how many exits each has
116
+ simframe goto "Settings" # plan a route and walk it, verifying each step
117
+ simframe do /tmp/flow.json --save=checkout # save it if every step verified
118
+ simframe flow run checkout # replay it
119
+ ```
120
+
121
+ `goto` refuses rather than guesses. Unknown screen, a name that fits two
122
+ screens equally, no remembered path — each is reported, with what it does know.
123
+ A wrong route is worse than no route, because it taps things.
124
+
125
+ ## It refuses rather than guesses
126
+
127
+ When two controls answer a query equally well, simframe lists them and asks
128
+ instead of picking. That is deliberate: a wrong tap can *do something* and
129
+ leave you believing it did the right thing. Pass `index`, or use a `#ref`.
130
+
131
+ ## Everything speaks JSON
132
+
133
+ `--json` is on every command, so nothing has to be parsed out of prose:
134
+
135
+ ```bash
136
+ simframe ui --json | jq '.elements[] | select(.type=="button") | .label'
137
+ simframe do /tmp/flow.json --json | jq '.results[] | select(.ok==false)'
138
+ ```
139
+
140
+ ## The cheap-to-expensive order
141
+
142
+ 1. `simframe state` — has anything changed at all? Cheapest thing there is.
143
+ 2. `simframe ui` — what is on screen and what can I tap? Text.
144
+ 3. `simframe do` — act, in a batch, with asserts inside the batch.
145
+ 4. `simframe frame` / `sim_look` — pixels. Only for a question about pixels.
146
+
147
+ ## When something is wrong with simframe itself
148
+
149
+ ```bash
150
+ simframe doctor # capture engine, input driver, a11y, OCR — each honestly
151
+ simframe doctor --strict # any degraded layer is a non-zero exit
152
+ ```
153
+
154
+ simframe falls back when it must — the simctl capture loop instead of the
155
+ daemon, idb instead of the in-process input and accessibility paths — but it
156
+ never falls back quietly. If
157
+ `doctor` says a layer is degraded, believe it: the numbers above assume the
158
+ daemon.
159
+
160
+ ## Other commands
161
+
162
+ ```bash
163
+ simframe start [device] # capture starts on first use anyway
164
+ simframe devices # booted simulators
165
+ simframe recall # what happened in the last ~60s, as text
166
+ simframe strip # recent frames tiled into one image, for an animation
167
+ simframe find "the save button" # resolve an intent without acting on it
168
+ simframe wait --mode=settle # block until the screen stops reacting
169
+ ```
170
+
171
+ `recall` matters more than it looks: if you look up and the screen is already
172
+ different, it tells you what happened and when, instead of you re-running the
173
+ action to find out.