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.
- package/README.md +334 -85
- package/native/simframed/Package.swift +16 -0
- package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +523 -0
- package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
- package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +149 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +112 -0
- package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
- package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
- package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
- package/native/simframed/Sources/SimframeCore/Element.swift +148 -0
- package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
- package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
- package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
- package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
- package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
- package/native/simframed/Sources/simframed/main.swift +485 -0
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +270 -0
- package/package.json +12 -4
- package/scripts/bench-flow.mjs +54 -0
- package/scripts/bench.sh +98 -0
- package/scripts/check-package.mjs +99 -0
- package/scripts/ci-memory.mjs +416 -0
- package/scripts/eval-fingerprint.mjs +192 -0
- package/scripts/smoke.mjs +76 -0
- package/scripts/sync-server-version.mjs +39 -0
- package/scripts/verify-baseline.mjs +65 -0
- package/skills/simframe/SKILL.md +173 -0
- package/src/actions.js +264 -18
- package/src/cli.js +561 -89
- package/src/control.js +77 -0
- package/src/daemon.js +8 -1
- package/src/engine.js +99 -0
- package/src/fingerprint.js +183 -0
- package/src/graph.js +411 -0
- package/src/index.js +351 -24
- package/src/input.js +179 -2
- package/src/matching.js +265 -0
- package/src/mcp.js +425 -112
- package/src/navigate.js +120 -0
- package/src/refs.js +141 -0
- package/src/regions.js +267 -0
- package/src/screenmap.js +119 -22
- package/src/simctl.js +74 -5
- package/src/store.js +8 -0
- 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.
|