simframe 0.18.0 → 0.19.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 +132 -1101
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +12 -6
- package/native/simframed/Sources/simframed/main.swift +18 -2
- package/package.json +1 -1
- package/scripts/article-md.mjs +111 -45
- package/scripts/bench-hpi.mjs +45 -1
- package/scripts/ci-memory.mjs +33 -6
- package/scripts/demo-gif/README.md +36 -0
- package/scripts/demo-gif/compose.swift +106 -0
- package/scripts/demo-gif/events.example.json +74 -0
- package/scripts/demo-gif/flow.json +6 -0
- package/scripts/smithery/icon.png +0 -0
- package/scripts/smithery-bundle.mjs +80 -0
- package/scripts/smithery-metadata.mjs +47 -0
- package/scripts/sync-server-version.mjs +19 -6
- package/skills/simframe/SKILL.md +7 -0
- package/src/actions.js +339 -33
- package/src/cli.js +46 -27
- package/src/device-state.js +37 -0
- package/src/index.js +144 -12
- package/src/mcp.js +2 -2
- package/src/platform/cdp.js +242 -0
- package/src/platform/index.js +28 -2
- package/src/refs.js +18 -4
- package/src/store.js +39 -0
- package/src/view.js +6 -2
- package/src/wedge.js +154 -2
package/src/index.js
CHANGED
|
@@ -1397,22 +1397,76 @@ export async function getFrameAt(deviceQuery, { msAgo = 0, options } = {}) {
|
|
|
1397
1397
|
* Wait, briefly, for a frame that is holding still. Returns whatever the newest
|
|
1398
1398
|
* frame is once the screen settles or the budget runs out, saying which.
|
|
1399
1399
|
*/
|
|
1400
|
-
|
|
1400
|
+
/**
|
|
1401
|
+
* When the stillness a state reports began, or null when that cannot be told.
|
|
1402
|
+
*
|
|
1403
|
+
* `stableForMs` is measured as of the frame's capture, so the quiet period
|
|
1404
|
+
* started `stableForMs` before `capturedAt`. Exported because the rule built on
|
|
1405
|
+
* it below is the interesting part and deserves to be testable without a device.
|
|
1406
|
+
*/
|
|
1407
|
+
export function stillnessBegan(state) {
|
|
1408
|
+
if (!Number.isFinite(state?.capturedAt) || !Number.isFinite(state?.stableForMs)) return null;
|
|
1409
|
+
return state.capturedAt - state.stableForMs;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Has this screen been still *since we acted*, or was it still before we did?
|
|
1414
|
+
*
|
|
1415
|
+
* The distinction the settle detector was missing. It answers "how long has the
|
|
1416
|
+
* screen been quiet" honestly and has no idea that the quiet it is describing
|
|
1417
|
+
* belongs to the screen the caller has just left.
|
|
1418
|
+
*
|
|
1419
|
+
* Only a stillness we can **prove** predates the action is rejected. When the
|
|
1420
|
+
* timestamps are missing this returns true, which is the previous behaviour —
|
|
1421
|
+
* this may only ever add refusals it can demonstrate, never turn an unknown
|
|
1422
|
+
* into a wait.
|
|
1423
|
+
*/
|
|
1424
|
+
export function stillSinceActing(state, actedAt) {
|
|
1425
|
+
if (!Number.isFinite(actedAt)) return true;
|
|
1426
|
+
const began = stillnessBegan(state);
|
|
1427
|
+
if (began == null) return true;
|
|
1428
|
+
return began >= actedAt;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
export async function settledState(udid, { settleMs = MEMORY_SETTLE_MS, timeoutMs = 1500, since } = {}) {
|
|
1401
1432
|
const p = store.paths(udid);
|
|
1402
1433
|
const deadline = Date.now() + timeoutMs;
|
|
1434
|
+
// What we are settling *after*. A caller that knows may say; otherwise it is
|
|
1435
|
+
// the last launch, openUrl or gesture this device was given.
|
|
1436
|
+
const actedAt = since ?? store.lastActionAt(udid);
|
|
1403
1437
|
let state = store.readJson(p.state);
|
|
1438
|
+
let stale = false;
|
|
1404
1439
|
while (Date.now() < deadline) {
|
|
1405
1440
|
state = store.readJson(p.state) ?? state;
|
|
1406
|
-
//
|
|
1407
|
-
//
|
|
1408
|
-
//
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1441
|
+
// **Stillness from before the action is not settlement.** Measured on the
|
|
1442
|
+
// bench device: 283ms after a Settings launch the state said `settled` with
|
|
1443
|
+
// `stableForMs: 4427` over a screen holding **zero** elements, which filled
|
|
1444
|
+
// 2.3 seconds later; 408ms after `tap General` it said `settled` with
|
|
1445
|
+
// `stableForMs: 7753` over the 23 elements of Settings root. Both readings
|
|
1446
|
+
// were honest about the number and wrong about the screen.
|
|
1447
|
+
//
|
|
1448
|
+
// Field-reported independently as the highest-priority class here: a read
|
|
1449
|
+
// that returns chrome with the content missing and no loading marker is
|
|
1450
|
+
// indistinguishable from a screen that is genuinely empty, so an agent
|
|
1451
|
+
// reports "this filter returns zero results" and means it.
|
|
1452
|
+
const fresh = stillSinceActing(state, actedAt);
|
|
1453
|
+
if (!fresh) stale = true;
|
|
1454
|
+
else {
|
|
1455
|
+
// The daemon runs a real settle detector that can tell a spinner from a
|
|
1456
|
+
// still screen. Prefer it; the duration check is the fallback for the
|
|
1457
|
+
// simctl engine, which has no such thing.
|
|
1458
|
+
if (state?.settled === true) return { state, settled: true, waitedForAction: stale };
|
|
1459
|
+
if (state && state.settled === undefined && state.stableForMs >= settleMs) {
|
|
1460
|
+
return { state, settled: true, waitedForAction: stale };
|
|
1461
|
+
}
|
|
1412
1462
|
}
|
|
1413
1463
|
await sleep(40);
|
|
1414
1464
|
}
|
|
1415
|
-
|
|
1465
|
+
// `settled: false` is not a failure and never was — callers use the state and
|
|
1466
|
+
// decline to persist a map built from it. What is new is that this can now be
|
|
1467
|
+
// false because the screen has not been seen to move since the action, which
|
|
1468
|
+
// is a different and more honest reason than "it is still moving".
|
|
1469
|
+
return { state, settled: false, ...(stale ? { stillnessPredatesAction: true } : {}) };
|
|
1416
1470
|
}
|
|
1417
1471
|
|
|
1418
1472
|
/**
|
|
@@ -1459,6 +1513,33 @@ export function offScreenMatch(targets, query, points) {
|
|
|
1459
1513
|
return hit.status === 'ambiguous' && hit.alternatives?.length ? hit.alternatives[0] : null;
|
|
1460
1514
|
}
|
|
1461
1515
|
|
|
1516
|
+
/**
|
|
1517
|
+
* Which way a target is outside the viewport, and by which measure.
|
|
1518
|
+
*
|
|
1519
|
+
* `offViewport` has checked both axes since it was written; the sentence
|
|
1520
|
+
* reporting it only ever named `y` and the screen *height*. So a tab in a
|
|
1521
|
+
* horizontally-scrolling strip was reported as *"it is at y=143 on a 874pt
|
|
1522
|
+
* screen"* — a coordinate plainly inside the screen, next to a conclusion that
|
|
1523
|
+
* it is not in view, with four of its siblings visible. Field-reported, and the
|
|
1524
|
+
* reporter's summary is the right one: the honest half was right and only the
|
|
1525
|
+
* axis was wrong.
|
|
1526
|
+
*
|
|
1527
|
+
* Vertical is checked first because it is overwhelmingly the common case, and
|
|
1528
|
+
* because `scrollTo` reasons vertically — a caller told "below the fold" and a
|
|
1529
|
+
* caller told "past the right edge" do different things next, which is the
|
|
1530
|
+
* whole reason to say which.
|
|
1531
|
+
*/
|
|
1532
|
+
export function offScreenAxis(target, points) {
|
|
1533
|
+
const { width, height } = points ?? {};
|
|
1534
|
+
if (Number.isFinite(height) && (target.y < 0 || target.y > height)) {
|
|
1535
|
+
return { axis: 'y', at: Math.round(target.y), extent: Math.round(height), edge: target.y < 0 ? 'above' : 'below' };
|
|
1536
|
+
}
|
|
1537
|
+
if (Number.isFinite(width) && (target.x < 0 || target.x > width)) {
|
|
1538
|
+
return { axis: 'x', at: Math.round(target.x), extent: Math.round(width), edge: target.x < 0 ? 'left of' : 'right of' };
|
|
1539
|
+
}
|
|
1540
|
+
return null;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1462
1543
|
/**
|
|
1463
1544
|
* Which sensors a read asks for by default.
|
|
1464
1545
|
*
|
|
@@ -1500,9 +1581,52 @@ export function sensorMode(options) {
|
|
|
1500
1581
|
*/
|
|
1501
1582
|
const nameFor = (t) => t.label || t.identifier || null;
|
|
1502
1583
|
|
|
1584
|
+
/**
|
|
1585
|
+
* Did screen memory alone produce this miss?
|
|
1586
|
+
*
|
|
1587
|
+
* A recall that finds the screen and not the target tags `ambiguous_intent`
|
|
1588
|
+
* without `ambiguous` — "I know this screen, and what you asked for is not on
|
|
1589
|
+
* it". That is the only miss worth re-asking, because it is the only one whose
|
|
1590
|
+
* answer came from a file rather than from the device. A miss off a map that
|
|
1591
|
+
* was just built has already read both sensors, and a genuine ambiguity is not
|
|
1592
|
+
* settled by reading again.
|
|
1593
|
+
*/
|
|
1594
|
+
export function memoryMiss(err) {
|
|
1595
|
+
const why = metrics.escalationOf(err);
|
|
1596
|
+
return Boolean(why && why.reason === 'ambiguous_intent' && !why.ambiguous);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1503
1599
|
export async function locate(deviceQuery, query, opts = {}) {
|
|
1504
1600
|
if (sensorMode(opts.options) !== 'ax-first' || opts.useOcr === false || opts.escalated) {
|
|
1505
|
-
return locateWith(deviceQuery, query, opts);
|
|
1601
|
+
if (opts.refresh || opts.escalated) return locateWith(deviceQuery, query, opts);
|
|
1602
|
+
try {
|
|
1603
|
+
return await locateWith(deviceQuery, query, opts);
|
|
1604
|
+
} catch (err) {
|
|
1605
|
+
// **Memory may confirm, never deny.** Measured on the bench device, 8 of
|
|
1606
|
+
// 8 and 4 of 4 in two separate runs: at the instant a flow's next step
|
|
1607
|
+
// asks, a recall says "not on this screen" in 25 ms and a fresh read
|
|
1608
|
+
// finds the target 1.8 s later, on the same device, without anything
|
|
1609
|
+
// touching it in between.
|
|
1610
|
+
//
|
|
1611
|
+
// Why the recall is wrong, and it is not staleness in the usual sense:
|
|
1612
|
+
// the frame it keys on was captured 47-124 ms *after* the tap — the first
|
|
1613
|
+
// frame of the push animation, which still looks like the screen being
|
|
1614
|
+
// left. Capture is damage-driven, so that frame then goes still,
|
|
1615
|
+
// `settledState` calls it settled at 500-700 ms of stillness, and
|
|
1616
|
+
// `recallNearest` — deliberately tolerant, because a list with new rows is
|
|
1617
|
+
// still the same screen — matches it back to the previous screen and
|
|
1618
|
+
// answers out of that screen's stored element list. The screen itself
|
|
1619
|
+
// arrives about 750 ms later.
|
|
1620
|
+
//
|
|
1621
|
+
// The cost is paid only on a miss, which today aborts the batch and buys
|
|
1622
|
+
// a ~20 s model round trip. 1.8 s to be sure is the cheaper mistake. The
|
|
1623
|
+
// hit path — the one the speed argument rests on — is untouched.
|
|
1624
|
+
//
|
|
1625
|
+
// This recovery already existed for `ax-first` (below) and had never run
|
|
1626
|
+
// in the default sensor mode, which is `full`.
|
|
1627
|
+
if (!memoryMiss(err)) throw err;
|
|
1628
|
+
return locateWith(deviceQuery, query, { ...opts, refresh: true, escalated: true });
|
|
1629
|
+
}
|
|
1506
1630
|
}
|
|
1507
1631
|
const options = opts;
|
|
1508
1632
|
try {
|
|
@@ -1723,8 +1847,12 @@ async function locateWith(
|
|
|
1723
1847
|
if (offScreen) {
|
|
1724
1848
|
throw metrics.tag(
|
|
1725
1849
|
new Error(
|
|
1726
|
-
`"${query}" is in the tree but not in view —
|
|
1727
|
-
|
|
1850
|
+
`"${query}" is in the tree but not in view — ${(() => {
|
|
1851
|
+
const off = offScreenAxis(offScreen, points);
|
|
1852
|
+
if (!off) return `it is at ${Math.round(offScreen.x)},${Math.round(offScreen.y)}`;
|
|
1853
|
+
return `it is ${off.edge} the viewport, at ${off.axis}=${off.at}`
|
|
1854
|
+
+ ` on a ${off.extent}pt ${off.axis === 'y' ? 'tall' : 'wide'} screen`;
|
|
1855
|
+
})()}. Scroll to it (sim_scroll_to) rather than waiting;`
|
|
1728
1856
|
+ ' waiting cannot bring it into view.',
|
|
1729
1857
|
),
|
|
1730
1858
|
from === 'memory' ? 'ambiguous_intent' : 'unknown_screen',
|
|
@@ -1861,7 +1989,7 @@ export async function screenIdentity(deviceQuery, { options, confirmNovel = true
|
|
|
1861
1989
|
// calling a screen unsettled while the flow was still happily waiting for
|
|
1862
1990
|
// it — and an unsettled screen records no edge, so the graph learned
|
|
1863
1991
|
// nothing and every later step read `unverified`.
|
|
1864
|
-
const { state: settledFrame, settled } = await settledState(udid, {
|
|
1992
|
+
const { state: settledFrame, settled, stillnessPredatesAction } = await settledState(udid, {
|
|
1865
1993
|
settleMs,
|
|
1866
1994
|
timeoutMs: Math.min(timeoutMs ?? IDENTITY_SETTLE_TIMEOUT_MS, IDENTITY_SETTLE_TIMEOUT_MS),
|
|
1867
1995
|
});
|
|
@@ -1884,6 +2012,10 @@ export async function screenIdentity(deviceQuery, { options, confirmNovel = true
|
|
|
1884
2012
|
keyboard: Boolean(entry.keyboard),
|
|
1885
2013
|
layoutHash: current.layoutHash,
|
|
1886
2014
|
settled,
|
|
2015
|
+
// Unsettled for the opposite reason to moving: nothing has changed since
|
|
2016
|
+
// the action at all. Both used to render as `STILL MOVING`, which told an
|
|
2017
|
+
// agent to wait for a screen that a missed tap had left perfectly still.
|
|
2018
|
+
unmoved: !settled && Boolean(stillnessPredatesAction),
|
|
1887
2019
|
// Settled and incomplete are different states and used to render
|
|
1888
2020
|
// identically. A screen awaiting a network call is perfectly still; a
|
|
1889
2021
|
// person sees a spinner and knows to wait. The classifier already says
|
package/src/mcp.js
CHANGED
|
@@ -1010,7 +1010,7 @@ function stepLines(res) {
|
|
|
1010
1010
|
: ` · WARNING: ${r.settled.stalled ? 'capture stalled' : 'never settled'} after ${r.settled.waitedMs}ms`
|
|
1011
1011
|
: '';
|
|
1012
1012
|
lines.push(
|
|
1013
|
-
` ${r
|
|
1013
|
+
` ${actions.stepMark(r)} [${r.index}] ${r.action}: ${r.ok ? r.detail : r.error}${settle}`,
|
|
1014
1014
|
);
|
|
1015
1015
|
}
|
|
1016
1016
|
if (!res.ok) lines.push('later steps were not run; the screen is left wherever the failing step stopped');
|
|
@@ -1153,7 +1153,7 @@ async function goto(target, args, options) {
|
|
|
1153
1153
|
? [`already on "${res.screen}"`]
|
|
1154
1154
|
: [
|
|
1155
1155
|
`${res.ok ? 'arrived at' : 'DID NOT REACH'} "${res.screen}" in ${res.ranSteps}/${res.steps.length} remembered steps`,
|
|
1156
|
-
...(res.results ?? []).map((r) => ` ${r
|
|
1156
|
+
...(res.results ?? []).map((r) => ` ${actions.stepMark(r)} [${r.index}] ${r.action}: ${r.ok ? r.detail : r.error}`),
|
|
1157
1157
|
];
|
|
1158
1158
|
lines.push('', await mapFrom(target, options, null));
|
|
1159
1159
|
return { content: [text(lines.join('\n'))], isError: !res.ok };
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// A dependency-free Chrome DevTools Protocol client.
|
|
2
|
+
//
|
|
3
|
+
// Hand-rolled rather than `ws`, and not because of ideology: CLAUDE.md keeps
|
|
4
|
+
// the runtime dependency list at exactly one (the MCP SDK), and the alternative
|
|
5
|
+
// does not exist anyway. The global `WebSocket` is **absent on Node 18 and
|
|
6
|
+
// flag-only on Node 20** — verified here on v20.20.0, where `typeof WebSocket`
|
|
7
|
+
// is `undefined` — and this package supports 18, 20 and 22. So the choice is
|
|
8
|
+
// between a dependency and forty lines of RFC 6455, and forty lines is smaller
|
|
9
|
+
// than the surface a dependency brings.
|
|
10
|
+
//
|
|
11
|
+
// The other thing a probe on 2026-09-11 established, recorded because it costs
|
|
12
|
+
// an afternoon to rediscover: Chrome refuses the upgrade with **401
|
|
13
|
+
// Unauthorized** when an `Origin` header is present and does not match the
|
|
14
|
+
// inspector's own host. A browser's own WebSocket API always sends one and
|
|
15
|
+
// cannot change it, which is why a page cannot drive CDP — and why a
|
|
16
|
+
// hand-rolled client, which simply omits the header, can.
|
|
17
|
+
//
|
|
18
|
+
// Scope: enough CDP to be the transport under `src/platform/web.js`. It speaks
|
|
19
|
+
// one target at a time, it does not multiplex sessions, and it has no
|
|
20
|
+
// reconnection policy. Anything cleverer belongs above the boundary or nowhere.
|
|
21
|
+
import net from 'node:net';
|
|
22
|
+
import crypto from 'node:crypto';
|
|
23
|
+
import http from 'node:http';
|
|
24
|
+
|
|
25
|
+
/** RFC 6455's fixed GUID, concatenated with the client key to prove the handshake. */
|
|
26
|
+
export const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
27
|
+
|
|
28
|
+
const OPCODE = { text: 0x1, binary: 0x2, close: 0x8, ping: 0x9, pong: 0xa };
|
|
29
|
+
|
|
30
|
+
/** Ask a browser what it has open. `targetId` is the udid: a tab is a device. */
|
|
31
|
+
export function listTargets(port, host = '127.0.0.1', { timeoutMs = 2000 } = {}) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const req = http.get({ host, port, path: '/json/list', timeout: timeoutMs }, (res) => {
|
|
34
|
+
let body = '';
|
|
35
|
+
res.on('data', (d) => { body += d; });
|
|
36
|
+
res.on('end', () => {
|
|
37
|
+
if (res.statusCode !== 200) {
|
|
38
|
+
reject(new Error(`the browser's debugging endpoint answered ${res.statusCode} — is it running with --remote-debugging-port=${port}?`));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try { resolve(JSON.parse(body)); } catch (err) { reject(new Error(`the debugging endpoint returned something that is not JSON: ${err.message}`)); }
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
req.on('timeout', () => { req.destroy(new Error(`no answer from ${host}:${port} within ${timeoutMs}ms`)); });
|
|
45
|
+
req.on('error', (err) => reject(new Error(
|
|
46
|
+
`cannot reach a browser on ${host}:${port} — ${err.message}.`
|
|
47
|
+
+ ' Start one with --remote-debugging-port, or pass --port.',
|
|
48
|
+
)));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One frame, encoded.
|
|
54
|
+
*
|
|
55
|
+
* Client frames must be masked — an unmasked client frame is a protocol error
|
|
56
|
+
* and Chrome closes the socket rather than answering, which reads as a hang.
|
|
57
|
+
*/
|
|
58
|
+
function encodeFrame(payload, opcode = OPCODE.text) {
|
|
59
|
+
const data = Buffer.from(payload, 'utf8');
|
|
60
|
+
const mask = crypto.randomBytes(4);
|
|
61
|
+
const len = data.length;
|
|
62
|
+
const header = len < 126
|
|
63
|
+
? Buffer.from([0x80 | opcode, 0x80 | len])
|
|
64
|
+
: len < 65536
|
|
65
|
+
? Buffer.concat([Buffer.from([0x80 | opcode, 0x80 | 126]), (() => { const b = Buffer.alloc(2); b.writeUInt16BE(len); return b; })()])
|
|
66
|
+
: Buffer.concat([Buffer.from([0x80 | opcode, 0x80 | 127]), (() => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(len)); return b; })()]);
|
|
67
|
+
const masked = Buffer.allocUnsafe(len);
|
|
68
|
+
for (let i = 0; i < len; i += 1) masked[i] = data[i] ^ mask[i % 4];
|
|
69
|
+
return Buffer.concat([header, mask, masked]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pull whole frames out of a growing buffer.
|
|
74
|
+
*
|
|
75
|
+
* Returns the frames it could complete and what is left over. CDP replies
|
|
76
|
+
* routinely exceed one TCP segment — a full accessibility tree is tens of
|
|
77
|
+
* kilobytes — so a reader that assumes one frame per `data` event works on a
|
|
78
|
+
* hello-world and fails on the first real payload.
|
|
79
|
+
*/
|
|
80
|
+
export function decodeFrames(buffer) {
|
|
81
|
+
const frames = [];
|
|
82
|
+
let rest = buffer;
|
|
83
|
+
for (;;) {
|
|
84
|
+
if (rest.length < 2) break;
|
|
85
|
+
const opcode = rest[0] & 0x0f;
|
|
86
|
+
const fin = Boolean(rest[0] & 0x80);
|
|
87
|
+
const masked = Boolean(rest[1] & 0x80);
|
|
88
|
+
let len = rest[1] & 0x7f;
|
|
89
|
+
let offset = 2;
|
|
90
|
+
if (len === 126) {
|
|
91
|
+
if (rest.length < 4) break;
|
|
92
|
+
len = rest.readUInt16BE(2);
|
|
93
|
+
offset = 4;
|
|
94
|
+
} else if (len === 127) {
|
|
95
|
+
if (rest.length < 10) break;
|
|
96
|
+
len = Number(rest.readBigUInt64BE(2));
|
|
97
|
+
offset = 10;
|
|
98
|
+
}
|
|
99
|
+
const maskKey = masked ? rest.subarray(offset, offset + 4) : null;
|
|
100
|
+
if (masked) offset += 4;
|
|
101
|
+
if (rest.length < offset + len) break;
|
|
102
|
+
let payload = rest.subarray(offset, offset + len);
|
|
103
|
+
if (maskKey) {
|
|
104
|
+
const out = Buffer.allocUnsafe(len);
|
|
105
|
+
for (let i = 0; i < len; i += 1) out[i] = payload[i] ^ maskKey[i % 4];
|
|
106
|
+
payload = out;
|
|
107
|
+
}
|
|
108
|
+
frames.push({ opcode, fin, payload });
|
|
109
|
+
rest = rest.subarray(offset + len);
|
|
110
|
+
}
|
|
111
|
+
return { frames, rest };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Open a CDP session against a websocket URL from `listTargets`.
|
|
116
|
+
*
|
|
117
|
+
* Resolves once the upgrade is accepted, so a caller that gets a connection has
|
|
118
|
+
* a usable one — the failure mode this avoids is a `send` that queues silently
|
|
119
|
+
* against a socket the browser refused.
|
|
120
|
+
*/
|
|
121
|
+
export function connect(wsUrl, { timeoutMs = 5000 } = {}) {
|
|
122
|
+
const url = new URL(wsUrl);
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const key = crypto.randomBytes(16).toString('base64');
|
|
125
|
+
const socket = net.connect({ host: url.hostname, port: Number(url.port || 80) });
|
|
126
|
+
let settled = false;
|
|
127
|
+
const fail = (err) => {
|
|
128
|
+
if (settled) return;
|
|
129
|
+
settled = true;
|
|
130
|
+
socket.destroy();
|
|
131
|
+
reject(err);
|
|
132
|
+
};
|
|
133
|
+
const timer = setTimeout(() => fail(new Error(`the browser did not complete the websocket upgrade within ${timeoutMs}ms`)), timeoutMs);
|
|
134
|
+
|
|
135
|
+
socket.on('error', (err) => fail(new Error(`cannot open a debugging socket: ${err.message}`)));
|
|
136
|
+
socket.on('connect', () => {
|
|
137
|
+
// No `Origin` header, deliberately. Chrome answers 401 when one is
|
|
138
|
+
// present and does not match the inspector's host, and nothing here needs
|
|
139
|
+
// to claim an origin.
|
|
140
|
+
socket.write(
|
|
141
|
+
`GET ${url.pathname}${url.search} HTTP/1.1\r\n`
|
|
142
|
+
+ `Host: ${url.host}\r\n`
|
|
143
|
+
+ 'Upgrade: websocket\r\nConnection: Upgrade\r\n'
|
|
144
|
+
+ `Sec-WebSocket-Key: ${key}\r\n`
|
|
145
|
+
+ 'Sec-WebSocket-Version: 13\r\n\r\n',
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
let handshake = Buffer.alloc(0);
|
|
150
|
+
const onHandshake = (chunk) => {
|
|
151
|
+
handshake = Buffer.concat([handshake, chunk]);
|
|
152
|
+
const end = handshake.indexOf('\r\n\r\n');
|
|
153
|
+
if (end === -1) return;
|
|
154
|
+
const head = handshake.subarray(0, end).toString('latin1');
|
|
155
|
+
const status = /^HTTP\/1\.1 (\d+)/.exec(head)?.[1];
|
|
156
|
+
if (status !== '101') {
|
|
157
|
+
fail(new Error(
|
|
158
|
+
`the browser refused the debugging socket with HTTP ${status ?? '(no status)'}.`
|
|
159
|
+
+ (status === '401'
|
|
160
|
+
? ' That is the Origin check: Chrome rejects an upgrade whose Origin header does not'
|
|
161
|
+
+ ' match the inspector host. This client sends none, so something else set one.'
|
|
162
|
+
: ' Check the target still exists — a closed tab keeps its id but not its socket.'),
|
|
163
|
+
));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const accept = /sec-websocket-accept:\s*(\S+)/i.exec(head)?.[1];
|
|
167
|
+
const expected = crypto.createHash('sha1').update(key + WS_GUID).digest('base64');
|
|
168
|
+
if (accept !== expected) {
|
|
169
|
+
fail(new Error('the browser accepted the upgrade with a key that does not match — this is not a websocket peer'));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
settled = true;
|
|
174
|
+
socket.removeListener('data', onHandshake);
|
|
175
|
+
resolve(session(socket, handshake.subarray(end + 4)));
|
|
176
|
+
};
|
|
177
|
+
socket.on('data', onHandshake);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A connected session: one id space, one pending map, text frames only. */
|
|
182
|
+
function session(socket, leftover) {
|
|
183
|
+
let buffer = leftover ?? Buffer.alloc(0);
|
|
184
|
+
let nextId = 1;
|
|
185
|
+
let closedWith = null;
|
|
186
|
+
const pending = new Map();
|
|
187
|
+
const listeners = new Set();
|
|
188
|
+
|
|
189
|
+
const rejectAll = (err) => {
|
|
190
|
+
for (const { reject } of pending.values()) reject(err);
|
|
191
|
+
pending.clear();
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
socket.on('data', (chunk) => {
|
|
195
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
196
|
+
const { frames, rest } = decodeFrames(buffer);
|
|
197
|
+
buffer = rest;
|
|
198
|
+
for (const frame of frames) {
|
|
199
|
+
if (frame.opcode === OPCODE.ping) { socket.write(encodeFrame(frame.payload.toString('utf8'), OPCODE.pong)); continue; }
|
|
200
|
+
if (frame.opcode === OPCODE.close) { closedWith = 'the browser closed the debugging socket'; socket.end(); continue; }
|
|
201
|
+
if (frame.opcode !== OPCODE.text) continue;
|
|
202
|
+
let message;
|
|
203
|
+
try { message = JSON.parse(frame.payload.toString('utf8')); } catch { continue; }
|
|
204
|
+
if (message.id != null && pending.has(message.id)) {
|
|
205
|
+
const { resolve, reject } = pending.get(message.id);
|
|
206
|
+
pending.delete(message.id);
|
|
207
|
+
// A CDP error is a reply, not a transport failure, and it carries the
|
|
208
|
+
// only sentence that says what was wrong with the call.
|
|
209
|
+
if (message.error) reject(new Error(`${message.error.message ?? 'CDP error'}${message.error.data ? ` — ${message.error.data}` : ''}`));
|
|
210
|
+
else resolve(message.result ?? {});
|
|
211
|
+
} else if (message.method) {
|
|
212
|
+
for (const fn of listeners) { try { fn(message); } catch { /* a listener must not break the socket */ } }
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
socket.on('close', () => { rejectAll(new Error(closedWith ?? 'the debugging socket closed')); });
|
|
218
|
+
socket.on('error', (err) => { rejectAll(new Error(`debugging socket error: ${err.message}`)); });
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
/** One CDP call. Rejects with the browser's own message on a protocol error. */
|
|
222
|
+
send(method, params = {}, { timeoutMs = 10000 } = {}) {
|
|
223
|
+
if (socket.destroyed) return Promise.reject(new Error(closedWith ?? 'the debugging socket is closed'));
|
|
224
|
+
const id = nextId++;
|
|
225
|
+
return new Promise((resolve, reject) => {
|
|
226
|
+
const timer = setTimeout(() => {
|
|
227
|
+
pending.delete(id);
|
|
228
|
+
reject(new Error(`${method} did not answer within ${timeoutMs}ms`));
|
|
229
|
+
}, timeoutMs);
|
|
230
|
+
pending.set(id, {
|
|
231
|
+
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
232
|
+
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
233
|
+
});
|
|
234
|
+
socket.write(encodeFrame(JSON.stringify({ id, method, params })));
|
|
235
|
+
});
|
|
236
|
+
},
|
|
237
|
+
/** Subscribe to CDP events. Returns an unsubscribe. */
|
|
238
|
+
on(fn) { listeners.add(fn); return () => listeners.delete(fn); },
|
|
239
|
+
close() { try { socket.end(encodeFrame('', OPCODE.close)); } catch { socket.destroy(); } },
|
|
240
|
+
get closed() { return socket.destroyed; },
|
|
241
|
+
};
|
|
242
|
+
}
|
package/src/platform/index.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// function that takes a udid routes on that udid — `ownsUdid` answers that from
|
|
21
21
|
// the id's own shape, because the question is asked from inside a capture loop
|
|
22
22
|
// in a process that never listed anything.
|
|
23
|
+
import * as store from '../store.js';
|
|
23
24
|
import { platform as android } from './android.js';
|
|
24
25
|
import { platform as ios } from './ios.js';
|
|
25
26
|
|
|
@@ -204,9 +205,34 @@ export async function resolveAcross(query, opts, all) {
|
|
|
204
205
|
// backend a call reaches, never what the call means.
|
|
205
206
|
export const isBootedSync = (udid, ...args) => platformFor(udid).isBootedSync(udid, ...args);
|
|
206
207
|
export const screenshot = (udid, ...args) => platformFor(udid).screenshot(udid, ...args);
|
|
207
|
-
|
|
208
|
+
/**
|
|
209
|
+
* Launch and openUrl stamp the action clock; nothing else here does.
|
|
210
|
+
*
|
|
211
|
+
* These two change the screen without touching the digitizer, so they are the
|
|
212
|
+
* only actions a gesture log cannot see — and a settle that cannot see them
|
|
213
|
+
* measures how long the screen being *left* has been sitting still. Measured:
|
|
214
|
+
* 283ms after a Settings launch, `settled: true` with `stableForMs: 4427` over
|
|
215
|
+
* a screen holding zero elements.
|
|
216
|
+
*
|
|
217
|
+
* **At the seam rather than in the `launch` step, and that placement is the
|
|
218
|
+
* point.** It was in the step first, which covered flows and missed everything
|
|
219
|
+
* else that launches an app — `baseline.resetFor`, `wedge.revive`, the bench,
|
|
220
|
+
* and the probe that found this. A rule enforced where you noticed it covers a
|
|
221
|
+
* symptom; this project's handoff has three worked examples of exactly that and
|
|
222
|
+
* this was very nearly a fourth.
|
|
223
|
+
*
|
|
224
|
+
* `terminateApp` is deliberately not stamped: it removes an app rather than
|
|
225
|
+
* presenting one, and the screen it leaves behind is whatever was underneath.
|
|
226
|
+
*/
|
|
227
|
+
export const launchApp = (udid, ...args) => {
|
|
228
|
+
store.noteAction(udid);
|
|
229
|
+
return platformFor(udid).launchApp(udid, ...args);
|
|
230
|
+
};
|
|
208
231
|
export const terminateApp = (udid, ...args) => platformFor(udid).terminateApp(udid, ...args);
|
|
209
|
-
export const openUrl = (udid, ...args) =>
|
|
232
|
+
export const openUrl = (udid, ...args) => {
|
|
233
|
+
store.noteAction(udid);
|
|
234
|
+
return platformFor(udid).openUrl(udid, ...args);
|
|
235
|
+
};
|
|
210
236
|
export const setPermission = (udid, ...args) => platformFor(udid).setPermission(udid, ...args);
|
|
211
237
|
export const setPasteboard = (udid, ...args) => platformFor(udid).setPasteboard(udid, ...args);
|
|
212
238
|
|
package/src/refs.js
CHANGED
|
@@ -145,10 +145,27 @@ export function resolveRef(udid, n, { structuralHash, layoutHash, screenKnown, s
|
|
|
145
145
|
throw staleError(`this is a different screen (${table.structuralHash.slice(0, 8)}`
|
|
146
146
|
+ ` → ${structuralHash.slice(0, 8)})`, 'identity');
|
|
147
147
|
}
|
|
148
|
+
// Computed before the recognition check, because it is also the evidence that
|
|
149
|
+
// check was missing. See the drift refusal below for what it measures.
|
|
150
|
+
const drift = layoutHash && table.layoutHash && informative(table.layoutHash) && informative(layoutHash)
|
|
151
|
+
? hashDistance(table.layoutHash, layoutHash)
|
|
152
|
+
: null;
|
|
148
153
|
// Nothing recognises the screen we are on, so nothing can vouch for the
|
|
149
154
|
// numbers. Refusing costs a re-read; guessing taps whatever is at those
|
|
150
155
|
// coordinates now.
|
|
151
|
-
|
|
156
|
+
//
|
|
157
|
+
// Except the refs table itself. A read that never settled numbers the
|
|
158
|
+
// elements and persists no map (`persist: settled`), so the very next call
|
|
159
|
+
// found screen memory empty and refused refs issued one second earlier on
|
|
160
|
+
// the same screen — `integration (memory)` went red on that twice running on
|
|
161
|
+
// an unchanged tree, a runner slow enough to miss the settle timeout being
|
|
162
|
+
// all it took. Screen memory recalls by the same layout distance and the same
|
|
163
|
+
// tolerance, so an informative hash this close to the table's is the test
|
|
164
|
+
// memory would have passed had it been allowed to remember; when the table's
|
|
165
|
+
// screen *was* remembered, recall would already have found it, and this
|
|
166
|
+
// changes nothing.
|
|
167
|
+
const vouchedByTable = drift != null && drift <= tolerance;
|
|
168
|
+
if (screenKnown === false && !vouchedByTable) {
|
|
152
169
|
// Flagged, like every other refusal in this function, and it was the one
|
|
153
170
|
// that was not.
|
|
154
171
|
//
|
|
@@ -178,9 +195,6 @@ export function resolveRef(udid, n, { structuralHash, layoutHash, screenKnown, s
|
|
|
178
195
|
// routinely while the hashes differ. So this says the distance, and says that
|
|
179
196
|
// it is pixels rather than identity — a different thing from the branch above,
|
|
180
197
|
// which had been wearing the same sentence.
|
|
181
|
-
const drift = layoutHash && table.layoutHash && informative(table.layoutHash) && informative(layoutHash)
|
|
182
|
-
? hashDistance(table.layoutHash, layoutHash)
|
|
183
|
-
: null;
|
|
184
198
|
if (drift != null && drift > tolerance) {
|
|
185
199
|
throw staleError(`the screen has moved too far from where these refs were numbered`
|
|
186
200
|
+ ` (layout distance ${drift}, tolerance ${tolerance}) — the identity may be unchanged;`
|
package/src/store.js
CHANGED
|
@@ -35,6 +35,7 @@ export function paths(udid) {
|
|
|
35
35
|
// recorded, and a stall is the absence of frames.
|
|
36
36
|
captureHealth: path.join(dir, 'capture-health.json'),
|
|
37
37
|
lastInput: path.join(dir, 'last-input'),
|
|
38
|
+
lastAction: path.join(dir, 'last-action'),
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
41
|
|
|
@@ -58,6 +59,44 @@ export function noteInput(udid, at = Date.now()) {
|
|
|
58
59
|
} catch {
|
|
59
60
|
/* a timestamp nothing depends on for correctness must not fail an action */
|
|
60
61
|
}
|
|
62
|
+
noteAction(udid, at);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* When we last did something that ought to change the screen.
|
|
67
|
+
*
|
|
68
|
+
* Deliberately separate from the input log above, which answers a different
|
|
69
|
+
* question — "have several *gestures* landed with no pixel moving" — and would
|
|
70
|
+
* be wrong to answer it about a launch, since a launch that paints nothing is
|
|
71
|
+
* not evidence of a dead digitizer.
|
|
72
|
+
*
|
|
73
|
+
* This one exists so a settle can tell its own stillness from the *previous*
|
|
74
|
+
* screen's. Measured on 2026-09-18: 283ms after a Settings launch the state
|
|
75
|
+
* read `settled: true` with `stableForMs: 4427` and **zero elements** — 4.4
|
|
76
|
+
* seconds of quiet that began before the launch was issued. 408ms after a
|
|
77
|
+
* `tap General`, `stableForMs: 7753` and the 23 elements of the screen being
|
|
78
|
+
* left. In both, the settle detector was honestly reporting how long the screen
|
|
79
|
+
* we had already abandoned had been sitting still.
|
|
80
|
+
*
|
|
81
|
+
* Every gesture writes it (via `noteInput`), and so does every launch and
|
|
82
|
+
* `openUrl`, because those change the screen without touching the digitizer.
|
|
83
|
+
*/
|
|
84
|
+
export function noteAction(udid, at = Date.now()) {
|
|
85
|
+
try {
|
|
86
|
+
writeAtomic(paths(udid).lastAction, String(at));
|
|
87
|
+
} catch {
|
|
88
|
+
/* as above: a timestamp may not fail the action it describes */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** When the last screen-changing action was issued, or null if none is recorded. */
|
|
93
|
+
export function lastActionAt(udid) {
|
|
94
|
+
try {
|
|
95
|
+
const n = Number(fs.readFileSync(paths(udid).lastAction, 'utf8').trim());
|
|
96
|
+
return Number.isFinite(n) ? n : null;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
61
100
|
}
|
|
62
101
|
|
|
63
102
|
/**
|
package/src/view.js
CHANGED
|
@@ -628,7 +628,7 @@ export async function screenMap(deviceQuery, {
|
|
|
628
628
|
* cheerfully says "carry on" into an unknown screen would be worse than no hint
|
|
629
629
|
* at all.
|
|
630
630
|
*/
|
|
631
|
-
export function nextHint({ ok, escalated, settled, loading, known, hash, exits, elements, ambiguous, filtered, exitList, staleExits } = {}) {
|
|
631
|
+
export function nextHint({ ok, escalated, settled, unmoved, loading, known, hash, exits, elements, ambiguous, filtered, exitList, staleExits } = {}) {
|
|
632
632
|
if (ok === false) {
|
|
633
633
|
return 'next: the flow stopped here — this is the moment to think. sim_recall shows how you got here; sim_ui re-reads the screen.';
|
|
634
634
|
}
|
|
@@ -654,6 +654,9 @@ export function nextHint({ ok, escalated, settled, loading, known, hash, exits,
|
|
|
654
654
|
if (loading) {
|
|
655
655
|
return 'next: settled, but the transition classifier still sees loading — an empty-looking region may be a list that has not arrived. waitFor a string you expect rather than acting on this.';
|
|
656
656
|
}
|
|
657
|
+
if (settled === false && unmoved) {
|
|
658
|
+
return 'next: nothing on the screen has changed since the last action. If that action should have changed it, it did not land — re-read with sim_ui before acting again, and do not assume the step worked.';
|
|
659
|
+
}
|
|
657
660
|
if (settled === false) {
|
|
658
661
|
return 'next: the screen is still moving. sim_state polls it for a fraction of a map; do not act on this reading yet.';
|
|
659
662
|
}
|
|
@@ -706,6 +709,7 @@ export function hintFor(map, { flowOk = true, escalated = false } = {}) {
|
|
|
706
709
|
escalated: Boolean(escalated),
|
|
707
710
|
filtered: map?.filtered === true,
|
|
708
711
|
settled: map?.identity?.settled !== false,
|
|
712
|
+
unmoved: map?.identity?.unmoved === true,
|
|
709
713
|
loading: map?.identity?.loading === true,
|
|
710
714
|
known: map?.exits != null,
|
|
711
715
|
hash: map?.identity?.hash ?? null,
|
|
@@ -795,7 +799,7 @@ export function render({ device, identity, rows, truncated, collapsed, screen, n
|
|
|
795
799
|
(exits == null ? ' (new to simframe)' : ` (known, ${exits} known exit${exits === 1 ? '' : 's'})`)
|
|
796
800
|
: 'screen unidentified',
|
|
797
801
|
identity?.keyboard ? 'keyboard up' : null,
|
|
798
|
-
identity?.settled === false ? 'STILL MOVING' : null,
|
|
802
|
+
identity?.settled === false ? (identity?.unmoved ? 'NOT MOVED SINCE THE ACTION' : 'STILL MOVING') : null,
|
|
799
803
|
// Still and finished are not the same thing.
|
|
800
804
|
identity?.loading === true ? 'STILL LOADING' : null,
|
|
801
805
|
// How old the *frame* this map was read from is.
|