@deeeed/metamask-harness 0.34.0 → 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 +29 -0
- package/adapters/manifest.json +0 -8
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +236 -3
- package/adapters/mobile/bridge-runtime/console-forwarder.cjs +22 -1
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +22 -15
- package/adapters/mobile/bridge-runtime/lib/ws-client.cjs +18 -0
- package/adapters/mobile/launch-metro.cjs +6 -4
- package/adapters/mobile/open-device.sh +170 -19
- package/adapters/mobile/start-metro.sh +165 -93
- package/adapters/mobile/stop-metro.sh +1 -0
- package/dist/adapters/mobile/prepare.js +1 -4
- package/dist/adapters/mobile/video-recorder.js +12 -10
- package/dist/adapters.js +134 -6
- package/dist/commands/launch/index.js +9 -0
- package/dist/commands/launch/mobile.js +17 -3
- package/dist/live-adapter-contract.js +10 -3
- 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 +579 -111
- 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 +102 -8
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +35 -8
- package/library/manifests/mobile.action-manifest.json +243 -511
- package/library/recipes/mobile/perps/performance.homepage.android-cold-disk-cache.recipe.json +20 -2
- package/library/recipes/mobile/perps/performance.homepage.android-cold-no-cache.recipe.json +2 -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/adapters/mobile/metro-config.cjs +0 -93
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
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
|
+
|
|
18
|
+
## 0.34.1 - 2026-08-09
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Launch Mobile Metro through the product's canonical watcher while preserving slot, build, Sentry, and Segment environment settings, applying cache clears only to the selected Metro instance, and removing the config override that could corrupt cold bundles.
|
|
23
|
+
- Pin native Mobile builds to the exact supported Main or Flask app identity and Android device, rejecting ambiguous physical-device names instead of building or launching on the wrong target.
|
|
24
|
+
- Clean up timed-out Metro process groups safely and require the cold-disk-cache Perps freshness gate to report the expected lifecycle.
|
|
25
|
+
- Allow performance recipes to enforce a first-fresh socket-to-subscriber latency budget for a stream and subscriber throttle, optionally scoped to a lifecycle while always reporting the observed lifecycle.
|
|
26
|
+
- Allow Mobile wallet unlock actions to use their declared target and unlock timeouts without the live-adapter process ending first.
|
|
27
|
+
- Remove Android's 180-second `screenrecord` cap so `--record-video=full-run` covers long recipe runs.
|
|
28
|
+
- Retry empty Metro target lists, derive the Android model pin from `ADB_SERIAL`, and use pin-aware wallet status while Hermes re-registers between recipe actions.
|
|
29
|
+
- Keep compound Mobile actions under one debugger lease so nested bridge calls cannot race another recipe action.
|
|
30
|
+
- Let Android `scroll_into_view` fall back to a scrollable ancestor when the target itself is not a scroll container.
|
|
31
|
+
- Leave Metro's worker count at its native default unless `METRO_MAX_WORKERS` is explicitly configured.
|
|
32
|
+
- Bound each Mobile wallet-status probe by its polling deadline, raise the default unlock verification window to 30 seconds, and give the Android cold-no-cache recipe an explicit cold-start allowance, preventing a successful physical-device unlock from being reported as a timeout.
|
|
33
|
+
|
|
5
34
|
## 0.34.0 - 2026-08-07
|
|
6
35
|
|
|
7
36
|
### Fixed
|
package/adapters/manifest.json
CHANGED
|
@@ -42,14 +42,6 @@
|
|
|
42
42
|
"inputs": "--target --port --log --pid-file [--workers] [--clear]",
|
|
43
43
|
"outputs": "Metro PID/launch metadata and redirected log; exit 0/1/2"
|
|
44
44
|
},
|
|
45
|
-
{
|
|
46
|
-
"id": "mobile/metro-config",
|
|
47
|
-
"entry": "adapters/mobile/metro-config.cjs",
|
|
48
|
-
"kind": "module",
|
|
49
|
-
"purpose": "Preserve the product Metro config while excluding harness-owned checkout temp artifacts.",
|
|
50
|
-
"inputs": "base Metro config; env MM_HARNESS_METRO_PROJECT_ROOT, MM_HARNESS_METRO_BASE_CONFIG",
|
|
51
|
-
"outputs": "merged Metro config with resolver.blockList for <projectRoot>/temp"
|
|
52
|
-
},
|
|
53
45
|
{
|
|
54
46
|
"id": "mobile/stop-metro",
|
|
55
47
|
"entry": "adapters/mobile/stop-metro.sh",
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -322,7 +335,11 @@ const COMMANDS = {
|
|
|
322
335
|
const optsJson = JSON.stringify({ testId, offset, animated, intoView });
|
|
323
336
|
// Try __AGENTIC__ bridge first, fall back to inline fiber walking
|
|
324
337
|
const expr = `(function() {
|
|
325
|
-
|
|
338
|
+
// The app helper implements generic scrolling by searching inside the
|
|
339
|
+
// selector fiber. For into-view requests the scroll container is commonly
|
|
340
|
+
// an ancestor (for example, a Wallet Home section row), so use the
|
|
341
|
+
// ancestor-aware bridge fallback below instead.
|
|
342
|
+
if (!${intoView} && globalThis.__AGENTIC__?.scrollView) return globalThis.__AGENTIC__.scrollView(${optsJson});
|
|
326
343
|
var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
327
344
|
if (!hook) return { ok: false, error: 'No React DevTools hook' };
|
|
328
345
|
var renderers = hook.renderers;
|
|
@@ -383,6 +400,214 @@ const COMMANDS = {
|
|
|
383
400
|
return { ...result, deviceName };
|
|
384
401
|
},
|
|
385
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
|
+
|
|
386
611
|
async 'set-input'(client, args, { deviceName } = {}) {
|
|
387
612
|
const testId = args[0];
|
|
388
613
|
const value = args.slice(1).join(' ');
|
|
@@ -681,6 +906,11 @@ const BRIDGE_LOCK_FILE = path.join(
|
|
|
681
906
|
process.env.RECIPE_RUNTIME_DIR || path.join('temp', 'recipe', 'runtime'),
|
|
682
907
|
'cdp-bridge.lock',
|
|
683
908
|
);
|
|
909
|
+
const BRIDGE_LOCK_OWNER_PID = /^\d+$/.test(
|
|
910
|
+
process.env.CDP_BRIDGE_LOCK_OWNER_PID || '',
|
|
911
|
+
)
|
|
912
|
+
? process.env.CDP_BRIDGE_LOCK_OWNER_PID
|
|
913
|
+
: String(process.pid);
|
|
684
914
|
|
|
685
915
|
// Ownership semantics: last writer wins. Each bridge stamps its own pid; the
|
|
686
916
|
// forwarder honors the lock while the pid IN THE FILE is alive. Overlapping
|
|
@@ -691,7 +921,7 @@ let bridgeLockHeld = false;
|
|
|
691
921
|
|
|
692
922
|
function acquireBridgeLock() {
|
|
693
923
|
try {
|
|
694
|
-
fs.writeFileSync(BRIDGE_LOCK_FILE,
|
|
924
|
+
fs.writeFileSync(BRIDGE_LOCK_FILE, BRIDGE_LOCK_OWNER_PID);
|
|
695
925
|
bridgeLockHeld = true;
|
|
696
926
|
} catch {
|
|
697
927
|
// Lock is best-effort: a missing runtime dir must not break bridge commands.
|
|
@@ -745,6 +975,7 @@ Commands:
|
|
|
745
975
|
press-text <text> Press a component containing visible text
|
|
746
976
|
scroll-view [--test-id <id>] [--offset <n>] [--animated]
|
|
747
977
|
Scroll a ScrollView/FlatList
|
|
978
|
+
measure-scroll-transition <json> Scroll and measure a visible target using one CDP session
|
|
748
979
|
set-input <testId> <value> Set text input value by testID (calls onChangeText)
|
|
749
980
|
sentry-debug [enable|disable] Patch Sentry to log errors to console with [SENTRY-DEBUG] prefix
|
|
750
981
|
unlock <password> Unlock wallet (inject password + press login button via fiber tree)
|
|
@@ -765,7 +996,7 @@ Environment:
|
|
|
765
996
|
process.exit(0);
|
|
766
997
|
}
|
|
767
998
|
|
|
768
|
-
const handler = COMMANDS[command];
|
|
999
|
+
const handler = COMMANDS[command === 'status-selected' ? 'status' : command];
|
|
769
1000
|
if (!handler) {
|
|
770
1001
|
console.error(`Unknown command: ${command}`);
|
|
771
1002
|
console.error(`Available: ${Object.keys(COMMANDS).join(', ')}`);
|
|
@@ -780,6 +1011,8 @@ Environment:
|
|
|
780
1011
|
const timeout = Number.parseInt(process.env.CDP_TIMEOUT || '5000', 10);
|
|
781
1012
|
|
|
782
1013
|
// `status` probes ALL connected targets so both platforms are visible.
|
|
1014
|
+
// `status-selected` falls through to the normal pinned discovery path for
|
|
1015
|
+
// action code that must survive one device's Hermes runtime rotation.
|
|
783
1016
|
if (command === 'status') {
|
|
784
1017
|
const { discoverAllTargets } = require('./lib/target-discovery.cjs');
|
|
785
1018
|
const allTargets = await discoverAllTargets(port);
|
|
@@ -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
|
}
|
|
@@ -6,7 +6,12 @@ const { createWSClient } = require('./ws-client.cjs');
|
|
|
6
6
|
const { BRIDGE_ERROR_CODES, coded } = require('./bridge-errors.cjs');
|
|
7
7
|
|
|
8
8
|
const FETCH_TIMEOUT_MS = Number.parseInt(process.env.CDP_TIMEOUT || '30000', 10);
|
|
9
|
-
const
|
|
9
|
+
const DISCOVERY_RETRY_DELAY_MS = 500;
|
|
10
|
+
const FETCH_RETRIES = Number.parseInt(
|
|
11
|
+
process.env.CDP_DISCOVERY_RETRIES ||
|
|
12
|
+
String(Math.max(3, Math.ceil(FETCH_TIMEOUT_MS / DISCOVERY_RETRY_DELAY_MS))),
|
|
13
|
+
10,
|
|
14
|
+
);
|
|
10
15
|
|
|
11
16
|
/** Fetch JSON from a URL (http only, no external deps) */
|
|
12
17
|
function fetchJSONOnce(url) {
|
|
@@ -43,7 +48,9 @@ async function fetchJSON(url, acceptResponse) {
|
|
|
43
48
|
lastError = error;
|
|
44
49
|
}
|
|
45
50
|
if (attempt === FETCH_RETRIES) break;
|
|
46
|
-
await new Promise((resolve) =>
|
|
51
|
+
await new Promise((resolve) =>
|
|
52
|
+
setTimeout(resolve, DISCOVERY_RETRY_DELAY_MS),
|
|
53
|
+
);
|
|
47
54
|
}
|
|
48
55
|
if (lastError !== undefined) throw lastError;
|
|
49
56
|
if (lastResponse !== undefined) return lastResponse;
|
|
@@ -146,21 +153,21 @@ async function discoverTarget(port) {
|
|
|
146
153
|
let acceptedPinnedCandidate;
|
|
147
154
|
let targets;
|
|
148
155
|
try {
|
|
149
|
-
targets = await fetchJSON(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
156
|
+
targets = await fetchJSON(listUrl, async (response) => {
|
|
157
|
+
const runtimeCandidates = rankRuntimeCandidates(response);
|
|
158
|
+
if (runtimeCandidates.length === 0) return false;
|
|
159
|
+
if (androidPinned) {
|
|
160
|
+
const pinnedCandidates = runtimeCandidates.filter(matchesAndroidPin);
|
|
161
|
+
for (const candidate of pinnedCandidates) {
|
|
162
|
+
if (await probeTarget(candidate.webSocketDebuggerUrl)) {
|
|
163
|
+
acceptedPinnedCandidate = candidate;
|
|
164
|
+
return true;
|
|
159
165
|
}
|
|
160
|
-
return false;
|
|
161
166
|
}
|
|
162
|
-
|
|
163
|
-
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
});
|
|
164
171
|
} catch (e) {
|
|
165
172
|
throw coded(
|
|
166
173
|
new Error(`Cannot reach Metro at ${listUrl}. Is Metro running?\n ${e.message}`),
|
|
@@ -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
|
}
|
|
@@ -26,10 +26,10 @@ function parseArgs(argv) {
|
|
|
26
26
|
async function main() {
|
|
27
27
|
const args = parseArgs(process.argv.slice(2));
|
|
28
28
|
if (args.help) {
|
|
29
|
-
console.log('Usage: launch-metro.cjs --target <path> --port <port> --log <path> --pid-file <path> [--workers <n>] [--clear]');
|
|
29
|
+
console.log('Usage: launch-metro.cjs --target <path> --port <port> --log <path> --pid-file <path> --build-env <path> [--workers <n>] [--clear]');
|
|
30
30
|
return;
|
|
31
31
|
}
|
|
32
|
-
for (const required of ['target', 'port', 'log', 'pid-file']) {
|
|
32
|
+
for (const required of ['target', 'port', 'log', 'pid-file', 'build-env']) {
|
|
33
33
|
if (!args[required]) throw new Error(`--${required} is required`);
|
|
34
34
|
}
|
|
35
35
|
const target = path.resolve(args.target);
|
|
@@ -38,9 +38,9 @@ async function main() {
|
|
|
38
38
|
fs.mkdirSync(path.dirname(log), { recursive: true });
|
|
39
39
|
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
|
|
40
40
|
const logFd = fs.openSync(log, 'a');
|
|
41
|
-
const childArgs = ['
|
|
42
|
-
if (args.clear) childArgs.push('--clear');
|
|
41
|
+
const childArgs = ['watch'];
|
|
43
42
|
const env = { ...process.env };
|
|
43
|
+
env.BASH_ENV = args['build-env'];
|
|
44
44
|
if (args.workers) env.METRO_MAX_WORKERS = String(args.workers);
|
|
45
45
|
const child = spawn('yarn', childArgs, {
|
|
46
46
|
cwd: target,
|
|
@@ -59,10 +59,12 @@ async function main() {
|
|
|
59
59
|
`${JSON.stringify({
|
|
60
60
|
schemaVersion: 1,
|
|
61
61
|
pid: child.pid,
|
|
62
|
+
pgid: child.pid,
|
|
62
63
|
startedAt: new Date().toISOString(),
|
|
63
64
|
target,
|
|
64
65
|
port: Number(args.port),
|
|
65
66
|
clear: Boolean(args.clear),
|
|
67
|
+
command: `yarn ${childArgs[0]}`,
|
|
66
68
|
}, null, 2)}\n`,
|
|
67
69
|
);
|
|
68
70
|
child.unref();
|