simframe 0.4.1 → 0.5.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 +227 -53
- package/native/simframed/Package.swift +16 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +449 -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 +117 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +89 -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 +120 -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 +357 -0
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +240 -0
- package/package.json +10 -4
- package/scripts/bench-flow.mjs +54 -0
- package/scripts/bench.sh +98 -0
- package/scripts/check-package.mjs +91 -0
- package/scripts/smoke.mjs +76 -0
- package/scripts/verify-baseline.mjs +65 -0
- package/src/actions.js +82 -5
- package/src/cli.js +347 -31
- package/src/control.js +76 -0
- package/src/daemon.js +8 -1
- package/src/engine.js +99 -0
- package/src/fingerprint.js +150 -0
- package/src/graph.js +409 -0
- package/src/index.js +292 -23
- package/src/input.js +80 -2
- package/src/matching.js +194 -0
- package/src/mcp.js +45 -1
- package/src/navigate.js +120 -0
- package/src/regions.js +90 -0
- package/src/screenmap.js +77 -21
- package/src/simctl.js +20 -4
- package/src/store.js +8 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Times a realistic navigation flow end to end, and reports where the time went.
|
|
2
|
+
// Used to check that the input driver works at all, and to measure sim_do.
|
|
3
|
+
import { runScript } from '../src/actions.js';
|
|
4
|
+
import * as input from '../src/input.js';
|
|
5
|
+
import * as api from '../src/index.js';
|
|
6
|
+
import { launchApp, terminateApp } from '../src/simctl.js';
|
|
7
|
+
|
|
8
|
+
const BUNDLE = process.argv[2];
|
|
9
|
+
const device = process.argv[3];
|
|
10
|
+
|
|
11
|
+
if (!BUNDLE) {
|
|
12
|
+
console.error('usage: node scripts/bench-flow.mjs <bundle-id> [device]');
|
|
13
|
+
process.exit(2);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const driver = await input.detectDriver();
|
|
17
|
+
console.log(`driver: ${driver.available ? driver.version : driver.reason}`);
|
|
18
|
+
if (!driver.available) process.exit(1);
|
|
19
|
+
|
|
20
|
+
const { device: dev } = await api.ensureDaemon(device);
|
|
21
|
+
console.log(`device: ${dev.name} (${dev.runtime})`);
|
|
22
|
+
const geo = await input.screenInfo(dev.udid);
|
|
23
|
+
console.log(`screen: ${geo.pixelWidth}x${geo.pixelHeight}px @${geo.density}x = ${geo.pointWidth}x${geo.pointHeight}pt\n`);
|
|
24
|
+
|
|
25
|
+
const t0 = Date.now();
|
|
26
|
+
try { await terminateApp(dev.udid, BUNDLE); } catch { /* not running */ }
|
|
27
|
+
await launchApp(dev.udid, BUNDLE);
|
|
28
|
+
const launched = await api.waitFor(device, { mode: 'settle', stableMs: 800, timeoutMs: 20_000 });
|
|
29
|
+
console.log(`cold launch + settle: ${Date.now() - t0}ms (satisfied=${launched.satisfied})`);
|
|
30
|
+
|
|
31
|
+
const nodes = await input.describeAll(dev.udid);
|
|
32
|
+
console.log(`accessibility tree: ${nodes.length} elements`);
|
|
33
|
+
const labelled = nodes.filter((n) => n.label && n.frame.width > 8 && n.frame.height > 8);
|
|
34
|
+
console.log('\nlabelled elements (type, tap point, label):');
|
|
35
|
+
for (const n of labelled.slice(0, 40)) {
|
|
36
|
+
const c = input.centerOf(n);
|
|
37
|
+
console.log(` ${(n.type || '?').padEnd(14)} ${String(`${c.x},${c.y}`).padEnd(10)} ${n.label}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Anything sitting in the bottom eighth of the screen is probably a tab bar.
|
|
41
|
+
const tabs = labelled.filter((n) => n.frame.y > geo.pointHeight * 0.86);
|
|
42
|
+
console.log(`\nlikely tab bar: ${tabs.map((t) => t.label).join(', ') || '(none found)'}`);
|
|
43
|
+
|
|
44
|
+
if (tabs.length >= 2) {
|
|
45
|
+
const steps = tabs.slice(0, 4).map((t) => ({ tap: t.label }));
|
|
46
|
+
console.log(`\nrunning ${steps.length} tab switches as ONE sim_do call...`);
|
|
47
|
+
const t1 = Date.now();
|
|
48
|
+
const res = await runScript(device, { steps, stableMs: 500, timeoutMs: 10_000 });
|
|
49
|
+
for (const r of res.results) {
|
|
50
|
+
console.log(` ${r.ok ? 'ok ' : 'FAIL'} ${r.action}: ${r.ok ? r.detail : r.error}` +
|
|
51
|
+
(r.settled ? ` (settled ${r.settled.waitedMs}ms, changed=${r.settled.sawChange})` : ''));
|
|
52
|
+
}
|
|
53
|
+
console.log(`\n${res.ok ? 'flow completed' : 'FLOW FAILED'} — ${res.ranSteps}/${res.totalSteps} steps in ${Date.now() - t1}ms`);
|
|
54
|
+
}
|
package/scripts/bench.sh
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Reproduces every number in docs/BENCHMARKS.md.
|
|
3
|
+
#
|
|
4
|
+
# Needs a booted simulator. Numbers are medians; the first sample of anything
|
|
5
|
+
# involving Vision is discarded, because its warm-up dwarfs the measurement.
|
|
6
|
+
set -uo pipefail
|
|
7
|
+
cd "$(dirname "$0")/.."
|
|
8
|
+
|
|
9
|
+
DEVICE="${1:-}"
|
|
10
|
+
BIN=native/simframed/.build/release/simframed
|
|
11
|
+
|
|
12
|
+
say() { printf '\n\033[1m%s\033[0m\n' "$*"; }
|
|
13
|
+
have() { command -v "$1" >/dev/null 2>&1; }
|
|
14
|
+
|
|
15
|
+
say "environment"
|
|
16
|
+
printf ' chip %s\n' "$(sysctl -n machdep.cpu.brand_string 2>/dev/null)"
|
|
17
|
+
printf ' macOS %s\n' "$(sw_vers -productVersion)"
|
|
18
|
+
printf ' Xcode %s\n' "$(xcodebuild -version 2>/dev/null | head -1)"
|
|
19
|
+
|
|
20
|
+
say "building simframed (release)"
|
|
21
|
+
swift build -c release --package-path native/simframed 2>&1 | tail -1
|
|
22
|
+
|
|
23
|
+
UDID="$DEVICE"
|
|
24
|
+
if [ -z "$UDID" ]; then
|
|
25
|
+
UDID=$($BIN devices | head -1 | awk '{print $1}')
|
|
26
|
+
fi
|
|
27
|
+
if [ -z "$UDID" ]; then
|
|
28
|
+
echo " no booted simulator; boot one and re-run" >&2
|
|
29
|
+
exit 1
|
|
30
|
+
fi
|
|
31
|
+
printf ' device %s\n' "$($BIN devices | grep "$UDID" | cut -c40-)"
|
|
32
|
+
|
|
33
|
+
say "capture pipeline (Phase 0)"
|
|
34
|
+
$BIN bench --udid="$UDID" --n=300 | sed 's/^/ /'
|
|
35
|
+
$BIN bench --udid="$UDID" --n=300 --max-dim=420 | sed 's/^/ /'
|
|
36
|
+
|
|
37
|
+
say "simctl + sips, the path it replaces"
|
|
38
|
+
for _ in 1 2 3 4 5; do
|
|
39
|
+
/usr/bin/time -p sh -c "xcrun simctl io $UDID screenshot --type=png --mask=ignored /tmp/bench.png >/dev/null 2>&1 && sips -Z 700 /tmp/bench.png --out /tmp/bench-s.png >/dev/null 2>&1" 2>&1 | grep real
|
|
40
|
+
done | awk '{s+=$2; n++} END {printf " mean %.0f ms over %d runs\n", s/n*1000, n}'
|
|
41
|
+
|
|
42
|
+
say "hash compatibility (Swift vs JavaScript, needs a still screen)"
|
|
43
|
+
node -e '
|
|
44
|
+
const { execSync } = require("child_process");
|
|
45
|
+
(async () => {
|
|
46
|
+
const api = await import("./src/index.js");
|
|
47
|
+
const { hashDistance } = await import("./src/analyze.js");
|
|
48
|
+
let a, b;
|
|
49
|
+
for (let i = 0; i < 25; i++) {
|
|
50
|
+
a = (await api.getState(undefined, {})).state;
|
|
51
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
52
|
+
b = (await api.getState(undefined, {})).state;
|
|
53
|
+
if (a.hash === b.hash && a.layoutHash === b.layoutHash) break;
|
|
54
|
+
}
|
|
55
|
+
if (a.hash !== b.hash) { console.log(" screen never settled; skipped"); return; }
|
|
56
|
+
const sw = JSON.parse(execSync("native/simframed/.build/release/simframed hash").toString());
|
|
57
|
+
console.log(` frameHash delta ${hashDistance(b.hash, sw.hash)}/128`);
|
|
58
|
+
console.log(` layoutHash delta ${hashDistance(b.layoutHash, sw.layoutHash)}/288`);
|
|
59
|
+
})()' 2>/dev/null
|
|
60
|
+
|
|
61
|
+
say "control socket round trips (Phase 1)"
|
|
62
|
+
node -e '
|
|
63
|
+
import("./src/control.js").then(async (c) => {
|
|
64
|
+
const UD = process.argv[1];
|
|
65
|
+
const med = a => { const s=[...a].sort((x,y)=>x-y); return s[s.length>>1]; };
|
|
66
|
+
const bench = async (label, fn, n) => {
|
|
67
|
+
const t = []; for (let i=0;i<n;i++){ const t0=Date.now(); await fn(i); t.push(Date.now()-t0); }
|
|
68
|
+
console.log(` ${label.padEnd(22)} n=${String(n).padStart(3)} median ${med(t)}ms`);
|
|
69
|
+
};
|
|
70
|
+
await bench("ping", () => c.request(UD,{action:"ping"}), 100);
|
|
71
|
+
await bench("status", () => c.status(UD), 50);
|
|
72
|
+
await bench("tap (70ms hold)", i => c.tap(UD, 200+(i%3), 300), 100);
|
|
73
|
+
await bench("tap (10ms hold)", i => c.tap(UD, 200+(i%3), 300, {durationMs:10}), 100);
|
|
74
|
+
})' "$UDID" 2>/dev/null
|
|
75
|
+
|
|
76
|
+
say "text recognition (Phase 2)"
|
|
77
|
+
node -e '
|
|
78
|
+
import("./src/control.js").then(async (c) => {
|
|
79
|
+
const UD = process.argv[1];
|
|
80
|
+
const med = a => { const s=[...a].sort((x,y)=>x-y); return s[s.length>>1]; };
|
|
81
|
+
const t=[]; for (let i=0;i<12;i++){ const t0=Date.now(); await c.request(UD,{action:"ui"}); t.push(Date.now()-t0); }
|
|
82
|
+
// Drop the first: Vision warm-up dwarfs it.
|
|
83
|
+
console.log(` in-process first ${t[0]}ms median ${med(t.slice(1))}ms`);
|
|
84
|
+
})' "$UDID" 2>/dev/null
|
|
85
|
+
|
|
86
|
+
say "screen map build (Phase 2)"
|
|
87
|
+
node -e '
|
|
88
|
+
import("./src/index.js").then(async (api) => {
|
|
89
|
+
const UD = process.argv[1];
|
|
90
|
+
for (const pass of [1,2,3]) {
|
|
91
|
+
api.screenmap.forget(UD);
|
|
92
|
+
const t0 = Date.now();
|
|
93
|
+
try { await api.locate(undefined, "a", {}); } catch { /* the label need not exist */ }
|
|
94
|
+
console.log(` build ${pass}: ${Date.now()-t0}ms`);
|
|
95
|
+
}
|
|
96
|
+
})' "$UDID" 2>/dev/null
|
|
97
|
+
|
|
98
|
+
say "done — compare against docs/BENCHMARKS.md"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Does the tarball contain everything the installed tool needs?
|
|
3
|
+
//
|
|
4
|
+
// This is the cheapest catch for the two worst regressions this project has
|
|
5
|
+
// shipped, both of which passed every test and printed nothing:
|
|
6
|
+
//
|
|
7
|
+
// - `Tests/` was absent, so Package.swift declared a test target with no
|
|
8
|
+
// directory, SwiftPM reported overlapping sources, and every install
|
|
9
|
+
// silently fell back to the simctl engine.
|
|
10
|
+
// - `native/ocr.swift` was absent, so OCR was silently off for all users.
|
|
11
|
+
//
|
|
12
|
+
// The required list is DERIVED from what the build actually reads, never
|
|
13
|
+
// hand-maintained. A hand-written list is how `files` rotted in the first
|
|
14
|
+
// place: it was correct when written and wrong three commits later. Add a new
|
|
15
|
+
// Swift source or a new module here and it becomes required automatically.
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const rel = (p) => path.relative(ROOT, p).split(path.sep).join('/');
|
|
23
|
+
|
|
24
|
+
function walk(dir, filter, out = []) {
|
|
25
|
+
let entries;
|
|
26
|
+
try {
|
|
27
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
28
|
+
} catch {
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
for (const e of entries) {
|
|
32
|
+
const full = path.join(dir, e.name);
|
|
33
|
+
// .build holds compiled output, not inputs, and is never published.
|
|
34
|
+
if (e.isDirectory()) {
|
|
35
|
+
if (e.name === '.build' || e.name === 'node_modules') continue;
|
|
36
|
+
walk(full, filter, out);
|
|
37
|
+
} else if (filter(full)) {
|
|
38
|
+
out.push(full);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Everything the SwiftPM build and the Node runtime read at install or run time. */
|
|
45
|
+
function requiredFiles() {
|
|
46
|
+
const swiftPackage = path.join(ROOT, 'native', 'simframed');
|
|
47
|
+
const required = new Set();
|
|
48
|
+
|
|
49
|
+
// The SwiftPM manifest, and every source under the targets it declares.
|
|
50
|
+
required.add(rel(path.join(swiftPackage, 'Package.swift')));
|
|
51
|
+
for (const dir of ['Sources', 'Tests']) {
|
|
52
|
+
for (const f of walk(path.join(swiftPackage, dir), (p) => p.endsWith('.swift'))) {
|
|
53
|
+
required.add(rel(f));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Test targets are build inputs whether or not anyone runs them: a declared
|
|
57
|
+
// target with a missing directory fails the whole build.
|
|
58
|
+
if (!walk(path.join(swiftPackage, 'Tests'), (p) => p.endsWith('.swift')).length) {
|
|
59
|
+
throw new Error('no Swift test sources found — has the layout moved? This check would silently pass.');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The standalone OCR helper, compiled on demand by src/ocr.js.
|
|
63
|
+
required.add(rel(path.join(ROOT, 'native', 'ocr.swift')));
|
|
64
|
+
|
|
65
|
+
// Every runtime module. `files` ships "src" wholesale today; asserting each
|
|
66
|
+
// one catches anybody narrowing that later.
|
|
67
|
+
for (const f of walk(path.join(ROOT, 'src'), (p) => p.endsWith('.js'))) required.add(rel(f));
|
|
68
|
+
|
|
69
|
+
// Anything package.json points at to run.
|
|
70
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
|
71
|
+
for (const target of Object.values(pkg.bin ?? {})) required.add(target.replace(/^\.\//, ''));
|
|
72
|
+
if (pkg.main) required.add(pkg.main.replace(/^\.\//, ''));
|
|
73
|
+
|
|
74
|
+
return [...required].sort();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const required = requiredFiles();
|
|
78
|
+
const packed = JSON.parse(execFileSync('npm', ['pack', '--dry-run', '--json'], { cwd: ROOT, encoding: 'utf8' }));
|
|
79
|
+
const shipped = new Set(packed[0].files.map((f) => f.path));
|
|
80
|
+
const missing = required.filter((f) => !shipped.has(f));
|
|
81
|
+
|
|
82
|
+
console.log(`${required.length} build inputs required, ${shipped.size} files in the tarball`);
|
|
83
|
+
for (const f of missing) console.log(`MISSING ${f}`);
|
|
84
|
+
if (missing.length) {
|
|
85
|
+
console.error(
|
|
86
|
+
`\n${missing.length} file(s) the build needs are absent from the published package.` +
|
|
87
|
+
`\nThis does not fail a build or a test — it fails silently for whoever installs it.`,
|
|
88
|
+
);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
console.log('ok — every build input ships');
|
|
@@ -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,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();
|
package/src/actions.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Waiting uses a baseline captured BEFORE each action, which is the whole
|
|
4
4
|
// reason these scripts are reliable rather than racy.
|
|
5
5
|
import * as api from './index.js';
|
|
6
|
+
import * as graph from './graph.js';
|
|
6
7
|
import * as input from './input.js';
|
|
7
8
|
import * as intent from './intent.js';
|
|
8
9
|
import { launchApp, openUrl, setPasteboard, terminateApp } from './simctl.js';
|
|
@@ -24,12 +25,32 @@ export function normalizeStep(raw) {
|
|
|
24
25
|
// Siblings like timeoutMs sit alongside the shorthand key and must survive.
|
|
25
26
|
const { [key]: value, ...rest } = raw;
|
|
26
27
|
const inline = value && typeof value === 'object' && !Array.isArray(value) ? value : { value };
|
|
27
|
-
|
|
28
|
+
const step = { ...rest, ...inline, action: key };
|
|
29
|
+
// Drop keys that are present but undefined. `simframe tap X` used to pass
|
|
30
|
+
// `index: undefined`, which survived here and then crashed the signature
|
|
31
|
+
// builder before the tap was ever sent.
|
|
32
|
+
for (const k of Object.keys(step)) if (step[k] === undefined) delete step[k];
|
|
33
|
+
return step;
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
export async function runScript(
|
|
31
37
|
deviceQuery,
|
|
32
|
-
{
|
|
38
|
+
{
|
|
39
|
+
steps,
|
|
40
|
+
autoSettle = true,
|
|
41
|
+
stableMs = 500,
|
|
42
|
+
timeoutMs = 8000,
|
|
43
|
+
confirmNovel = true,
|
|
44
|
+
continueOnError = false,
|
|
45
|
+
// Check each action against what it did last time, and remember what it
|
|
46
|
+
// does this time. On by default: a flow that cannot tell a wrong turn from
|
|
47
|
+
// a right one is worse than no flow.
|
|
48
|
+
verify = true,
|
|
49
|
+
// Stop when a verified step lands somewhere it should not have. A flow
|
|
50
|
+
// continuing past a wrong turn taps controls on a screen nobody intended.
|
|
51
|
+
stopOnUnexpected = true,
|
|
52
|
+
options,
|
|
53
|
+
} = {},
|
|
33
54
|
) {
|
|
34
55
|
if (!Array.isArray(steps) || !steps.length) throw new Error('a script needs at least one step');
|
|
35
56
|
const { device } = await api.ensureDaemon(deviceQuery, options);
|
|
@@ -38,7 +59,11 @@ export async function runScript(
|
|
|
38
59
|
|
|
39
60
|
const needsInput = steps.some((s) => ACTION_STEPS.has(normalizeStep(s).action));
|
|
40
61
|
if (needsInput) {
|
|
41
|
-
|
|
62
|
+
// driverFor, not detectDriver: detectDriver asks specifically whether idb
|
|
63
|
+
// is installed, so every batch flow demanded idb even on a machine where
|
|
64
|
+
// the daemon was doing the input perfectly well. Single-step `simframe tap`
|
|
65
|
+
// already went through driverFor, so `tap` worked and `do` did not.
|
|
66
|
+
const driver = await input.driverFor(udid);
|
|
42
67
|
if (!driver.available) throw new Error(driver.reason);
|
|
43
68
|
}
|
|
44
69
|
|
|
@@ -48,12 +73,25 @@ export async function runScript(
|
|
|
48
73
|
const results = [];
|
|
49
74
|
const frames = [];
|
|
50
75
|
let failed = false;
|
|
76
|
+
let carriedScreen = null;
|
|
51
77
|
|
|
52
78
|
for (const [i, raw] of steps.entries()) {
|
|
53
79
|
const step = normalizeStep(raw);
|
|
54
80
|
const stepStart = Date.now();
|
|
55
81
|
// The baseline for "did the screen react" must predate the action itself.
|
|
56
|
-
const
|
|
82
|
+
const beforeState = (await api.getState(deviceQuery, { options })).state;
|
|
83
|
+
const before = beforeState.hash;
|
|
84
|
+
// Identity is structural: a list with new rows is the same screen, and the
|
|
85
|
+
// pixel hash cannot say so.
|
|
86
|
+
// The screen this step starts on is the screen the last one ended on —
|
|
87
|
+
// nothing happens in between. Recomputing it cost a full perception pass
|
|
88
|
+
// per step for an answer already in hand.
|
|
89
|
+
const beforeScreen = verify
|
|
90
|
+
? (carriedScreen ?? await api.screenIdentity(deviceQuery, { options, settleMs: stableMs, timeoutMs, confirmNovel }))
|
|
91
|
+
: null;
|
|
92
|
+
carriedScreen = null;
|
|
93
|
+
// What this action did last time it was taken here, if ever.
|
|
94
|
+
const prediction = verify && beforeScreen?.hash ? graph.predict(udid, beforeScreen, step) : null;
|
|
57
95
|
try {
|
|
58
96
|
const detail = await runStep(deviceQuery, udid, step, { screen, options, frames });
|
|
59
97
|
let settled = null;
|
|
@@ -73,15 +111,51 @@ export async function runScript(
|
|
|
73
111
|
noVisibleChange: Boolean(w.noVisibleChange),
|
|
74
112
|
};
|
|
75
113
|
}
|
|
114
|
+
// Verify against what was predicted, and remember what actually
|
|
115
|
+
// happened. Without this a step that moved the screen the wrong way
|
|
116
|
+
// reports success, and the flow carries on believing it worked.
|
|
117
|
+
let verification = null;
|
|
118
|
+
if (verify && ACTION_STEPS.has(step.action) && beforeScreen?.hash) {
|
|
119
|
+
const afterState = (await api.getState(deviceQuery, { options })).state;
|
|
120
|
+
const kind = afterState.transition?.kind;
|
|
121
|
+
const afterScreen = await api.screenIdentity(deviceQuery, { options, settleMs: stableMs, timeoutMs, confirmNovel });
|
|
122
|
+
verification = {
|
|
123
|
+
...graph.verdict({ udid, prediction, before: beforeScreen.hash, after: afterScreen.hash, kind }),
|
|
124
|
+
predicted: prediction ? { to: prediction.to.slice(0, 10), kind: prediction.kind, seen: prediction.count } : null,
|
|
125
|
+
observed: { to: afterScreen.hash?.slice(0, 10), kind },
|
|
126
|
+
};
|
|
127
|
+
// Only remember what was seen on a settled screen: an edge recorded
|
|
128
|
+
// mid-transition points at a screen that never really existed.
|
|
129
|
+
// `confirmed` already means the fingerprint held still across two
|
|
130
|
+
// independent readings, which is the thing `settled` was standing in
|
|
131
|
+
// for. Requiring both meant a screen that settled slowly recorded
|
|
132
|
+
// nothing at all.
|
|
133
|
+
if (afterScreen.confirmed && afterScreen.hash) {
|
|
134
|
+
graph.record(udid, { from: beforeScreen, action: step, to: afterScreen, kind });
|
|
135
|
+
carriedScreen = afterScreen;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Only an unexpected *screen* stops a flow. `unexpected-transition` is no
|
|
140
|
+
// longer a verdict at all — a noisy classifier disagreeing about whether
|
|
141
|
+
// a tab switch was a push or a pop is not a reason to call a correct
|
|
142
|
+
// navigation wrong.
|
|
143
|
+
const wrongTurn = verification?.verdict === 'unexpected-screen';
|
|
76
144
|
const note = settled?.noVisibleChange ? ' [no visible change]' : '';
|
|
77
145
|
results.push({
|
|
78
146
|
index: i,
|
|
79
147
|
action: step.action,
|
|
148
|
+
verification,
|
|
80
149
|
ok: true,
|
|
81
150
|
ms: Date.now() - stepStart,
|
|
82
|
-
detail: `${detail}${note}`,
|
|
151
|
+
detail: `${detail}${note}${wrongTurn ? ` [${verification.verdict}: ${verification.detail}]` : ''}`,
|
|
83
152
|
settled,
|
|
84
153
|
});
|
|
154
|
+
if (wrongTurn && stopOnUnexpected && !continueOnError) {
|
|
155
|
+
results[results.length - 1].ok = false;
|
|
156
|
+
results[results.length - 1].error = `${verification.verdict}: ${verification.detail}`;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
85
159
|
} catch (err) {
|
|
86
160
|
results.push({ index: i, action: step.action, ok: false, ms: Date.now() - stepStart, error: err.message });
|
|
87
161
|
failed = true;
|
|
@@ -91,6 +165,9 @@ export async function runScript(
|
|
|
91
165
|
|
|
92
166
|
return {
|
|
93
167
|
device,
|
|
168
|
+
// Returned so a run that verified end to end can be handed straight to
|
|
169
|
+
// navigate.saveFlow without the caller reassembling what it just ran.
|
|
170
|
+
steps,
|
|
94
171
|
results,
|
|
95
172
|
ok: !failed,
|
|
96
173
|
totalMs: Date.now() - startedAt,
|