@deeeed/metamask-harness 0.34.1 → 0.34.2
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/CHANGELOG.md +13 -0
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +222 -0
- package/adapters/mobile/bridge-runtime/console-forwarder.cjs +22 -1
- package/adapters/mobile/bridge-runtime/lib/ws-client.cjs +18 -0
- package/adapters/mobile/open-device.sh +6 -4
- package/dist/adapters/mobile/prepare.js +1 -4
- package/dist/adapters.js +134 -6
- package/dist/commands/launch/mobile.js +16 -2
- package/dist/live-adapter-contract.js +6 -6
- package/dist/recipe-security.js +2 -0
- package/dist/runner.js +53 -8
- package/dist/runtime-context.js +1 -0
- package/library/actions/mobile/perps/measure_homepage_visible.mjs +4 -0
- package/library/actions/mobile/perps/performance-capture.mjs +506 -120
- package/library/actions/mobile/perps/perps.mjs +221 -38
- package/library/actions/mobile/perps/prepare_local_snapshot_endpoint.mjs +58 -0
- package/library/actions/mobile/platform/bridge.mjs +38 -5
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +5 -1
- package/library/manifests/mobile.action-manifest.json +226 -512
- package/library/recipes/mobile/perps/performance.homepage.cold-position-sample.recipe.json +120 -0
- package/library/recipes/mobile/perps/performance.homepage.ios-background-reconnect.recipe.json +7 -6
- package/library/recipes/mobile/perps/performance.homepage.ios-cold-no-cache.recipe.json +5 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.34.2 - 2026-08-11
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Add composable platform-neutral Homepage Perps position setup and background-lifecycle measurement recipes.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Give Mobile foreground lifecycle actions the same bounded 60-second runtime-reacquisition window as launch and restart.
|
|
14
|
+
- Classify iOS foreground as a same-process resume or cold relaunch, and let reconnect recipes reject process-evicted samples immediately.
|
|
15
|
+
- Quiesce active Perps streams before direct-CDP performance cache removal and verify the disk keys remain absent.
|
|
16
|
+
- Exclude queued pre-lifecycle socket frames from lifecycle-specific Fresh DFD measurements.
|
|
17
|
+
|
|
5
18
|
## 0.34.1 - 2026-08-09
|
|
6
19
|
|
|
7
20
|
### Fixed
|
|
@@ -30,6 +30,19 @@ const {
|
|
|
30
30
|
const { cdpEval, cdpEvalAsync } = require('./lib/cdp-eval.cjs');
|
|
31
31
|
const { buildArmSnippet, buildCollectSnippet } = require('./lib/issue-capture.cjs');
|
|
32
32
|
|
|
33
|
+
function parseHomepagePerformanceConsoleEvent(params) {
|
|
34
|
+
const marker = '[HomepagePerf] ';
|
|
35
|
+
for (const arg of params?.args || []) {
|
|
36
|
+
const text = typeof arg?.value === 'string' ? arg.value : '';
|
|
37
|
+
const markerIndex = text.indexOf(marker);
|
|
38
|
+
if (markerIndex < 0) continue;
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(text.slice(markerIndex + marker.length));
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
// ---------------------------------------------------------------------------
|
|
34
47
|
// Commands
|
|
35
48
|
// ---------------------------------------------------------------------------
|
|
@@ -387,6 +400,214 @@ const COMMANDS = {
|
|
|
387
400
|
return { ...result, deviceName };
|
|
388
401
|
},
|
|
389
402
|
|
|
403
|
+
async 'measure-scroll-transition'(client, args, { deviceName } = {}) {
|
|
404
|
+
let options;
|
|
405
|
+
try {
|
|
406
|
+
options = JSON.parse(args[0] || '{}');
|
|
407
|
+
} catch (error) {
|
|
408
|
+
throw new Error(`Invalid measure-scroll-transition options: ${error.message}`);
|
|
409
|
+
}
|
|
410
|
+
const timeoutMs = Number(options.timeoutMs);
|
|
411
|
+
const pollIntervalMs = Number(options.pollIntervalMs ?? 100);
|
|
412
|
+
const visibleEventStage = options.visibleEventStage || null;
|
|
413
|
+
const visibleEventGraceMs = Number(options.visibleEventGraceMs ?? 0);
|
|
414
|
+
const targetCount = Number(Boolean(options.targetTestId)) + Number(Boolean(options.targetText));
|
|
415
|
+
if (
|
|
416
|
+
!options.fromTestId ||
|
|
417
|
+
targetCount !== 1 ||
|
|
418
|
+
!Number.isFinite(timeoutMs) ||
|
|
419
|
+
timeoutMs <= 0 ||
|
|
420
|
+
!Number.isFinite(pollIntervalMs) ||
|
|
421
|
+
pollIntervalMs < 16 ||
|
|
422
|
+
(visibleEventStage !== null && typeof visibleEventStage !== 'string') ||
|
|
423
|
+
!Number.isFinite(visibleEventGraceMs) ||
|
|
424
|
+
visibleEventGraceMs < 0
|
|
425
|
+
) {
|
|
426
|
+
throw new Error(
|
|
427
|
+
'measure-scroll-transition requires fromTestId, exactly one targetTestId or targetText, a positive timeoutMs, pollIntervalMs >= 16, and a non-negative visibleEventGraceMs.',
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
const visibleEvents = [];
|
|
431
|
+
let removeConsoleListener = null;
|
|
432
|
+
if (visibleEventStage) {
|
|
433
|
+
removeConsoleListener = client.on('Runtime.consoleAPICalled', (params) => {
|
|
434
|
+
const payload = parseHomepagePerformanceConsoleEvent(params);
|
|
435
|
+
if (payload?.stage === visibleEventStage) visibleEvents.push(payload);
|
|
436
|
+
});
|
|
437
|
+
await client.send('Runtime.enable');
|
|
438
|
+
}
|
|
439
|
+
const targetQuery = options.targetTestId
|
|
440
|
+
? { testId: options.targetTestId, visibility: 'viewport' }
|
|
441
|
+
: { textContains: options.targetText, visibility: 'viewport' };
|
|
442
|
+
const from = await cdpEvalAsync(
|
|
443
|
+
client,
|
|
444
|
+
`globalThis.__AGENTIC__?.queryUiTarget(${JSON.stringify({
|
|
445
|
+
testId: options.fromTestId,
|
|
446
|
+
visibility: 'viewport',
|
|
447
|
+
})})`,
|
|
448
|
+
timeoutMs,
|
|
449
|
+
);
|
|
450
|
+
if (from?.visible !== true) {
|
|
451
|
+
return {
|
|
452
|
+
ok: false,
|
|
453
|
+
error: 'measurement start target is not visible',
|
|
454
|
+
from,
|
|
455
|
+
deviceName,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
let walletReady = null;
|
|
459
|
+
if (options.requireWalletReady === true) {
|
|
460
|
+
walletReady = await cdpEval(
|
|
461
|
+
client,
|
|
462
|
+
`(function(){
|
|
463
|
+
var route = globalThis.__AGENTIC__?.getRoute() || null;
|
|
464
|
+
var account = null;
|
|
465
|
+
try { account = globalThis.__AGENTIC__?.getSelectedAccount() || null; } catch(e) {}
|
|
466
|
+
var address = account && typeof account.address === 'string' ? account.address : '';
|
|
467
|
+
var id = account && typeof account.id === 'string' ? account.id : '';
|
|
468
|
+
return {
|
|
469
|
+
ready: Boolean(address || id) && String(route && route.name || '') !== 'Login',
|
|
470
|
+
route: route,
|
|
471
|
+
accountPresent: Boolean(address || id),
|
|
472
|
+
observedAtMs: performance.now(),
|
|
473
|
+
};
|
|
474
|
+
})()`,
|
|
475
|
+
);
|
|
476
|
+
if (walletReady?.ready !== true) {
|
|
477
|
+
return {
|
|
478
|
+
ok: false,
|
|
479
|
+
error: 'wallet is not ready at measurement start',
|
|
480
|
+
walletReady,
|
|
481
|
+
from,
|
|
482
|
+
deviceName,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const started = await cdpEvalAsync(
|
|
487
|
+
client,
|
|
488
|
+
`(function(){
|
|
489
|
+
const scrollStartedAtMs = performance.now();
|
|
490
|
+
const startedAtMs = ${
|
|
491
|
+
walletReady === null
|
|
492
|
+
? 'scrollStartedAtMs'
|
|
493
|
+
: JSON.stringify(Number(walletReady.observedAtMs))
|
|
494
|
+
};
|
|
495
|
+
return Promise.resolve(globalThis.__AGENTIC__?.scrollView(${JSON.stringify({
|
|
496
|
+
testId: options.scrollTestId,
|
|
497
|
+
offset: options.offset,
|
|
498
|
+
animated: options.animated,
|
|
499
|
+
intoView: false,
|
|
500
|
+
})})).then(function(scroll){ return { startedAtMs, scrollStartedAtMs, scroll }; });
|
|
501
|
+
})()`,
|
|
502
|
+
timeoutMs,
|
|
503
|
+
);
|
|
504
|
+
if (started?.scroll?.ok === false) {
|
|
505
|
+
return {
|
|
506
|
+
ok: false,
|
|
507
|
+
error: 'scroll failed',
|
|
508
|
+
...started,
|
|
509
|
+
from,
|
|
510
|
+
deviceName,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
const hostDeadline = Date.now() + timeoutMs;
|
|
514
|
+
if (visibleEventStage && visibleEventGraceMs > 0) {
|
|
515
|
+
const eventDeadline = Math.min(hostDeadline, Date.now() + visibleEventGraceMs);
|
|
516
|
+
while (Date.now() <= eventDeadline) {
|
|
517
|
+
const event = visibleEvents.find(
|
|
518
|
+
(candidate) =>
|
|
519
|
+
Number(candidate.frame_checkpoint_monotonic_ms) >=
|
|
520
|
+
Number(started.scrollStartedAtMs),
|
|
521
|
+
);
|
|
522
|
+
if (event) {
|
|
523
|
+
removeConsoleListener?.();
|
|
524
|
+
const observedAtMs = Number(event.frame_checkpoint_monotonic_ms);
|
|
525
|
+
return {
|
|
526
|
+
ok: true,
|
|
527
|
+
clock: 'performance.now',
|
|
528
|
+
observationSource: 'runtime-console-event',
|
|
529
|
+
durationMs: observedAtMs - started.startedAtMs,
|
|
530
|
+
scrollDurationMs: observedAtMs - started.scrollStartedAtMs,
|
|
531
|
+
appVisibleDurationMs: Number(event.duration_ms),
|
|
532
|
+
pollIntervalMs,
|
|
533
|
+
preScrollDelayMs: started.scrollStartedAtMs - started.startedAtMs,
|
|
534
|
+
startedAtMs: started.startedAtMs,
|
|
535
|
+
scrollStartedAtMs: started.scrollStartedAtMs,
|
|
536
|
+
observedAtMs,
|
|
537
|
+
visibleEvent: event,
|
|
538
|
+
...(walletReady ? { walletReady } : {}),
|
|
539
|
+
from,
|
|
540
|
+
scroll: started.scroll,
|
|
541
|
+
deviceName,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
while (Date.now() <= hostDeadline) {
|
|
548
|
+
const remainingMs = hostDeadline - Date.now();
|
|
549
|
+
if (remainingMs <= 1_000) break;
|
|
550
|
+
const queryTimeoutMs = Math.min(5_000, remainingMs);
|
|
551
|
+
const observation = await cdpEvalAsync(
|
|
552
|
+
client,
|
|
553
|
+
`(function(){
|
|
554
|
+
return Promise.resolve(globalThis.__AGENTIC__?.queryUiTarget(${JSON.stringify(targetQuery)})).then(function(target){
|
|
555
|
+
return { target, observedAtMs: performance.now() };
|
|
556
|
+
});
|
|
557
|
+
})()`,
|
|
558
|
+
queryTimeoutMs,
|
|
559
|
+
);
|
|
560
|
+
const requiredPresent =
|
|
561
|
+
observation?.target?.visible === true && options.requiredPresentTestId
|
|
562
|
+
? await cdpEvalAsync(
|
|
563
|
+
client,
|
|
564
|
+
`globalThis.__AGENTIC__?.queryUiTarget(${JSON.stringify({
|
|
565
|
+
testId: options.requiredPresentTestId,
|
|
566
|
+
visibility: 'tree',
|
|
567
|
+
})})`,
|
|
568
|
+
queryTimeoutMs,
|
|
569
|
+
)
|
|
570
|
+
: null;
|
|
571
|
+
if (
|
|
572
|
+
observation?.target?.visible === true &&
|
|
573
|
+
(!options.requiredPresentTestId || requiredPresent?.present === true)
|
|
574
|
+
) {
|
|
575
|
+
return {
|
|
576
|
+
ok: true,
|
|
577
|
+
clock: 'performance.now',
|
|
578
|
+
durationMs: observation.observedAtMs - started.startedAtMs,
|
|
579
|
+
scrollDurationMs:
|
|
580
|
+
observation.observedAtMs - started.scrollStartedAtMs,
|
|
581
|
+
pollIntervalMs,
|
|
582
|
+
observationSource: visibleEventStage
|
|
583
|
+
? 'fiber-poll-after-event-grace'
|
|
584
|
+
: 'fiber-poll',
|
|
585
|
+
preScrollDelayMs:
|
|
586
|
+
started.scrollStartedAtMs - started.startedAtMs,
|
|
587
|
+
startedAtMs: started.startedAtMs,
|
|
588
|
+
scrollStartedAtMs: started.scrollStartedAtMs,
|
|
589
|
+
observedAtMs: observation.observedAtMs,
|
|
590
|
+
...(walletReady ? { walletReady } : {}),
|
|
591
|
+
from,
|
|
592
|
+
target: observation.target,
|
|
593
|
+
...(requiredPresent ? { requiredPresent } : {}),
|
|
594
|
+
scroll: started.scroll,
|
|
595
|
+
deviceName,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
599
|
+
}
|
|
600
|
+
removeConsoleListener?.();
|
|
601
|
+
return {
|
|
602
|
+
ok: false,
|
|
603
|
+
error: 'timed out waiting for visible transition target',
|
|
604
|
+
durationMs: await cdpEval(client, `performance.now() - ${Number(started.startedAtMs)}`),
|
|
605
|
+
startedAtMs: started.startedAtMs,
|
|
606
|
+
scrollStartedAtMs: started.scrollStartedAtMs,
|
|
607
|
+
deviceName,
|
|
608
|
+
};
|
|
609
|
+
},
|
|
610
|
+
|
|
390
611
|
async 'set-input'(client, args, { deviceName } = {}) {
|
|
391
612
|
const testId = args[0];
|
|
392
613
|
const value = args.slice(1).join(' ');
|
|
@@ -754,6 +975,7 @@ Commands:
|
|
|
754
975
|
press-text <text> Press a component containing visible text
|
|
755
976
|
scroll-view [--test-id <id>] [--offset <n>] [--animated]
|
|
756
977
|
Scroll a ScrollView/FlatList
|
|
978
|
+
measure-scroll-transition <json> Scroll and measure a visible target using one CDP session
|
|
757
979
|
set-input <testId> <value> Set text input value by testID (calls onChangeText)
|
|
758
980
|
sentry-debug [enable|disable] Patch Sentry to log errors to console with [SENTRY-DEBUG] prefix
|
|
759
981
|
unlock <password> Unlock wallet (inject password + press login button via fiber tree)
|
|
@@ -61,6 +61,11 @@ const statePath = `${out}.forwarder-state.json`;
|
|
|
61
61
|
// buffer replay backfills everything missed, so no lines are lost.
|
|
62
62
|
const LOCK_FILE = path.join(path.dirname(out), 'cdp-bridge.lock');
|
|
63
63
|
const LOCK_SETTLE_MS = 1500;
|
|
64
|
+
// The inspector can deliver NEW_DEBUGGER_OPENED after a short bridge command
|
|
65
|
+
// has already removed its lock. Keep a narrow release grace so that delayed
|
|
66
|
+
// close event is not mistaken for a human DevTools takeover (which otherwise
|
|
67
|
+
// suppresses device logs for five minutes).
|
|
68
|
+
const BRIDGE_RELEASE_GRACE_MS = 3000;
|
|
64
69
|
const LOCK_STALE_MS = 30000; // unreadable lock body: crashed bridge must not block logs forever
|
|
65
70
|
// dev-middleware serves one debugger slot per device; when a debugger we do not
|
|
66
71
|
// coordinate with (React Native DevTools) takes it, re-attaching would evict
|
|
@@ -105,9 +110,20 @@ function yieldSessions() {
|
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
let resumeTimer = null;
|
|
113
|
+
let bridgeCoordinationUntil = 0;
|
|
114
|
+
|
|
115
|
+
function noteBridgeCoordination() {
|
|
116
|
+
bridgeCoordinationUntil = Date.now() + BRIDGE_RELEASE_GRACE_MS;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function bridgeCoordinationRecent() {
|
|
120
|
+
return Date.now() < bridgeCoordinationUntil;
|
|
121
|
+
}
|
|
122
|
+
|
|
108
123
|
try {
|
|
109
124
|
fs.watch(path.dirname(out), (_event, filename) => {
|
|
110
125
|
if (filename !== path.basename(LOCK_FILE)) return;
|
|
126
|
+
noteBridgeCoordination();
|
|
111
127
|
if (bridgeLockActive()) {
|
|
112
128
|
if (resumeTimer) {
|
|
113
129
|
clearTimeout(resumeTimer);
|
|
@@ -272,7 +288,11 @@ function connect(target) {
|
|
|
272
288
|
// another one attaches. With no bridge lock present that debugger is a
|
|
273
289
|
// human's DevTools session — back off long instead of evicting them back.
|
|
274
290
|
const why = event && event.reason ? String(event.reason) : '';
|
|
275
|
-
if (
|
|
291
|
+
if (
|
|
292
|
+
why.includes('NEW_DEBUGGER_OPENED') &&
|
|
293
|
+
!bridgeLockActive() &&
|
|
294
|
+
!bridgeCoordinationRecent()
|
|
295
|
+
) {
|
|
276
296
|
foreignDebuggerUntil.set(deviceId, Date.now() + FOREIGN_DEBUGGER_BACKOFF_MS);
|
|
277
297
|
process.stderr.write(
|
|
278
298
|
`console-forwarder: another debugger took ${name}; standing down for ${FOREIGN_DEBUGGER_BACKOFF_MS / 60000} min\n`,
|
|
@@ -285,6 +305,7 @@ function connect(target) {
|
|
|
285
305
|
|
|
286
306
|
function discover() {
|
|
287
307
|
if (bridgeLockActive()) {
|
|
308
|
+
noteBridgeCoordination();
|
|
288
309
|
schedule(DISCOVER_ACTIVE_MS);
|
|
289
310
|
return;
|
|
290
311
|
}
|
|
@@ -31,6 +31,7 @@ function createWSClient(wsUrl, timeout) {
|
|
|
31
31
|
const ws = new WebSocketImpl(wsUrl);
|
|
32
32
|
let msgId = 0;
|
|
33
33
|
const pending = new Map();
|
|
34
|
+
const eventHandlers = new Map();
|
|
34
35
|
|
|
35
36
|
const timer = setTimeout(() => {
|
|
36
37
|
ws.close();
|
|
@@ -69,6 +70,15 @@ function createWSClient(wsUrl, timeout) {
|
|
|
69
70
|
ws.send(msg);
|
|
70
71
|
});
|
|
71
72
|
},
|
|
73
|
+
on(method, handler) {
|
|
74
|
+
const handlers = eventHandlers.get(method) || new Set();
|
|
75
|
+
handlers.add(handler);
|
|
76
|
+
eventHandlers.set(method, handlers);
|
|
77
|
+
return () => {
|
|
78
|
+
handlers.delete(handler);
|
|
79
|
+
if (handlers.size === 0) eventHandlers.delete(method);
|
|
80
|
+
};
|
|
81
|
+
},
|
|
72
82
|
close() {
|
|
73
83
|
ws.close();
|
|
74
84
|
},
|
|
@@ -92,6 +102,13 @@ function createWSClient(wsUrl, timeout) {
|
|
|
92
102
|
} else {
|
|
93
103
|
res(msg.result);
|
|
94
104
|
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!msg.method) return;
|
|
108
|
+
for (const handler of eventHandlers.get(msg.method) || []) {
|
|
109
|
+
try {
|
|
110
|
+
handler(msg.params || {});
|
|
111
|
+
} catch {}
|
|
95
112
|
}
|
|
96
113
|
};
|
|
97
114
|
|
|
@@ -106,6 +123,7 @@ function createWSClient(wsUrl, timeout) {
|
|
|
106
123
|
rej(coded(new Error('WebSocket closed'), BRIDGE_ERROR_CODES.WS_CLOSED));
|
|
107
124
|
}
|
|
108
125
|
pending.clear();
|
|
126
|
+
eventHandlers.clear();
|
|
109
127
|
};
|
|
110
128
|
});
|
|
111
129
|
}
|
|
@@ -522,10 +522,6 @@ else
|
|
|
522
522
|
ADB_ARGS=()
|
|
523
523
|
[ -n "$ADB_SERIAL_ARG" ] && ADB_ARGS+=("-s" "$ADB_SERIAL_ARG")
|
|
524
524
|
|
|
525
|
-
# Reverse-tunnel Metro port so the device can reach localhost:PORT.
|
|
526
|
-
"$ADB_BIN" "${ADB_ARGS[@]}" reverse "tcp:${PORT}" "tcp:${PORT}" >/dev/null 2>&1 || \
|
|
527
|
-
printf 'open-device: adb reverse failed (non-fatal — device may already be reachable)\n' >&2
|
|
528
|
-
|
|
529
525
|
LAUNCHED=false
|
|
530
526
|
for PKG in "${ANDROID_PKGS[@]}"; do
|
|
531
527
|
if ! ensure_android_app_for_mode "$PKG" "$PREFLIGHT_MODE" "${ADB_ARGS[@]}"; then
|
|
@@ -539,6 +535,12 @@ else
|
|
|
539
535
|
printf 'Restarting Android dev client %s\n' "$PKG" >&2
|
|
540
536
|
fi
|
|
541
537
|
|
|
538
|
+
# Installing/rebuilding a dev client can invalidate adb reverse mappings.
|
|
539
|
+
# Restore Metro reachability only after the app is ensured, immediately
|
|
540
|
+
# before opening the development-client URL.
|
|
541
|
+
"$ADB_BIN" "${ADB_ARGS[@]}" reverse "tcp:${PORT}" "tcp:${PORT}" >/dev/null 2>&1 || \
|
|
542
|
+
printf 'open-device: adb reverse failed (non-fatal — device may already be reachable)\n' >&2
|
|
543
|
+
|
|
542
544
|
ENCODED_URL="$(urlencode "http://localhost:${PORT}?disableOnboarding=1" 2>/dev/null \
|
|
543
545
|
|| printf 'http%%3A%%2F%%2Flocalhost%%3A%s%%3FdisableOnboarding%%3D1' "$PORT")"
|
|
544
546
|
DEEP_LINK="expo-metamask://expo-development-client/?url=${ENCODED_URL}"
|
|
@@ -116,10 +116,7 @@ async function prepareMobile(target, opts = {}) {
|
|
|
116
116
|
);
|
|
117
117
|
}
|
|
118
118
|
const launch = await dispatchActionSequence(
|
|
119
|
-
withAppRestart(
|
|
120
|
-
launchActions(path.resolve(target)),
|
|
121
|
-
restartApp || iosAccessibilityChanged
|
|
122
|
-
),
|
|
119
|
+
withAppRestart(launchActions(path.resolve(target)), true),
|
|
123
120
|
target,
|
|
124
121
|
platform,
|
|
125
122
|
json,
|
package/dist/adapters.js
CHANGED
|
@@ -39,6 +39,8 @@ const LIVE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set([
|
|
|
39
39
|
"metamask.perps.ensure_orders",
|
|
40
40
|
"metamask.perps.assert_orders",
|
|
41
41
|
"metamask.perps.clear_performance_caches",
|
|
42
|
+
"metamask.perps.measure_homepage_visible",
|
|
43
|
+
"metamask.perps.prepare_local_snapshot_endpoint",
|
|
42
44
|
"metamask.perps.start_state",
|
|
43
45
|
"metamask.perps.teardown_state",
|
|
44
46
|
"metamask.perps.capture_performance"
|
|
@@ -198,7 +200,11 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
|
|
|
198
200
|
"metamask.perps.start_state",
|
|
199
201
|
"metamask.perps.teardown_state",
|
|
200
202
|
"metamask.perps.capture_performance",
|
|
201
|
-
...platform === "mobile" ? [
|
|
203
|
+
...platform === "mobile" ? [
|
|
204
|
+
"metamask.perps.clear_performance_caches",
|
|
205
|
+
"metamask.perps.measure_homepage_visible",
|
|
206
|
+
"metamask.perps.prepare_local_snapshot_endpoint"
|
|
207
|
+
] : [],
|
|
202
208
|
// read_account is core-only: only the headless core adapter implements it.
|
|
203
209
|
...platform === "core" ? ["metamask.perps.read_account"] : []
|
|
204
210
|
];
|
|
@@ -283,6 +289,17 @@ function resolveMobileWaitTarget(payload) {
|
|
|
283
289
|
visibility: expected === "visible" || expected === "hidden" ? "viewport" : "tree"
|
|
284
290
|
};
|
|
285
291
|
}
|
|
292
|
+
const RETRYABLE_MOBILE_WAIT_CODES = /* @__PURE__ */ new Set([
|
|
293
|
+
MOBILE_BRIDGE_ERROR_CODES.NO_TARGET,
|
|
294
|
+
MOBILE_BRIDGE_ERROR_CODES.CDP_TIMEOUT,
|
|
295
|
+
MOBILE_BRIDGE_ERROR_CODES.WS_CLOSED,
|
|
296
|
+
MOBILE_BRIDGE_ERROR_CODES.METRO_UNREACHABLE
|
|
297
|
+
]);
|
|
298
|
+
function isRetryableMobileWaitError(error) {
|
|
299
|
+
return Boolean(
|
|
300
|
+
isRecord(error) && typeof error.code === "string" && RETRYABLE_MOBILE_WAIT_CODES.has(error.code)
|
|
301
|
+
);
|
|
302
|
+
}
|
|
286
303
|
async function waitForMobileTarget(input, payload) {
|
|
287
304
|
const timeoutMs = Number(payload.timeout_ms ?? payload.timeoutMs ?? 1e4);
|
|
288
305
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -308,9 +325,28 @@ async function waitForMobileTarget(input, payload) {
|
|
|
308
325
|
})()` : null;
|
|
309
326
|
let lastResult = null;
|
|
310
327
|
let lastText = null;
|
|
328
|
+
let lastBridgeError = null;
|
|
311
329
|
const expectsAbsent = expected === "absent" || expected === "hidden" || expected === "not_present";
|
|
312
330
|
while (Date.now() <= deadline) {
|
|
313
|
-
const
|
|
331
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
332
|
+
const probeTimeoutMs = Math.min(5e3, remainingMs);
|
|
333
|
+
const probeInput = {
|
|
334
|
+
...input,
|
|
335
|
+
node: {
|
|
336
|
+
...input.node,
|
|
337
|
+
bridge_timeout_ms: probeTimeoutMs,
|
|
338
|
+
cdp_timeout_ms: probeTimeoutMs
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
let result;
|
|
342
|
+
try {
|
|
343
|
+
result = await evalAsync(probeInput, queryExpression);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (!isRetryableMobileWaitError(error)) throw error;
|
|
346
|
+
lastBridgeError = error;
|
|
347
|
+
await sleep(250);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
314
350
|
if (!isRecord(result) || result.unsupported === true) {
|
|
315
351
|
throw new Error(
|
|
316
352
|
"Mobile ui.wait_for requires the current __AGENTIC__.queryUiTarget bridge for text-only or viewport-visible assertions."
|
|
@@ -321,7 +357,14 @@ async function waitForMobileTarget(input, payload) {
|
|
|
321
357
|
const visible = result.visible === true;
|
|
322
358
|
let textMatched = !expectedText || textMatch === "contains";
|
|
323
359
|
if (expectedText && textMatch === "exact" && textExpression) {
|
|
324
|
-
|
|
360
|
+
try {
|
|
361
|
+
lastText = await evalSync(probeInput, textExpression);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (!isRetryableMobileWaitError(error)) throw error;
|
|
364
|
+
lastBridgeError = error;
|
|
365
|
+
await sleep(250);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
325
368
|
textMatched = traceText(lastText) === expectedText;
|
|
326
369
|
}
|
|
327
370
|
const matched = expected === "visible" ? visible && textMatched : expected === "hidden" ? !visible : expectsAbsent ? !present : present && textMatched;
|
|
@@ -344,8 +387,9 @@ async function waitForMobileTarget(input, payload) {
|
|
|
344
387
|
expectedText ? `text ${textMatch} ${JSON.stringify(expectedText)}` : ""
|
|
345
388
|
].filter(Boolean).join(" with ");
|
|
346
389
|
const exactReason = textMatch === "exact" ? `; last text=${JSON.stringify(lastText)}` : "";
|
|
390
|
+
const bridgeReason = lastBridgeError ? `; last bridge error=${traceText(lastBridgeError)}` : "";
|
|
347
391
|
throw new Error(
|
|
348
|
-
`Timed out waiting for mobile ${target} to be ${expected}; last result=${JSON.stringify(lastResult)}${exactReason}.`
|
|
392
|
+
`Timed out waiting for mobile ${target} to be ${expected}; last result=${JSON.stringify(lastResult)}${exactReason}${bridgeReason}.`
|
|
349
393
|
);
|
|
350
394
|
}
|
|
351
395
|
const MOBILE_BRIDGE_HANDLERS = {
|
|
@@ -427,6 +471,48 @@ async function handleMobileScroll(payload, context) {
|
|
|
427
471
|
const testId = optionalScalarText(payload.test_id ?? payload.testID, "ui.scroll.test_id");
|
|
428
472
|
const offset = scalarText(payload.offset ?? payload.delta_y ?? payload.deltaY, "ui.scroll.offset", "600");
|
|
429
473
|
const intoView = payload.scroll_into_view === true || payload.into_view === true;
|
|
474
|
+
const measureFromTestId = optionalScalarText(
|
|
475
|
+
payload.measure_from_test_id ?? payload.measureFromTestId,
|
|
476
|
+
"ui.scroll.measure_from_test_id"
|
|
477
|
+
);
|
|
478
|
+
const measureTargetTestId = optionalScalarText(
|
|
479
|
+
payload.measure_target_test_id ?? payload.measureTargetTestId,
|
|
480
|
+
"ui.scroll.measure_target_test_id"
|
|
481
|
+
);
|
|
482
|
+
const measureTargetText = optionalScalarText(
|
|
483
|
+
payload.measure_target_text ?? payload.measureTargetText,
|
|
484
|
+
"ui.scroll.measure_target_text"
|
|
485
|
+
);
|
|
486
|
+
const measureTargetCount = Number(Boolean(measureTargetTestId)) + Number(Boolean(measureTargetText));
|
|
487
|
+
const measureRequiredPresentTestId = optionalScalarText(
|
|
488
|
+
payload.measure_required_present_test_id ?? payload.measureRequiredPresentTestId,
|
|
489
|
+
"ui.scroll.measure_required_present_test_id"
|
|
490
|
+
);
|
|
491
|
+
if (Boolean(measureFromTestId) !== (measureTargetCount === 1)) {
|
|
492
|
+
throw new Error(
|
|
493
|
+
"ui.scroll visible-transition measurement requires measure_from_test_id and exactly one of measure_target_test_id or measure_target_text."
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
if (measureFromTestId && measureTargetCount === 1) {
|
|
497
|
+
if (intoView) {
|
|
498
|
+
throw new Error(
|
|
499
|
+
"ui.scroll visible-transition measurement uses an explicit offset and does not support scroll_into_view."
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
return measureMobileScrollTransition(input, {
|
|
503
|
+
animated: payload.animated === true,
|
|
504
|
+
fromTestId: measureFromTestId,
|
|
505
|
+
offset: Number(offset),
|
|
506
|
+
...measureTargetTestId ? { targetTestId: measureTargetTestId } : {},
|
|
507
|
+
...measureTargetText ? { targetText: measureTargetText } : {},
|
|
508
|
+
...measureRequiredPresentTestId ? { requiredPresentTestId: measureRequiredPresentTestId } : {},
|
|
509
|
+
timeoutMs: Math.max(
|
|
510
|
+
1,
|
|
511
|
+
Number(payload.timeout_ms ?? payload.timeoutMs ?? 3e4)
|
|
512
|
+
),
|
|
513
|
+
...testId ? { scrollTestId: testId } : {}
|
|
514
|
+
});
|
|
515
|
+
}
|
|
430
516
|
if (testId && intoView) {
|
|
431
517
|
const before = await queryMobileViewportTarget(input, testId);
|
|
432
518
|
if (before.visible === true) {
|
|
@@ -448,12 +534,23 @@ async function handleMobileScroll(payload, context) {
|
|
|
448
534
|
...intoView ? ["--into-view"] : [],
|
|
449
535
|
animatedFlag(payload)
|
|
450
536
|
] : ["scroll-view", "--offset", offset, animatedFlag(payload)];
|
|
451
|
-
const
|
|
537
|
+
const timeoutMs = Math.max(0, Number(payload.timeout_ms ?? payload.timeoutMs ?? 5e3));
|
|
538
|
+
const deadline = Date.now() + timeoutMs;
|
|
539
|
+
let result;
|
|
540
|
+
while (true) {
|
|
541
|
+
try {
|
|
542
|
+
result = await bridgeCommand(input, args);
|
|
543
|
+
break;
|
|
544
|
+
} catch (error) {
|
|
545
|
+
if (!testId || Date.now() >= deadline) throw error;
|
|
546
|
+
await sleep(100);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
452
549
|
if (testId && intoView) {
|
|
453
550
|
const after = await waitForMobileViewportTarget(
|
|
454
551
|
input,
|
|
455
552
|
testId,
|
|
456
|
-
|
|
553
|
+
timeoutMs
|
|
457
554
|
);
|
|
458
555
|
if (after.visible !== true) {
|
|
459
556
|
throw new Error(
|
|
@@ -466,6 +563,36 @@ async function handleMobileScroll(payload, context) {
|
|
|
466
563
|
intoView
|
|
467
564
|
};
|
|
468
565
|
}
|
|
566
|
+
async function measureMobileScrollTransition(input, options) {
|
|
567
|
+
const result = await bridgeCommand(
|
|
568
|
+
{
|
|
569
|
+
...input,
|
|
570
|
+
node: {
|
|
571
|
+
...input.node,
|
|
572
|
+
bridge_timeout_ms: options.timeoutMs + 5e3,
|
|
573
|
+
cdp_timeout_ms: options.timeoutMs + 5e3
|
|
574
|
+
}
|
|
575
|
+
},
|
|
576
|
+
["measure-scroll-transition", JSON.stringify(options)]
|
|
577
|
+
);
|
|
578
|
+
if (!isRecord(result) || result.unsupported === true) {
|
|
579
|
+
throw new Error(
|
|
580
|
+
"ui.scroll visible-transition measurement requires queryUiTarget and scrollView on the current mobile bridge."
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
if (result.ok !== true) {
|
|
584
|
+
throw new Error(
|
|
585
|
+
`ui.scroll visible-transition measurement failed: ${traceText(result.error ?? result)}.`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
return {
|
|
589
|
+
...result,
|
|
590
|
+
measureFromTestId: options.fromTestId,
|
|
591
|
+
...options.targetTestId ? { measureTargetTestId: options.targetTestId } : {},
|
|
592
|
+
...options.targetText ? { measureTargetText: options.targetText } : {},
|
|
593
|
+
...options.requiredPresentTestId ? { measureRequiredPresentTestId: options.requiredPresentTestId } : {}
|
|
594
|
+
};
|
|
595
|
+
}
|
|
469
596
|
async function queryMobileViewportTarget(input, testId) {
|
|
470
597
|
const expression = `(function(){
|
|
471
598
|
const api = globalThis.__AGENTIC__;
|
|
@@ -851,6 +978,7 @@ export {
|
|
|
851
978
|
createMetaMaskUiTransport,
|
|
852
979
|
hideMobileHudOnTeardown,
|
|
853
980
|
isMobileHudLifecycleSkip,
|
|
981
|
+
isRetryableMobileWaitError,
|
|
854
982
|
normalizeUiWaitNode,
|
|
855
983
|
resolveMobileWaitTarget
|
|
856
984
|
};
|
|
@@ -1,18 +1,32 @@
|
|
|
1
1
|
import { prepareMobile } from "../../adapters/mobile/prepare.js";
|
|
2
|
+
import {
|
|
3
|
+
mobileSourceCheck,
|
|
4
|
+
recordMobileSourceBaseline
|
|
5
|
+
} from "../../adapters/mobile/source-freshness.js";
|
|
2
6
|
import { ensureHarnessFresh } from "../../adapters/harness-freshness.js";
|
|
7
|
+
import { EXIT } from "../shared.js";
|
|
3
8
|
async function launchMobile(target, mobileTarget, tier, json, restartApp = false, clearMetro = false) {
|
|
4
9
|
await ensureHarnessFresh(target, "mobile");
|
|
5
10
|
const platform = mobileTarget ?? "ios";
|
|
6
11
|
const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : void 0;
|
|
7
12
|
const preflightMode = tier === "build" ? "rebuild-native" : "fast";
|
|
8
|
-
|
|
13
|
+
const source = mobileSourceCheck(target);
|
|
14
|
+
const result = await prepareMobile(target, {
|
|
9
15
|
platform,
|
|
10
16
|
json,
|
|
11
17
|
watcherPort,
|
|
12
18
|
preflightMode,
|
|
13
|
-
restartApp,
|
|
19
|
+
restartApp: restartApp || source.status !== "current",
|
|
14
20
|
clearMetro
|
|
15
21
|
});
|
|
22
|
+
if (result.status !== 0) return result;
|
|
23
|
+
if (!recordMobileSourceBaseline(target, source.fingerprint)) {
|
|
24
|
+
const message = "Mobile source changed while the app was launching; the loaded-source baseline was not recorded.\n Next: re-run mm-harness launch.";
|
|
25
|
+
if (!json) process.stderr.write(`${message}
|
|
26
|
+
`);
|
|
27
|
+
return { status: EXIT.runtime, output: message };
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
16
30
|
}
|
|
17
31
|
export {
|
|
18
32
|
launchMobile
|
|
@@ -352,15 +352,15 @@ function liveAdapterProcessTimeoutMs(node) {
|
|
|
352
352
|
}
|
|
353
353
|
const settleMs = Number(node.settle_ms);
|
|
354
354
|
const settleAllowance = Number.isFinite(settleMs) && settleMs > 0 ? settleMs : 0;
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
355
|
+
const actionTimeouts = [
|
|
356
|
+
node.target_timeout_ms,
|
|
357
|
+
node.unlock_timeout_ms,
|
|
358
|
+
node.timeout_ms
|
|
359
|
+
].map(Number).filter((value) => Number.isFinite(value) && value > 0);
|
|
360
360
|
if (actionTimeouts.length > 0) {
|
|
361
361
|
return Math.max(
|
|
362
362
|
6e4,
|
|
363
|
-
actionTimeouts.reduce((total, value) => total + value, 0) + 5e3
|
|
363
|
+
actionTimeouts.reduce((total, value) => total + value, 0) + settleAllowance + 5e3
|
|
364
364
|
);
|
|
365
365
|
}
|
|
366
366
|
return 6e4 + settleAllowance;
|
package/dist/recipe-security.js
CHANGED
|
@@ -29,6 +29,8 @@ const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
|
|
|
29
29
|
"metamask.perps.ensure_positions",
|
|
30
30
|
"metamask.perps.ensure_orders",
|
|
31
31
|
"metamask.perps.clear_performance_caches",
|
|
32
|
+
"metamask.perps.measure_homepage_visible",
|
|
33
|
+
"metamask.perps.prepare_local_snapshot_endpoint",
|
|
32
34
|
"metamask.perps.start_state",
|
|
33
35
|
"metamask.perps.teardown_state",
|
|
34
36
|
// Consent changes wallet state and is therefore an app mutation.
|