simframe 0.7.2 → 0.8.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 +18 -4
- package/package.json +1 -1
- package/scripts/ci-memory.mjs +15 -2
- package/src/cli.js +5 -0
- package/src/platform/android.js +12 -0
- package/src/platform/ios.js +23 -0
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ Same four-tab navigation flow, on a real production app:
|
|
|
37
37
|
| | Before | With simframe |
|
|
38
38
|
| --- | --- | --- |
|
|
39
39
|
| Look at the screen | ~130–400 ms, blocking | **~20 ms**, already captured |
|
|
40
|
+
|
|
40
41
|
| "Did anything change?" | a full image | **~2 ms**, text only |
|
|
41
42
|
| Finding a control | read tree (~570 ms) + reason | **~1 ms** from memory |
|
|
42
43
|
| A 4-step flow, verified | 4+ model round trips | **1 call**, 3.6 s |
|
|
@@ -44,6 +45,13 @@ Same four-tab navigation flow, on a real production app:
|
|
|
44
45
|
| A 10-step flow | 10 turns, 10 images (~16,000 tokens at best) | **1 turn, 0 images, ~1,650 characters** |
|
|
45
46
|
| Reading a screen | an image: ~1,600 tokens, no tap points | **~330 tokens** of text, with tap points |
|
|
46
47
|
|
|
48
|
+
Every figure above is the cost inside a live process — the MCP server, or the
|
|
49
|
+
daemon answering a socket — which is how an agent actually uses simframe. A
|
|
50
|
+
one-shot `simframe` command from a shell pays about 200 ms of Node startup on
|
|
51
|
+
top, and a frame sitting on an idle screen can be older than 20 ms because the
|
|
52
|
+
capture loop throttles when nothing moves. `~20 ms` is the read, not the
|
|
53
|
+
process.
|
|
54
|
+
|
|
47
55
|
The four-tab tour, three times back to back from a cleared memory:
|
|
48
56
|
|
|
49
57
|
| Pass | Wall clock | Steps verified | Controls from memory |
|
|
@@ -67,10 +75,16 @@ npm install -g simframe
|
|
|
67
75
|
simframe doctor
|
|
68
76
|
```
|
|
69
77
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
**Whichever of those two commands you run first** builds a small Swift daemon
|
|
79
|
+
from source — including `doctor`, which is why a cold `doctor` takes around 15
|
|
80
|
+
seconds and every later one takes two. It needs the Xcode command line tools,
|
|
81
|
+
which you already have if you have a simulator. Without them simframe falls
|
|
82
|
+
back to the original `simctl` loop and says so.
|
|
83
|
+
|
|
84
|
+
If more than one simulator is booted, name the one you mean — `--device=<udid>`,
|
|
85
|
+
or `export SIMFRAME_DEVICE=<udid>` once per shell. simframe refuses to choose
|
|
86
|
+
for you, because the first booted device is nobody's idea of "yours" and the
|
|
87
|
+
command that would act on it is a tap.
|
|
74
88
|
|
|
75
89
|
`doctor` checks each capability separately and tells you what you have:
|
|
76
90
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simframe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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": [
|
package/scripts/ci-memory.mjs
CHANGED
|
@@ -257,7 +257,14 @@ if (first) {
|
|
|
257
257
|
// to defend — and it is how this check has failed twice.
|
|
258
258
|
if (moved) {
|
|
259
259
|
const stale = await cli(['find', `#${first.ref}`], { expectFail: true });
|
|
260
|
-
check
|
|
260
|
+
// Matched on prose, which is this check's weakness: simframe refused
|
|
261
|
+
// correctly with "#1 cannot be trusted here — simframe does not recognise
|
|
262
|
+
// this screen. Read it again", and the check failed because that wording was
|
|
263
|
+
// not one of the two it knew. The refusal is what matters, so the
|
|
264
|
+
// alternation covers how a refusal is actually phrased; the durable fix is a
|
|
265
|
+
// machine-readable reason on the failure, which `find --json` does not yet
|
|
266
|
+
// carry.
|
|
267
|
+
check(/different screen|read the screen|read it again|does not recognise this screen|cannot be trusted/i.test(stale),
|
|
261
268
|
'a ref numbered on another screen refuses instead of tapping those coordinates',
|
|
262
269
|
stale.trim().split('\n')[0]?.slice(0, 90));
|
|
263
270
|
}
|
|
@@ -398,7 +405,13 @@ check(Array.isArray(nonsense.known) && nonsense.known.length > 0,
|
|
|
398
405
|
|
|
399
406
|
const target = screens[0];
|
|
400
407
|
const walked = await jsonRetry(['goto', target.hash], { allowFail: true });
|
|
401
|
-
|
|
408
|
+
// `no-identity` is new in 0.7.2 and belongs here: `goto` used to *throw*
|
|
409
|
+
// `Cannot read properties of null (reading 'slice')` on a screen whose token set
|
|
410
|
+
// was empty, because `hashTokens` returns null for one on purpose. A crash is
|
|
411
|
+
// neither walking there nor naming why it cannot, so this check failed with an
|
|
412
|
+
// empty detail — the reason was `undefined` — and the check was right to fail.
|
|
413
|
+
// Now there is a name for it, and the list has to know the name.
|
|
414
|
+
const outcomes = ['no-route', 'unreplayable-edge', 'ambiguous', 'unknown-screen', 'no-identity'];
|
|
402
415
|
check(walked.ok === true || outcomes.includes(walked.reason),
|
|
403
416
|
'and asked for a screen it knows, it either walks there or names why it cannot',
|
|
404
417
|
walked.ok ? (walked.already ? 'already there' : `walked ${walked.ranSteps} step(s)`) : walked.reason);
|
package/src/cli.js
CHANGED
|
@@ -243,6 +243,11 @@ async function main() {
|
|
|
243
243
|
`stopped ${stopped} daemon${stopped === 1 ? '' : 's'}` +
|
|
244
244
|
(inUse ? `; left ${inUse} in use by another client (pass --force to stop anyway)` : ''),
|
|
245
245
|
);
|
|
246
|
+
// Asked to stop one device, refused, and exited 0 — which is a success
|
|
247
|
+
// code for work not done, and a script checking `$?` could not tell the
|
|
248
|
+
// difference. `--all` is informational by nature, so it keeps exiting 0
|
|
249
|
+
// when it skips a device somebody else holds.
|
|
250
|
+
if (!flags.all && inUse && !stopped) process.exitCode = 1;
|
|
246
251
|
return;
|
|
247
252
|
}
|
|
248
253
|
|
package/src/platform/android.js
CHANGED
|
@@ -176,6 +176,18 @@ async function resolveDevice(query, opts) {
|
|
|
176
176
|
: 'no booted emulator (start one with `emulator -avd <name>`)',
|
|
177
177
|
);
|
|
178
178
|
}
|
|
179
|
+
// See the same guard in ios.js: an arbitrary pick is a tap on the wrong
|
|
180
|
+
// device, and this backend can act too.
|
|
181
|
+
if (booted.length > 1) {
|
|
182
|
+
throw Object.assign(
|
|
183
|
+
new Error(
|
|
184
|
+
`${booted.length} emulators are running and none was named: ` +
|
|
185
|
+
`${booted.map((d) => `${d.name} (${d.udid})`).join(', ')} — name one with --device, ` +
|
|
186
|
+
'or set SIMFRAME_DEVICE to pick a default for this shell',
|
|
187
|
+
),
|
|
188
|
+
{ ambiguous: true },
|
|
189
|
+
);
|
|
190
|
+
}
|
|
179
191
|
return booted[0];
|
|
180
192
|
}
|
|
181
193
|
const q = loose(query);
|
package/src/platform/ios.js
CHANGED
|
@@ -66,6 +66,29 @@ async function resolveDevice(query, opts) {
|
|
|
66
66
|
const booted = all.filter((d) => d.state === 'Booted');
|
|
67
67
|
if (!query) {
|
|
68
68
|
if (booted.length === 0) throw new Error('no booted simulator (open Simulator.app or run `xcrun simctl boot <udid>`)');
|
|
69
|
+
// `booted[0]` was the wrong-device bug, and it was worse than it looked.
|
|
70
|
+
// simctl's order is not "yours" by any definition, so on a machine with
|
|
71
|
+
// more than one booted simulator a bare `simframe ui` read whichever came
|
|
72
|
+
// first — and a bare `simframe tap` would have *injected input* into it. A
|
|
73
|
+
// reviewer reproduced it deterministically against a colleague's simulator.
|
|
74
|
+
//
|
|
75
|
+
// `doctor` got a guard for its own fan-out and this default did not, which
|
|
76
|
+
// is how the same command set could pick two different devices in one
|
|
77
|
+
// moment. Refusing is the only safe answer here: the seam cannot see which
|
|
78
|
+
// device simframe is already driving (that is store state, above the
|
|
79
|
+
// boundary), and a backend must never guess when the cost of guessing wrong
|
|
80
|
+
// is a tap on somebody else's screen. `ambiguous` so that a second platform
|
|
81
|
+
// matching cleanly cannot override this — see resolveAcross.
|
|
82
|
+
if (booted.length > 1) {
|
|
83
|
+
throw Object.assign(
|
|
84
|
+
new Error(
|
|
85
|
+
`${booted.length} simulators are booted and none was named: ` +
|
|
86
|
+
`${booted.map((d) => `${d.name} (${d.udid})`).join(', ')} — name one with --device, ` +
|
|
87
|
+
'or set SIMFRAME_DEVICE to pick a default for this shell',
|
|
88
|
+
),
|
|
89
|
+
{ ambiguous: true },
|
|
90
|
+
);
|
|
91
|
+
}
|
|
69
92
|
return booted[0];
|
|
70
93
|
}
|
|
71
94
|
const q = query.toLowerCase();
|