simframe 0.10.0 → 0.12.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 +181 -2
- package/data/vocabulary/en.json +148 -0
- package/native/ocr.swift +13 -1
- package/native/rank.swift +87 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +72 -4
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +27 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +4 -0
- package/native/simframed/Sources/SimframeCore/CaptureRecovery.swift +19 -0
- package/native/simframed/Sources/simframed/main.swift +33 -3
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +41 -0
- package/native/supervise.swift +216 -0
- package/package.json +4 -1
- package/scripts/check-package.mjs +22 -2
- package/scripts/check-private.mjs +9 -0
- package/scripts/ci-integration-local.sh +79 -0
- package/scripts/ci-memory.mjs +104 -20
- package/scripts/collect-rulings.mjs +312 -0
- package/scripts/eval-fingerprint.mjs +100 -23
- package/scripts/eval-perception.mjs +33 -0
- package/scripts/phase17-corpus.mjs +176 -0
- package/scripts/probe-network.mjs +118 -0
- package/scripts/soak-capture.mjs +72 -0
- package/skills/simframe/SKILL.md +257 -7
- package/src/actions.js +1791 -44
- package/src/cli.js +216 -14
- package/src/control.js +1 -0
- package/src/fingerprint.js +43 -1
- package/src/graph.js +136 -7
- package/src/index.js +252 -14
- package/src/input.js +66 -3
- package/src/localhelper.js +161 -0
- package/src/matching.js +64 -1
- package/src/mcp.js +333 -32
- package/src/metrics.js +148 -3
- package/src/ocr.js +18 -1
- package/src/planner.js +195 -0
- package/src/platform/android.js +25 -1
- package/src/platform/index.js +11 -1
- package/src/platform/ios.js +25 -1
- package/src/png.js +26 -0
- package/src/refs.js +51 -8
- package/src/regions.js +215 -1
- package/src/screenmap.js +89 -9
- package/src/supervisor.js +161 -0
- package/src/view.js +375 -11
- package/src/vocabulary.js +134 -0
- package/src/wrote.js +136 -0
|
@@ -36,6 +36,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
36
36
|
import * as fingerprint from '../src/fingerprint.js';
|
|
37
37
|
import * as matching from '../src/matching.js';
|
|
38
38
|
import * as analyze from '../src/analyze.js';
|
|
39
|
+
import * as view from '../src/view.js';
|
|
39
40
|
|
|
40
41
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
41
42
|
const DIR = path.join(ROOT, 'test', 'perception', 'screens');
|
|
@@ -68,6 +69,7 @@ export function checkScreen(fx) {
|
|
|
68
69
|
const authored = (expect.resolutions?.length ?? 0)
|
|
69
70
|
+ (expect.ambiguous?.length ?? 0)
|
|
70
71
|
+ (expect.none?.length ?? 0)
|
|
72
|
+
+ (expect.discoverable?.length ?? 0)
|
|
71
73
|
+ (fx.frame_pairs?.length ?? 0)
|
|
72
74
|
+ (expect.identity ? 1 : 0);
|
|
73
75
|
|
|
@@ -118,6 +120,37 @@ export function checkScreen(fx) {
|
|
|
118
120
|
}
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
// Discovery: does the default map still SAY what is on this screen?
|
|
124
|
+
//
|
|
125
|
+
// This check exists because of a regression it would have caught. A rule that
|
|
126
|
+
// dropped long non-interactive rows left every React Native list card out of
|
|
127
|
+
// the map — the cards expose their children as one concatenated accessibility
|
|
128
|
+
// label, so they look exactly like a Settings caption. Nothing became
|
|
129
|
+
// untappable, because `locate` reads targets rather than rows, so every
|
|
130
|
+
// resolution expectation above still passed. What was lost was the map's
|
|
131
|
+
// account of what is there, and an agent that cannot see a row falls back to
|
|
132
|
+
// a ~1600-token screenshot.
|
|
133
|
+
//
|
|
134
|
+
// Resolution and discovery are different claims, and the harness could only
|
|
135
|
+
// make the first one.
|
|
136
|
+
if (expect.discoverable?.length && screen) {
|
|
137
|
+
const { rows } = view.rowsFor({ targets }, { screen });
|
|
138
|
+
const shown = rows.map((r) => String(r.label ?? ''));
|
|
139
|
+
for (const want of expect.discoverable) {
|
|
140
|
+
// Compared as a prefix, because a long label is legitimately truncated in
|
|
141
|
+
// a row — truncated is discoverable, absent is not.
|
|
142
|
+
const head = want.slice(0, 24);
|
|
143
|
+
if (!shown.some((got) => got.startsWith(head))) {
|
|
144
|
+
findings.push({
|
|
145
|
+
kind: 'discovery',
|
|
146
|
+
query: `${want.slice(0, 40)}${want.length > 40 ? '…' : ''}`,
|
|
147
|
+
want: 'listed in the default map',
|
|
148
|
+
got: `${rows.length} row(s), none starting with it`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
121
154
|
// Identity drift. A token-rule change that silently alters a screen's
|
|
122
155
|
// structural hash discards every stored map and graph for it, and the failure
|
|
123
156
|
// is invisible — an old hash is a well-formed hash that matches nothing.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Phase 17, go/no-go step 1: build the corpus and ask whether there is a job.
|
|
4
|
+
*
|
|
5
|
+
* The plan in docs/PHASES-HUMAN-PARITY.md said to export 200 `ambiguous_intent`
|
|
6
|
+
* and `no_plan` escalations. That corpus cannot be assembled: `no_plan` has
|
|
7
|
+
* never been logged once, `ambiguous_intent` is the reason the ranking bug
|
|
8
|
+
* produced (so the pre-fix records encode a bug), and the session id is minted
|
|
9
|
+
* per process — so a CLI-driven agent gets one "session" per command and the
|
|
10
|
+
* "filter to one session id" step has nothing to filter.
|
|
11
|
+
*
|
|
12
|
+
* There is a better corpus and it was there all along. Every verified graph edge
|
|
13
|
+
* is a decision that *worked*: the goal is in `step`, the screen it was taken on
|
|
14
|
+
* is the node, and the element list for that screen is in the screen map. That
|
|
15
|
+
* is (goal, element list, action) for every successful step, not just the rare
|
|
16
|
+
* failures — and the ground truth is stronger, because the tap was verified.
|
|
17
|
+
*
|
|
18
|
+
* What this measures is the question the whole phase turns on: of the decisions
|
|
19
|
+
* an agent actually makes, how many does the local matcher already resolve? A
|
|
20
|
+
* local planner can only earn its keep on the remainder. If the remainder is
|
|
21
|
+
* small, Phase 17 is a no-go regardless of how good the model is.
|
|
22
|
+
*
|
|
23
|
+
* Output is aggregate by design. This reads a real device's memory of real
|
|
24
|
+
* third-party apps, so it prints counts and never a label, an app name or a
|
|
25
|
+
* screen's contents. See the standing rule in scripts/check-private.mjs.
|
|
26
|
+
*
|
|
27
|
+
* Usage: node scripts/phase17-corpus.mjs [--device <udid>] [--json]
|
|
28
|
+
*/
|
|
29
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
import { homedir } from 'node:os';
|
|
32
|
+
import * as matching from '../src/matching.js';
|
|
33
|
+
import { goalOf } from '../src/actions.js';
|
|
34
|
+
|
|
35
|
+
const ROOT = process.env.SIMFRAME_HOME || join(homedir(), '.simframe');
|
|
36
|
+
|
|
37
|
+
const readJson = (p) => {
|
|
38
|
+
try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; }
|
|
39
|
+
};
|
|
40
|
+
const listDir = (p) => (existsSync(p) ? readdirSync(p).filter((f) => f.endsWith('.json')) : []);
|
|
41
|
+
|
|
42
|
+
/** Screens are keyed by frame hash; graph nodes carry a layoutHash. Index both. */
|
|
43
|
+
function screenIndex(dev) {
|
|
44
|
+
const idx = new Map();
|
|
45
|
+
for (const f of listDir(join(dev, 'screens'))) {
|
|
46
|
+
const s = readJson(join(dev, 'screens', f));
|
|
47
|
+
if (!s?.targets?.length) continue;
|
|
48
|
+
for (const k of ['layoutHash', 'structuralHash', 'hash']) {
|
|
49
|
+
if (s[k] && !idx.has(s[k])) idx.set(s[k], s);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return idx;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Only some steps involve choosing an element. `launch` names an app, `button`
|
|
57
|
+
* names hardware, `openUrl` names a URL, `scroll` names a direction — none of
|
|
58
|
+
* them is a decision a planner could help with, and counting them was inflating
|
|
59
|
+
* the "not found" bucket to 45% on the first run of this script.
|
|
60
|
+
*/
|
|
61
|
+
const CHOOSES_AN_ELEMENT = new Set(['tap', 'type', 'scroll_to', 'scrollTo', 'assert', 'swipe_from', 'longPress']);
|
|
62
|
+
|
|
63
|
+
/** A "#3" is answered by the ref table and a "@x,y" by arithmetic. Neither is a decision. */
|
|
64
|
+
const isSelectorNotIntent = (goal) => /^#\d+$/.test(goal) || /^@?-?\d+\s*,\s*-?\d+$/.test(goal);
|
|
65
|
+
|
|
66
|
+
export function classify(dev) {
|
|
67
|
+
const idx = screenIndex(dev);
|
|
68
|
+
const out = {
|
|
69
|
+
screens_stored: idx.size,
|
|
70
|
+
edges: 0,
|
|
71
|
+
unjoinable: 0,
|
|
72
|
+
no_goal: 0,
|
|
73
|
+
not_an_element_step: 0,
|
|
74
|
+
selector_not_intent: 0,
|
|
75
|
+
cases: 0,
|
|
76
|
+
resolved: 0,
|
|
77
|
+
ambiguous: 0,
|
|
78
|
+
none: 0,
|
|
79
|
+
};
|
|
80
|
+
for (const f of listDir(join(dev, 'graph'))) {
|
|
81
|
+
const g = readJson(join(dev, 'graph', f));
|
|
82
|
+
const screen = idx.get(g?.layoutHash) ?? idx.get(g?.hash);
|
|
83
|
+
for (const e of g?.edges ?? []) {
|
|
84
|
+
out.edges += 1;
|
|
85
|
+
if (!screen) { out.unjoinable += 1; continue; }
|
|
86
|
+
const goal = goalOf(e.step);
|
|
87
|
+
if (!goal) { out.no_goal += 1; continue; }
|
|
88
|
+
if (!CHOOSES_AN_ELEMENT.has(e.step?.action)) { out.not_an_element_step += 1; continue; }
|
|
89
|
+
if (isSelectorNotIntent(String(goal).trim())) { out.selector_not_intent += 1; continue; }
|
|
90
|
+
out.cases += 1;
|
|
91
|
+
const r = matching.resolve(screen.targets, String(goal));
|
|
92
|
+
if (r.status === 'ok') out.resolved += 1;
|
|
93
|
+
else if (r.status === 'ambiguous') out.ambiguous += 1;
|
|
94
|
+
else out.none += 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The second instrument, with the opposite bias.
|
|
102
|
+
*
|
|
103
|
+
* A verified edge is a decision that *succeeded*, so the graph cannot see the
|
|
104
|
+
* ones the matcher fumbled — those became escalations, Claude fixed them, and
|
|
105
|
+
* the edge was written with the corrected goal. Measuring the matcher on its
|
|
106
|
+
* own successes is partly circular. So also count resolution failures from the
|
|
107
|
+
* log against total edge traversals: same question, denominator built from
|
|
108
|
+
* failures instead of successes.
|
|
109
|
+
*/
|
|
110
|
+
export function resolutionFailureRate(dev) {
|
|
111
|
+
let traversals = 0;
|
|
112
|
+
for (const f of listDir(join(dev, 'graph'))) {
|
|
113
|
+
for (const e of readJson(join(dev, 'graph', f))?.edges ?? []) traversals += e.count ?? 0;
|
|
114
|
+
}
|
|
115
|
+
let failures = 0;
|
|
116
|
+
let escalations = 0;
|
|
117
|
+
const log = join(dev, 'escalations.jsonl');
|
|
118
|
+
if (existsSync(log)) {
|
|
119
|
+
for (const line of readFileSync(log, 'utf8').split('\n')) {
|
|
120
|
+
if (!line.trim()) continue;
|
|
121
|
+
let r;
|
|
122
|
+
try { r = JSON.parse(line); } catch { continue; }
|
|
123
|
+
escalations += 1;
|
|
124
|
+
if (r.reason === 'ambiguous_intent' || r.reason === 'unknown_screen') failures += 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { traversals, escalations, failures };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const flags = process.argv.slice(2);
|
|
131
|
+
const only = flags.includes('--device') ? flags[flags.indexOf('--device') + 1] : null;
|
|
132
|
+
const devices = (existsSync(ROOT) ? readdirSync(ROOT) : [])
|
|
133
|
+
.filter((d) => !d.startsWith('TEST-') && d !== 'bin')
|
|
134
|
+
.filter((d) => existsSync(join(ROOT, d, 'graph')))
|
|
135
|
+
.filter((d) => !only || d === only);
|
|
136
|
+
|
|
137
|
+
const totals = { cases: 0, resolved: 0, ambiguous: 0, none: 0, edges: 0, unjoinable: 0, not_an_element_step: 0, selector_not_intent: 0 };
|
|
138
|
+
const rows = [];
|
|
139
|
+
for (const d of devices) {
|
|
140
|
+
const r = classify(join(ROOT, d));
|
|
141
|
+
if (!r.edges) continue;
|
|
142
|
+
rows.push({ device: `${d.slice(0, 8)}…`, ...r, ...resolutionFailureRate(join(ROOT, d)) });
|
|
143
|
+
for (const k of Object.keys(totals)) totals[k] += r[k];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (flags.includes('--json')) {
|
|
147
|
+
console.log(JSON.stringify({ devices: rows, totals }, null, 2));
|
|
148
|
+
} else {
|
|
149
|
+
console.log('Phase 17 corpus — verified graph edges as (goal, element list, action)\n');
|
|
150
|
+
for (const r of rows) {
|
|
151
|
+
console.log(` ${r.device} edges ${r.edges} joinable ${r.edges - r.unjoinable} decisions ${r.cases}` +
|
|
152
|
+
` → matcher ok ${r.resolved}, ambiguous ${r.ambiguous}, not found ${r.none}`);
|
|
153
|
+
}
|
|
154
|
+
const { cases, resolved, ambiguous, none } = totals;
|
|
155
|
+
const pct = (n) => (cases ? `${Math.round((n / cases) * 1000) / 10}%` : '—');
|
|
156
|
+
console.log(`\n ${totals.edges} edges: ${totals.unjoinable} unjoinable, ` +
|
|
157
|
+
`${totals.not_an_element_step} chose no element (launch/button/openUrl/scroll), ` +
|
|
158
|
+
`${totals.selector_not_intent} used a #ref or a coordinate.`);
|
|
159
|
+
console.log(` TOTAL element decisions: ${cases}`);
|
|
160
|
+
console.log(` already resolved locally ${resolved} ${pct(resolved)} <- a planner adds nothing here`);
|
|
161
|
+
console.log(` ambiguous ${ambiguous} ${pct(ambiguous)} <- a planner could pick`);
|
|
162
|
+
console.log(` not found ${none} ${pct(none)} <- a planner cannot invent an element`);
|
|
163
|
+
const addressable = ambiguous;
|
|
164
|
+
console.log(`\n Addressable by a local planner: ${addressable} of ${cases} (${pct(addressable)}).`);
|
|
165
|
+
|
|
166
|
+
console.log('\nSecond instrument — resolution failures from the log, per traversal.');
|
|
167
|
+
console.log('A rate over 100% means the graph was discarded while the log kept appending');
|
|
168
|
+
console.log('(a MAP_VERSION or GRAPH_VERSION bump), so it is not a rate. Read the device');
|
|
169
|
+
console.log('that was driven by an agent doing real work, not the bench device.\n');
|
|
170
|
+
for (const r of rows) {
|
|
171
|
+
const rate = r.traversals ? `${Math.round((r.failures / r.traversals) * 1000) / 10}%` : '—';
|
|
172
|
+
console.log(` ${r.device} traversals ${String(r.traversals).padStart(4)}` +
|
|
173
|
+
` escalations ${String(r.escalations).padStart(4)}` +
|
|
174
|
+
` resolution failures ${String(r.failures).padStart(3)} ${rate.padStart(6)}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Minimal CDP client over a hand-rolled WebSocket — no dependencies, because
|
|
2
|
+
// simframe has exactly one runtime dependency and this would not be it.
|
|
3
|
+
//
|
|
4
|
+
// Node 20 has no global WebSocket, so the upgrade and the framing are done by
|
|
5
|
+
// hand. Item 80 records that the upgrade 401s without an `Origin` matching the
|
|
6
|
+
// inspector's host; this sends one.
|
|
7
|
+
import http from 'node:http';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
const wsUrl = process.argv[2];
|
|
11
|
+
const seconds = Number(process.argv[3] ?? 20);
|
|
12
|
+
const u = new URL(wsUrl);
|
|
13
|
+
|
|
14
|
+
const key = crypto.randomBytes(16).toString('base64');
|
|
15
|
+
const req = http.request({
|
|
16
|
+
hostname: u.hostname,
|
|
17
|
+
port: u.port,
|
|
18
|
+
path: u.pathname + u.search,
|
|
19
|
+
headers: {
|
|
20
|
+
Connection: 'Upgrade',
|
|
21
|
+
Upgrade: 'websocket',
|
|
22
|
+
'Sec-WebSocket-Key': key,
|
|
23
|
+
'Sec-WebSocket-Version': '13',
|
|
24
|
+
Origin: `http://${u.host}`,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** A client frame must be masked; the server's are not. */
|
|
29
|
+
const frame = (text) => {
|
|
30
|
+
const payload = Buffer.from(text, 'utf8');
|
|
31
|
+
const mask = crypto.randomBytes(4);
|
|
32
|
+
const head = payload.length < 126
|
|
33
|
+
? Buffer.from([0x81, 0x80 | payload.length])
|
|
34
|
+
: Buffer.concat([Buffer.from([0x81, 0xfe]), (() => { const b = Buffer.alloc(2); b.writeUInt16BE(payload.length); return b; })()]);
|
|
35
|
+
const masked = Buffer.from(payload);
|
|
36
|
+
for (let i = 0; i < masked.length; i += 1) masked[i] ^= mask[i % 4];
|
|
37
|
+
return Buffer.concat([head, mask, masked]);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
req.on('upgrade', (res, socket) => {
|
|
41
|
+
console.log(`upgraded: ${res.statusCode} ${res.headers.upgrade ?? ''}`);
|
|
42
|
+
let buf = Buffer.alloc(0);
|
|
43
|
+
let id = 0;
|
|
44
|
+
const send = (method, params) => {
|
|
45
|
+
id += 1;
|
|
46
|
+
socket.write(frame(JSON.stringify({ id, method, params })));
|
|
47
|
+
console.log(`-> ${method}`);
|
|
48
|
+
return id;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const seen = new Map();
|
|
52
|
+
socket.on('data', (chunk) => {
|
|
53
|
+
buf = Buffer.concat([buf, chunk]);
|
|
54
|
+
// Enough framing to read small unfragmented text frames, which is all CDP
|
|
55
|
+
// sends here. Deliberately not a WebSocket implementation.
|
|
56
|
+
for (;;) {
|
|
57
|
+
if (buf.length < 2) return;
|
|
58
|
+
const len0 = buf[1] & 0x7f;
|
|
59
|
+
let offset = 2;
|
|
60
|
+
let len = len0;
|
|
61
|
+
if (len0 === 126) { if (buf.length < 4) return; len = buf.readUInt16BE(2); offset = 4; }
|
|
62
|
+
else if (len0 === 127) { if (buf.length < 10) return; len = Number(buf.readBigUInt64BE(2)); offset = 10; }
|
|
63
|
+
if (buf.length < offset + len) return;
|
|
64
|
+
const text = buf.slice(offset, offset + len).toString('utf8');
|
|
65
|
+
buf = buf.slice(offset + len);
|
|
66
|
+
try {
|
|
67
|
+
const msg = JSON.parse(text);
|
|
68
|
+
if (msg.method) {
|
|
69
|
+
seen.set(msg.method, (seen.get(msg.method) ?? 0) + 1);
|
|
70
|
+
// The contents are the whole question: "settled, and these three
|
|
71
|
+
// requests fired with these statuses" is only sayable if the status
|
|
72
|
+
// and the URL are actually in here.
|
|
73
|
+
if (msg.method.startsWith('Network.')) {
|
|
74
|
+
const p = msg.params ?? {};
|
|
75
|
+
const bits = [
|
|
76
|
+
p.requestId && `id=${p.requestId}`,
|
|
77
|
+
p.request?.method && `${p.request.method} ${p.request.url}`,
|
|
78
|
+
p.response && `status=${p.response.status} ${p.response.mimeType ?? ''}`,
|
|
79
|
+
p.response?.timing && 'timing=yes',
|
|
80
|
+
p.encodedDataLength != null && `bytes=${p.encodedDataLength}`,
|
|
81
|
+
p.type && `type=${p.type}`,
|
|
82
|
+
].filter(Boolean);
|
|
83
|
+
console.log(` ${msg.method}: ${bits.join(' ')}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else console.log(`<- reply #${msg.id} ${msg.error ? `ERROR ${JSON.stringify(msg.error)}` : JSON.stringify(msg.result).slice(0, 120)}`);
|
|
87
|
+
} catch { /* not JSON; ignore */ }
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
send('Network.enable', {});
|
|
92
|
+
send('Runtime.enable', {});
|
|
93
|
+
send('Log.enable', {});
|
|
94
|
+
|
|
95
|
+
// Trigger real traffic through the app's own JS runtime, rather than through
|
|
96
|
+
// its UI. This isolates the question — does React Native's networking layer
|
|
97
|
+
// report to CDP at all — from whether my app happens to call anything, and it
|
|
98
|
+
// needs no screen, so it does not fight whatever else is driving the device.
|
|
99
|
+
setTimeout(() => {
|
|
100
|
+
send('Runtime.evaluate', {
|
|
101
|
+
expression: "fetch('https://jsonplaceholder.typicode.com/todos/1')"
|
|
102
|
+
+ ".then(r => r.json()).then(j => 'FETCHED ' + JSON.stringify(j))",
|
|
103
|
+
awaitPromise: true,
|
|
104
|
+
returnByValue: true,
|
|
105
|
+
});
|
|
106
|
+
}, 1500);
|
|
107
|
+
|
|
108
|
+
setTimeout(() => {
|
|
109
|
+
console.log('\nevents received, by method:');
|
|
110
|
+
if (!seen.size) console.log(' (none)');
|
|
111
|
+
for (const [m, n] of [...seen].sort((a, b) => b[1] - a[1])) console.log(` ${String(n).padStart(4)}x ${m}`);
|
|
112
|
+
socket.destroy();
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}, seconds * 1000);
|
|
115
|
+
});
|
|
116
|
+
req.on('response', (res) => { console.log(`NOT upgraded: ${res.statusCode}`); process.exit(1); });
|
|
117
|
+
req.on('error', (e) => { console.log('error:', e.message); process.exit(1); });
|
|
118
|
+
req.end();
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// How long can this device be driven before capture wedges?
|
|
3
|
+
//
|
|
4
|
+
// The wedge is the thing standing between us and a ruling population: the
|
|
5
|
+
// display stops rendering after a few minutes of hard driving, `simctl
|
|
6
|
+
// screenshot` fails too, both of the daemon's recoveries fail, and only a
|
|
7
|
+
// device restart cures it. Three times in one afternoon.
|
|
8
|
+
//
|
|
9
|
+
// A leak was found in our own code on 2026-09-12 — damage-callback registration
|
|
10
|
+
// was not idempotent, and the recovery loop called it on every attempt, so one
|
|
11
|
+
// log's 670 port re-resolves meant up to 670 live callbacks on one port, each
|
|
12
|
+
// invoked per redraw. That is a real leak with a plausible path to saturating
|
|
13
|
+
// the display service, and it is **not proof** that it is the cause. This is
|
|
14
|
+
// how we find out: drive until it dies, and report how long that took.
|
|
15
|
+
//
|
|
16
|
+
// node scripts/soak-capture.mjs --device=<udid> --minutes=25
|
|
17
|
+
//
|
|
18
|
+
// A number to compare against, not a pass/fail. Before the fix, the device
|
|
19
|
+
// wedged roughly every 10-20 minutes of this kind of work.
|
|
20
|
+
import * as actions from '../src/actions.js';
|
|
21
|
+
import * as api from '../src/index.js';
|
|
22
|
+
import * as store from '../src/store.js';
|
|
23
|
+
|
|
24
|
+
const arg = (n, d) => {
|
|
25
|
+
const hit = process.argv.find((a) => a.startsWith(`--${n}=`));
|
|
26
|
+
return hit ? hit.slice(n.length + 3) : d;
|
|
27
|
+
};
|
|
28
|
+
const device = arg('device');
|
|
29
|
+
const minutes = Number(arg('minutes', 20));
|
|
30
|
+
const BUNDLE = 'com.example.simframetestbed';
|
|
31
|
+
|
|
32
|
+
const { device: dev } = await api.ensureDaemon(device);
|
|
33
|
+
console.log(`device: ${dev.name} (${dev.runtime}) budget: ${minutes} minutes`);
|
|
34
|
+
|
|
35
|
+
const started = Date.now();
|
|
36
|
+
const deadline = started + minutes * 60_000;
|
|
37
|
+
let laps = 0;
|
|
38
|
+
let reads = 0;
|
|
39
|
+
const elapsed = () => ((Date.now() - started) / 60_000).toFixed(1);
|
|
40
|
+
|
|
41
|
+
// A lap is deliberately the kind of work that provokes it: launching, moving
|
|
42
|
+
// between screens, and a cold read on each — not idling with a poll.
|
|
43
|
+
const LAP = [
|
|
44
|
+
[{ launch: { value: BUNDLE, relaunch: true } }, { pause: 2500 }],
|
|
45
|
+
[{ tap: 'Forms, tab, 2 of 3' }, { pause: 800 }],
|
|
46
|
+
[{ tap: 'Long form' }, { pause: 900 }],
|
|
47
|
+
[{ scroll: 'down' }, { pause: 500 }],
|
|
48
|
+
[{ scroll: 'down' }, { pause: 500 }],
|
|
49
|
+
[{ tap: 'Plants, tab, 1 of 3' }, { pause: 900 }],
|
|
50
|
+
[{ tap: 'Diagnostics, tab, 3 of 3' }, { pause: 900 }],
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
while (Date.now() < deadline) {
|
|
54
|
+
for (const steps of LAP) {
|
|
55
|
+
try {
|
|
56
|
+
await actions.runScript(device, { steps, verify: false, options: { supervisor: 'none' } });
|
|
57
|
+
} catch { /* a failed step is not the subject; a dead display is */ }
|
|
58
|
+
try {
|
|
59
|
+
await api.screenIdentity(device, { fresh: true, confirmNovel: false });
|
|
60
|
+
reads += 1;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.log(`\nWEDGED after ${elapsed()} minutes, ${laps} laps, ${reads} cold reads`);
|
|
63
|
+
console.log(` ${String(err.message).split('\n')[0]}`);
|
|
64
|
+
const health = store.captureHealth(dev.udid);
|
|
65
|
+
if (health) console.log(` captureHealth: ${JSON.stringify(health)}`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
laps += 1;
|
|
70
|
+
if (laps % 5 === 0) process.stdout.write(` ${elapsed()}m ${laps} laps ${reads} reads still alive\n`);
|
|
71
|
+
}
|
|
72
|
+
console.log(`\nSURVIVED ${minutes} minutes: ${laps} laps, ${reads} cold reads, no wedge`);
|