@deeeed/metamask-harness 0.34.1 → 0.34.3
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 +24 -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 +139 -8
- package/dist/commands/launch/mobile.js +16 -2
- package/dist/live-adapter-contract.js +6 -6
- package/dist/recipe-security.js +3 -0
- package/dist/runner.js +53 -8
- package/dist/runtime-context.js +1 -0
- package/library/actions/core/perps/_controller.mjs +75 -23
- package/library/actions/core/perps/read_account.mjs +6 -12
- package/library/actions/core/perps/read_snapshot.mjs +66 -0
- package/library/actions/extension/performance/_navigation-memory.mjs +372 -0
- package/library/actions/extension/performance/compare_idle_navigation_memory.mjs +18 -0
- package/library/actions/extension/performance/measure_detached_dom.mjs +18 -0
- package/library/actions/extension/performance/measure_navigation_memory.mjs +18 -0
- package/library/actions/mobile/app/network-control.mjs +24 -12
- 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/core.action-manifest.json +55 -0
- package/library/manifests/extension.action-manifest.json +100 -0
- package/library/manifests/mobile.action-manifest.json +226 -512
- package/library/recipes/core/perps/snapshot.recipe.json +25 -0
- package/library/recipes/extension/performance/navigation-memory.recipe.json +175 -0
- 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/scripts/site-contrast.mjs +39 -3
- package/site/architecture.html +22 -16
- package/site/assets/progress.mjs +1 -1
- package/site/assets/style.css +113 -1
- package/site/cheatsheet.html +14 -12
- package/site/how-it-works.html +693 -0
- package/site/index.html +70 -640
- package/site/perps.html +7 -6
- package/site/recipes.html +23 -17
- package/site/reviewers.html +7 -6
- package/site/tutorials/index.html +7 -6
- package/site/tutorials/v1.html +12 -11
- package/site/tutorials/v2.html +7 -6
- package/site/tutorials/v3.html +15 -11
- package/site/tutorials/v4.html +6 -5
- package/site/tutorials/v5.html +6 -5
- package/site/tutorials/v6.html +6 -5
- package/site/tutorials/v7.html +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.34.3 - 2026-08-12
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Add a composable headless Core Perps snapshot recipe that calls the public controller APIs with configurable Terminal endpoints.
|
|
10
|
+
- Add route-agnostic Extension actions and a reusable recipe for post-GC navigation slopes, detached-DOM growth, and equal-duration idle-control memory measurement.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Preserve an already-foregrounded Android app while recipes toggle network state, avoiding an unnecessary launcher intent during recovery validation.
|
|
15
|
+
|
|
16
|
+
## 0.34.2 - 2026-08-11
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Add composable platform-neutral Homepage Perps position setup and background-lifecycle measurement recipes.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Give Mobile foreground lifecycle actions the same bounded 60-second runtime-reacquisition window as launch and restart.
|
|
25
|
+
- Classify iOS foreground as a same-process resume or cold relaunch, and let reconnect recipes reject process-evicted samples immediately.
|
|
26
|
+
- Quiesce active Perps streams before direct-CDP performance cache removal and verify the disk keys remain absent.
|
|
27
|
+
- Exclude queued pre-lifecycle socket frames from lifecycle-specific Fresh DFD measurements.
|
|
28
|
+
|
|
5
29
|
## 0.34.1 - 2026-08-09
|
|
6
30
|
|
|
7
31
|
### 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,11 +39,16 @@ 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"
|
|
45
47
|
]);
|
|
46
|
-
const CORE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set([
|
|
48
|
+
const CORE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set([
|
|
49
|
+
"metamask.perps.read_account",
|
|
50
|
+
"metamask.perps.read_snapshot"
|
|
51
|
+
]);
|
|
47
52
|
const LIVE_ONLY_WALLET_ACTIONS = /* @__PURE__ */ new Set([
|
|
48
53
|
"metamask.wallet.setup",
|
|
49
54
|
"metamask.wallet.ensure_unlocked",
|
|
@@ -198,9 +203,13 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
|
|
|
198
203
|
"metamask.perps.start_state",
|
|
199
204
|
"metamask.perps.teardown_state",
|
|
200
205
|
"metamask.perps.capture_performance",
|
|
201
|
-
...platform === "mobile" ? [
|
|
206
|
+
...platform === "mobile" ? [
|
|
207
|
+
"metamask.perps.clear_performance_caches",
|
|
208
|
+
"metamask.perps.measure_homepage_visible",
|
|
209
|
+
"metamask.perps.prepare_local_snapshot_endpoint"
|
|
210
|
+
] : [],
|
|
202
211
|
// read_account is core-only: only the headless core adapter implements it.
|
|
203
|
-
...platform === "core" ? ["metamask.perps.read_account"] : []
|
|
212
|
+
...platform === "core" ? ["metamask.perps.read_account", "metamask.perps.read_snapshot"] : []
|
|
204
213
|
];
|
|
205
214
|
const bundled = new Set(bundledActions);
|
|
206
215
|
const actions = [.../* @__PURE__ */ new Set([...bundledActions, ...declaredCustomActions])];
|
|
@@ -283,6 +292,17 @@ function resolveMobileWaitTarget(payload) {
|
|
|
283
292
|
visibility: expected === "visible" || expected === "hidden" ? "viewport" : "tree"
|
|
284
293
|
};
|
|
285
294
|
}
|
|
295
|
+
const RETRYABLE_MOBILE_WAIT_CODES = /* @__PURE__ */ new Set([
|
|
296
|
+
MOBILE_BRIDGE_ERROR_CODES.NO_TARGET,
|
|
297
|
+
MOBILE_BRIDGE_ERROR_CODES.CDP_TIMEOUT,
|
|
298
|
+
MOBILE_BRIDGE_ERROR_CODES.WS_CLOSED,
|
|
299
|
+
MOBILE_BRIDGE_ERROR_CODES.METRO_UNREACHABLE
|
|
300
|
+
]);
|
|
301
|
+
function isRetryableMobileWaitError(error) {
|
|
302
|
+
return Boolean(
|
|
303
|
+
isRecord(error) && typeof error.code === "string" && RETRYABLE_MOBILE_WAIT_CODES.has(error.code)
|
|
304
|
+
);
|
|
305
|
+
}
|
|
286
306
|
async function waitForMobileTarget(input, payload) {
|
|
287
307
|
const timeoutMs = Number(payload.timeout_ms ?? payload.timeoutMs ?? 1e4);
|
|
288
308
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -308,9 +328,28 @@ async function waitForMobileTarget(input, payload) {
|
|
|
308
328
|
})()` : null;
|
|
309
329
|
let lastResult = null;
|
|
310
330
|
let lastText = null;
|
|
331
|
+
let lastBridgeError = null;
|
|
311
332
|
const expectsAbsent = expected === "absent" || expected === "hidden" || expected === "not_present";
|
|
312
333
|
while (Date.now() <= deadline) {
|
|
313
|
-
const
|
|
334
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
335
|
+
const probeTimeoutMs = Math.min(5e3, remainingMs);
|
|
336
|
+
const probeInput = {
|
|
337
|
+
...input,
|
|
338
|
+
node: {
|
|
339
|
+
...input.node,
|
|
340
|
+
bridge_timeout_ms: probeTimeoutMs,
|
|
341
|
+
cdp_timeout_ms: probeTimeoutMs
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
let result;
|
|
345
|
+
try {
|
|
346
|
+
result = await evalAsync(probeInput, queryExpression);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if (!isRetryableMobileWaitError(error)) throw error;
|
|
349
|
+
lastBridgeError = error;
|
|
350
|
+
await sleep(250);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
314
353
|
if (!isRecord(result) || result.unsupported === true) {
|
|
315
354
|
throw new Error(
|
|
316
355
|
"Mobile ui.wait_for requires the current __AGENTIC__.queryUiTarget bridge for text-only or viewport-visible assertions."
|
|
@@ -321,7 +360,14 @@ async function waitForMobileTarget(input, payload) {
|
|
|
321
360
|
const visible = result.visible === true;
|
|
322
361
|
let textMatched = !expectedText || textMatch === "contains";
|
|
323
362
|
if (expectedText && textMatch === "exact" && textExpression) {
|
|
324
|
-
|
|
363
|
+
try {
|
|
364
|
+
lastText = await evalSync(probeInput, textExpression);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (!isRetryableMobileWaitError(error)) throw error;
|
|
367
|
+
lastBridgeError = error;
|
|
368
|
+
await sleep(250);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
325
371
|
textMatched = traceText(lastText) === expectedText;
|
|
326
372
|
}
|
|
327
373
|
const matched = expected === "visible" ? visible && textMatched : expected === "hidden" ? !visible : expectsAbsent ? !present : present && textMatched;
|
|
@@ -344,8 +390,9 @@ async function waitForMobileTarget(input, payload) {
|
|
|
344
390
|
expectedText ? `text ${textMatch} ${JSON.stringify(expectedText)}` : ""
|
|
345
391
|
].filter(Boolean).join(" with ");
|
|
346
392
|
const exactReason = textMatch === "exact" ? `; last text=${JSON.stringify(lastText)}` : "";
|
|
393
|
+
const bridgeReason = lastBridgeError ? `; last bridge error=${traceText(lastBridgeError)}` : "";
|
|
347
394
|
throw new Error(
|
|
348
|
-
`Timed out waiting for mobile ${target} to be ${expected}; last result=${JSON.stringify(lastResult)}${exactReason}.`
|
|
395
|
+
`Timed out waiting for mobile ${target} to be ${expected}; last result=${JSON.stringify(lastResult)}${exactReason}${bridgeReason}.`
|
|
349
396
|
);
|
|
350
397
|
}
|
|
351
398
|
const MOBILE_BRIDGE_HANDLERS = {
|
|
@@ -427,6 +474,48 @@ async function handleMobileScroll(payload, context) {
|
|
|
427
474
|
const testId = optionalScalarText(payload.test_id ?? payload.testID, "ui.scroll.test_id");
|
|
428
475
|
const offset = scalarText(payload.offset ?? payload.delta_y ?? payload.deltaY, "ui.scroll.offset", "600");
|
|
429
476
|
const intoView = payload.scroll_into_view === true || payload.into_view === true;
|
|
477
|
+
const measureFromTestId = optionalScalarText(
|
|
478
|
+
payload.measure_from_test_id ?? payload.measureFromTestId,
|
|
479
|
+
"ui.scroll.measure_from_test_id"
|
|
480
|
+
);
|
|
481
|
+
const measureTargetTestId = optionalScalarText(
|
|
482
|
+
payload.measure_target_test_id ?? payload.measureTargetTestId,
|
|
483
|
+
"ui.scroll.measure_target_test_id"
|
|
484
|
+
);
|
|
485
|
+
const measureTargetText = optionalScalarText(
|
|
486
|
+
payload.measure_target_text ?? payload.measureTargetText,
|
|
487
|
+
"ui.scroll.measure_target_text"
|
|
488
|
+
);
|
|
489
|
+
const measureTargetCount = Number(Boolean(measureTargetTestId)) + Number(Boolean(measureTargetText));
|
|
490
|
+
const measureRequiredPresentTestId = optionalScalarText(
|
|
491
|
+
payload.measure_required_present_test_id ?? payload.measureRequiredPresentTestId,
|
|
492
|
+
"ui.scroll.measure_required_present_test_id"
|
|
493
|
+
);
|
|
494
|
+
if (Boolean(measureFromTestId) !== (measureTargetCount === 1)) {
|
|
495
|
+
throw new Error(
|
|
496
|
+
"ui.scroll visible-transition measurement requires measure_from_test_id and exactly one of measure_target_test_id or measure_target_text."
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
if (measureFromTestId && measureTargetCount === 1) {
|
|
500
|
+
if (intoView) {
|
|
501
|
+
throw new Error(
|
|
502
|
+
"ui.scroll visible-transition measurement uses an explicit offset and does not support scroll_into_view."
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
return measureMobileScrollTransition(input, {
|
|
506
|
+
animated: payload.animated === true,
|
|
507
|
+
fromTestId: measureFromTestId,
|
|
508
|
+
offset: Number(offset),
|
|
509
|
+
...measureTargetTestId ? { targetTestId: measureTargetTestId } : {},
|
|
510
|
+
...measureTargetText ? { targetText: measureTargetText } : {},
|
|
511
|
+
...measureRequiredPresentTestId ? { requiredPresentTestId: measureRequiredPresentTestId } : {},
|
|
512
|
+
timeoutMs: Math.max(
|
|
513
|
+
1,
|
|
514
|
+
Number(payload.timeout_ms ?? payload.timeoutMs ?? 3e4)
|
|
515
|
+
),
|
|
516
|
+
...testId ? { scrollTestId: testId } : {}
|
|
517
|
+
});
|
|
518
|
+
}
|
|
430
519
|
if (testId && intoView) {
|
|
431
520
|
const before = await queryMobileViewportTarget(input, testId);
|
|
432
521
|
if (before.visible === true) {
|
|
@@ -448,12 +537,23 @@ async function handleMobileScroll(payload, context) {
|
|
|
448
537
|
...intoView ? ["--into-view"] : [],
|
|
449
538
|
animatedFlag(payload)
|
|
450
539
|
] : ["scroll-view", "--offset", offset, animatedFlag(payload)];
|
|
451
|
-
const
|
|
540
|
+
const timeoutMs = Math.max(0, Number(payload.timeout_ms ?? payload.timeoutMs ?? 5e3));
|
|
541
|
+
const deadline = Date.now() + timeoutMs;
|
|
542
|
+
let result;
|
|
543
|
+
while (true) {
|
|
544
|
+
try {
|
|
545
|
+
result = await bridgeCommand(input, args);
|
|
546
|
+
break;
|
|
547
|
+
} catch (error) {
|
|
548
|
+
if (!testId || Date.now() >= deadline) throw error;
|
|
549
|
+
await sleep(100);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
452
552
|
if (testId && intoView) {
|
|
453
553
|
const after = await waitForMobileViewportTarget(
|
|
454
554
|
input,
|
|
455
555
|
testId,
|
|
456
|
-
|
|
556
|
+
timeoutMs
|
|
457
557
|
);
|
|
458
558
|
if (after.visible !== true) {
|
|
459
559
|
throw new Error(
|
|
@@ -466,6 +566,36 @@ async function handleMobileScroll(payload, context) {
|
|
|
466
566
|
intoView
|
|
467
567
|
};
|
|
468
568
|
}
|
|
569
|
+
async function measureMobileScrollTransition(input, options) {
|
|
570
|
+
const result = await bridgeCommand(
|
|
571
|
+
{
|
|
572
|
+
...input,
|
|
573
|
+
node: {
|
|
574
|
+
...input.node,
|
|
575
|
+
bridge_timeout_ms: options.timeoutMs + 5e3,
|
|
576
|
+
cdp_timeout_ms: options.timeoutMs + 5e3
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
["measure-scroll-transition", JSON.stringify(options)]
|
|
580
|
+
);
|
|
581
|
+
if (!isRecord(result) || result.unsupported === true) {
|
|
582
|
+
throw new Error(
|
|
583
|
+
"ui.scroll visible-transition measurement requires queryUiTarget and scrollView on the current mobile bridge."
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
if (result.ok !== true) {
|
|
587
|
+
throw new Error(
|
|
588
|
+
`ui.scroll visible-transition measurement failed: ${traceText(result.error ?? result)}.`
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
...result,
|
|
593
|
+
measureFromTestId: options.fromTestId,
|
|
594
|
+
...options.targetTestId ? { measureTargetTestId: options.targetTestId } : {},
|
|
595
|
+
...options.targetText ? { measureTargetText: options.targetText } : {},
|
|
596
|
+
...options.requiredPresentTestId ? { measureRequiredPresentTestId: options.requiredPresentTestId } : {}
|
|
597
|
+
};
|
|
598
|
+
}
|
|
469
599
|
async function queryMobileViewportTarget(input, testId) {
|
|
470
600
|
const expression = `(function(){
|
|
471
601
|
const api = globalThis.__AGENTIC__;
|
|
@@ -851,6 +981,7 @@ export {
|
|
|
851
981
|
createMetaMaskUiTransport,
|
|
852
982
|
hideMobileHudOnTeardown,
|
|
853
983
|
isMobileHudLifecycleSkip,
|
|
984
|
+
isRetryableMobileWaitError,
|
|
854
985
|
normalizeUiWaitNode,
|
|
855
986
|
resolveMobileWaitTarget
|
|
856
987
|
};
|
|
@@ -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
|