@deeeed/metamask-harness 0.43.0 → 0.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,12 +14,25 @@ const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
14
14
  const MAX_POST_DATA_BYTES = 64 * 1024;
15
15
  const MAX_REQUEST_FRAME_BYTES = 8 * 1024 * 1024;
16
16
  const MAX_RESPONSE_FRAME_BYTES = 16 * 1024 * 1024;
17
+ const MAX_HUD_STEP_BYTES = 16 * 1024;
18
+ const MAX_HUD_UPDATES = 64;
17
19
  const MAX_RETAINED_STRING_LENGTH = 256;
18
20
  const MAX_BROKER_TIMEOUT_MS = 60_000;
19
21
  const SENSITIVE_FIELD =
20
22
  /(?:address|authorization|cookie|key|password|secret|token|user|account)/iu;
21
23
  const SENSITIVE_VALUE =
22
24
  /(?:0x[a-f0-9]{40,}|[a-f0-9]{64,}|eyJ[a-z0-9_-]{20,}\.[a-z0-9_-]{20,})/iu;
25
+ const APPLY_HUD_UPDATE_FUNCTION = `function(step) {
26
+ const bridge = this.__AGENTIC__;
27
+ if (step === null) {
28
+ if (typeof bridge?.hideStep !== 'function') return false;
29
+ setTimeout(() => bridge.hideStep(), 0);
30
+ return true;
31
+ }
32
+ if (typeof bridge?.showStep !== 'function') return false;
33
+ setTimeout(() => bridge.showStep(step), 0);
34
+ return true;
35
+ }`;
23
36
 
24
37
  function brokerSocketPath(runtimeDir, endpointIdentity) {
25
38
  const runtimePath = path.resolve(
@@ -353,6 +366,9 @@ function createCdpBroker({
353
366
  }) {
354
367
  const captures = new Map();
355
368
  const completedCaptures = new Map();
369
+ const hudUpdates = new Map();
370
+ const appliedHudVersions = new Map();
371
+ const hudApplyChains = new Map();
356
372
  const clients = new Set();
357
373
  const subscriptions = new Map();
358
374
  const knownTargets = new Map(
@@ -482,6 +498,67 @@ function createCdpBroker({
482
498
  capturesFor(deviceId).delete(capture.id);
483
499
  }
484
500
 
501
+ async function applyHudUpdate(deviceId, timeoutMs, socket) {
502
+ if (!hudUpdates.has(deviceId)) return { status: 'none' };
503
+ const budgetMs = boundedTimeout(timeoutMs);
504
+ const deadline = Date.now() + budgetMs;
505
+ const session = await waitForSession(deviceId, budgetMs, socket);
506
+ const update = hudUpdates.get(deviceId);
507
+ if (!update) return { status: 'none' };
508
+ if (appliedHudVersions.get(deviceId) === update.version) {
509
+ return { status: 'applied', version: update.version };
510
+ }
511
+ let applied = false;
512
+ while (Date.now() < deadline) {
513
+ const globalObject = await sendCommand(
514
+ session,
515
+ 'Runtime.evaluate',
516
+ { expression: 'globalThis', returnByValue: false, awaitPromise: false },
517
+ Math.max(1, deadline - Date.now()),
518
+ );
519
+ const objectId = globalObject?.result?.objectId;
520
+ if (!objectId) {
521
+ throw new Error('Mobile HUD runtime did not expose globalThis');
522
+ }
523
+ const evaluation = await sendCommand(
524
+ session,
525
+ 'Runtime.callFunctionOn',
526
+ {
527
+ objectId,
528
+ functionDeclaration: APPLY_HUD_UPDATE_FUNCTION,
529
+ arguments: [{ value: update.step }],
530
+ returnByValue: true,
531
+ awaitPromise: false,
532
+ },
533
+ Math.max(1, deadline - Date.now()),
534
+ );
535
+ if (evaluation?.result?.value === true) {
536
+ applied = true;
537
+ break;
538
+ }
539
+ await new Promise((resolve) => setTimeout(resolve, 100));
540
+ }
541
+ if (!applied) {
542
+ throw new Error('Mobile HUD bridge was not installed before the update deadline');
543
+ }
544
+ if (hudUpdates.get(deviceId)?.version === update.version) {
545
+ appliedHudVersions.set(deviceId, update.version);
546
+ }
547
+ return { status: 'applied', version: update.version };
548
+ }
549
+
550
+ function scheduleHudUpdate(deviceId, timeoutMs, socket) {
551
+ const previous = hudApplyChains.get(deviceId) || Promise.resolve();
552
+ const next = previous
553
+ .catch(() => undefined)
554
+ .then(() => applyHudUpdate(deviceId, timeoutMs, socket));
555
+ hudApplyChains.set(deviceId, next);
556
+ void next.finally(() => {
557
+ if (hudApplyChains.get(deviceId) === next) hudApplyChains.delete(deviceId);
558
+ }).catch(() => undefined);
559
+ return next;
560
+ }
561
+
485
562
  function expireCapture(deviceId, capture) {
486
563
  if (capturesFor(deviceId).get(capture.id) !== capture) return;
487
564
  finishCapture(deviceId, capture, true);
@@ -539,6 +616,29 @@ function createCdpBroker({
539
616
  if (action === 'resolve-targets') {
540
617
  return resolveTargets(params, timeoutMs, socket);
541
618
  }
619
+ if (action === 'hud-update') {
620
+ const step = params.step ?? null;
621
+ if (step !== null && (typeof step !== 'object' || Array.isArray(step))) {
622
+ throw new Error('HUD update requires an object step or null');
623
+ }
624
+ if (Buffer.byteLength(JSON.stringify(step)) > MAX_HUD_STEP_BYTES) {
625
+ throw new Error('HUD update exceeds its bound');
626
+ }
627
+ const previousVersion = hudUpdates.get(deviceId)?.version || 0;
628
+ if (hudUpdates.has(deviceId)) {
629
+ hudUpdates.delete(deviceId);
630
+ }
631
+ while (hudUpdates.size >= MAX_HUD_UPDATES) {
632
+ const oldestDeviceId = hudUpdates.keys().next().value;
633
+ hudUpdates.delete(oldestDeviceId);
634
+ appliedHudVersions.delete(oldestDeviceId);
635
+ }
636
+ const version = previousVersion + 1;
637
+ hudUpdates.set(deviceId, { step, version });
638
+ requestDiscovery?.(deviceId);
639
+ void scheduleHudUpdate(deviceId, 10_000).catch(() => undefined);
640
+ return { status: 'queued', version };
641
+ }
542
642
  if (action === 'capture-start') {
543
643
  const capture = normalizeCapture(params);
544
644
  const deviceCaptures = capturesFor(deviceId);
@@ -726,11 +826,13 @@ function createCdpBroker({
726
826
  if (capture.reconnects > 0) capture.partial = true;
727
827
  void enableNetwork(deviceId, capture);
728
828
  }
829
+ void scheduleHudUpdate(deviceId, 10_000).catch(() => undefined);
729
830
  },
730
831
  onSessionClose(deviceId) {
731
832
  const previous = knownTargets.get(deviceId);
732
833
  if (previous) knownTargets.set(deviceId, { ...previous, ready: false });
733
834
  observedSessions.delete(deviceId);
835
+ appliedHudVersions.delete(deviceId);
734
836
  for (const capture of capturesFor(deviceId).values()) {
735
837
  capture.reconnects += 1;
736
838
  capture.partial = true;
@@ -300,6 +300,7 @@ async function discoverTarget(port, { probe = true } = {}) {
300
300
  for (const candidate of candidates) {
301
301
  if (candidate.webSocketDebuggerUrl === acceptedPinnedCandidate.webSocketDebuggerUrl) {
302
302
  return {
303
+ id: acceptedPinnedCandidate.id || '',
303
304
  wsUrl: acceptedPinnedCandidate.webSocketDebuggerUrl,
304
305
  deviceName: acceptedPinnedCandidate.deviceName || '',
305
306
  };
@@ -312,6 +313,7 @@ async function discoverTarget(port, { probe = true } = {}) {
312
313
  // platform/device filtering and select the highest-ranked runtime instead.
313
314
  if (!probe) {
314
315
  return {
316
+ id: candidates[0].id || '',
315
317
  wsUrl: candidates[0].webSocketDebuggerUrl,
316
318
  deviceName: candidates[0].deviceName || '',
317
319
  };
@@ -331,7 +333,7 @@ async function discoverTarget(port, { probe = true } = {}) {
331
333
  for (const candidate of candidates) {
332
334
  const hasAgentic = await probeTarget(candidate.webSocketDebuggerUrl);
333
335
  if (hasAgentic) {
334
- return { wsUrl: candidate.webSocketDebuggerUrl, deviceName: candidate.deviceName || '' };
336
+ return { id: candidate.id || '', wsUrl: candidate.webSocketDebuggerUrl, deviceName: candidate.deviceName || '' };
335
337
  }
336
338
  }
337
339
 
@@ -348,7 +350,7 @@ async function discoverTarget(port, { probe = true } = {}) {
348
350
  }
349
351
 
350
352
  // Fallback: return highest page number (most likely the JS runtime)
351
- return { wsUrl: candidates[0].webSocketDebuggerUrl, deviceName: candidates[0].deviceName || '' };
353
+ return { id: candidates[0].id || '', wsUrl: candidates[0].webSocketDebuggerUrl, deviceName: candidates[0].deviceName || '' };
352
354
  }
353
355
 
354
356
  /**
@@ -1,12 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { spawnSync } from 'node:child_process';
3
4
  import { createRequire } from 'node:module';
5
+ import { resolveMobileToolPath } from '../../library/actions/mobile/platform/tool-paths.mjs';
4
6
 
5
7
  const require = createRequire(import.meta.url);
6
8
  const WebSocket = require('ws');
9
+ const { discoverTarget } = require('./bridge-runtime/lib/target-discovery.cjs');
7
10
 
8
11
  function parseArgs(argv) {
9
- const args = { port: process.env.WATCHER_PORT || process.env.METRO_PORT || '8081', json: false };
12
+ const args = {
13
+ port: process.env.WATCHER_PORT || process.env.METRO_PORT || '8081',
14
+ json: false,
15
+ };
10
16
  for (let index = 0; index < argv.length; index += 1) {
11
17
  if (argv[index] === '--port') args.port = argv[++index];
12
18
  else if (argv[index] === '--json') args.json = true;
@@ -25,6 +31,21 @@ async function reload(port) {
25
31
  if (!Number.isInteger(numericPort) || numericPort <= 0) {
26
32
  throw new Error(`Metro port is invalid: ${port}`);
27
33
  }
34
+ const targetBefore = await readHermesTarget(numericPort);
35
+ if (!targetBefore?.id) {
36
+ throw new Error(
37
+ `No React Native debug target with a stable ID is connected to Metro on port ${numericPort}`,
38
+ );
39
+ }
40
+ const processBefore = readAndroidPid();
41
+ if (androidSerial() && !processBefore) {
42
+ throw new Error(`The Android app is not running on ${androidSerial()}`);
43
+ }
44
+ if (processBefore && hasAndroidThread(processBefore, 'hermes-sampling')) {
45
+ throw new Error(
46
+ 'The Android Hermes sampling profiler is active, so a runtime reload can abort the app. Use an explicit app.lifecycle restart.',
47
+ );
48
+ }
28
49
  await new Promise((resolve, reject) => {
29
50
  const socket = new WebSocket(`ws://127.0.0.1:${numericPort}/message`);
30
51
  const timeout = setTimeout(() => {
@@ -44,15 +65,92 @@ async function reload(port) {
44
65
  reject(new Error(`Metro is not reachable on port ${numericPort}`));
45
66
  });
46
67
  });
68
+ const targetAfter = await waitForReload(
69
+ numericPort,
70
+ targetBefore.id,
71
+ processBefore,
72
+ );
73
+ const processAfter = processBefore ? readAndroidPid() : null;
74
+ if (processBefore && processAfter !== processBefore) {
75
+ throw new Error(
76
+ `Android app process ${processBefore} changed to ${processAfter ?? 'none'} during reload.`,
77
+ );
78
+ }
47
79
  return {
48
80
  ok: true,
49
81
  adapter: 'mobile',
50
82
  method: 'metro-message',
51
83
  command: 'reload',
52
84
  port: numericPort,
85
+ targetBefore: targetBefore.id,
86
+ targetAfter: targetAfter.id,
87
+ ...(processBefore
88
+ ? {
89
+ processBefore,
90
+ processAfter,
91
+ processContinuity: 'preserved',
92
+ }
93
+ : {}),
53
94
  };
54
95
  }
55
96
 
97
+ async function waitForReload(port, targetBefore, processBefore) {
98
+ const deadline = Date.now() + 30_000;
99
+ while (Date.now() < deadline) {
100
+ if (processBefore && readAndroidPid() !== processBefore) {
101
+ throw new Error(
102
+ `Android app process ${processBefore} exited during reload. Inspect the native crash before using an explicit app.lifecycle restart.`,
103
+ );
104
+ }
105
+ const target = await readHermesTarget(port).catch(() => null);
106
+ if (target && target.id !== targetBefore) return target;
107
+ await new Promise((resolve) => setTimeout(resolve, 250));
108
+ }
109
+ throw new Error(
110
+ `React Native did not expose a new Hermes target after reload on port ${port}`,
111
+ );
112
+ }
113
+
114
+ async function readHermesTarget(port) {
115
+ return discoverTarget(port, { probe: false });
116
+ }
117
+
118
+ function androidSerial() {
119
+ return process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || '';
120
+ }
121
+
122
+ function readAndroidPid() {
123
+ const serial = androidSerial();
124
+ if (!serial) return null;
125
+ const adb = resolveMobileToolPath('adb', { required: true });
126
+ const result = spawnSync(
127
+ adb,
128
+ ['-s', serial, 'shell', 'pidof', process.env.ANDROID_PACKAGE_ID || 'io.metamask'],
129
+ { encoding: 'utf8', timeout: 5_000 },
130
+ );
131
+ if (result.status !== 0) return null;
132
+ const pid = Number.parseInt(
133
+ String(result.stdout).trim().split(/\s+/u)[0] ?? '',
134
+ 10,
135
+ );
136
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
137
+ }
138
+
139
+ function hasAndroidThread(pid, name) {
140
+ const adb = resolveMobileToolPath('adb', { required: true });
141
+ const result = spawnSync(
142
+ adb,
143
+ ['-s', androidSerial(), 'shell', 'ps', '-T', '-p', String(pid)],
144
+ { encoding: 'utf8', timeout: 5_000 },
145
+ );
146
+ return (
147
+ result.status === 0 &&
148
+ String(result.stdout)
149
+ .split(/\r?\n/u)
150
+ .some((line) => line.trim().endsWith(name))
151
+ );
152
+ }
153
+
56
154
  const args = parseArgs(process.argv.slice(2));
57
155
  reload(args.port)
58
156
  .then((result) => {
@@ -59,6 +59,18 @@ async function prepareMobile(target, opts = {}) {
59
59
  return { status: EXIT.runtime, output: msg };
60
60
  }
61
61
  const iosAccessibilityChanged = platform === "ios" && report.decision !== "unknown" && enableIosAccessibility(json);
62
+ const rebuildNative = preflightMode === "rebuild-native" || preflightMode === "clean";
63
+ if (report.decision === "ready" && rebuildNative) {
64
+ const launch = await dispatchActionSequence(
65
+ launchActions(path.resolve(target), clearMetro),
66
+ target,
67
+ platform,
68
+ json,
69
+ preflightMode,
70
+ opts.watcherPort
71
+ );
72
+ return launch.status === 0 ? startMobileConsoleForwarder(target, platform, json, opts.watcherPort) : launch;
73
+ }
62
74
  if (report.decision === "ready" && clearMetro) {
63
75
  const launch = await dispatchActionSequence(
64
76
  withAppRestart(
package/dist/adapters.js CHANGED
@@ -526,6 +526,7 @@ const MOBILE_BRIDGE_HANDLERS = {
526
526
  status: handleMobileStatus,
527
527
  navigate: handleMobileNavigate,
528
528
  press: handleMobilePress,
529
+ keyPress: handleMobileKeyPress,
529
530
  setInput: handleMobileSetInput,
530
531
  scroll: handleMobileScroll,
531
532
  waitFor: handleMobileWaitFor,
@@ -585,6 +586,11 @@ async function handleMobilePress(payload, context) {
585
586
  if (text !== void 0) return bridgeCommand(input, ["press-text", target]);
586
587
  return bridgeCommand(input, [longPress ? "long-press-test-id" : "press-test-id", target]);
587
588
  }
589
+ async function handleMobileKeyPress(payload, context) {
590
+ const key = scalarText(payload.key, "ui.key_press.key", "Enter");
591
+ const input = mobileUiInput(context, "key_press", payload);
592
+ return bridgeCommand(input, ["key-press", key]);
593
+ }
588
594
  async function handleMobileSetInput(payload, context) {
589
595
  const input = mobileUiInput(context, "set_input", payload);
590
596
  const testId = firstScalarText(payload, ["test_id", "testID"], "ui.set_input");
@@ -753,7 +759,17 @@ async function handleMobileWaitFor(payload, context) {
753
759
  return waitForMobileTarget(mobileUiInput(context, "waitFor", payload), payload);
754
760
  }
755
761
  async function handleMobileHud(payload, context) {
756
- const input = mobileUiInput(context, "hud", payload);
762
+ const automaticProgress = typeof payload.action_name === "string" || context.nodeId === "recipe-complete" && isRecord(payload.progress) && payload.progress.complete === true;
763
+ const captureProgress = payload.action_name === "ui.screenshot" || payload.action_name === "ui.capture_surface";
764
+ const input = mobileUiInput(
765
+ context,
766
+ "hud",
767
+ automaticProgress ? {
768
+ ...payload,
769
+ bridge_timeout_ms: captureProgress ? 1e4 : 2e3,
770
+ cdp_timeout_ms: captureProgress ? 1e4 : 2e3
771
+ } : payload
772
+ );
757
773
  if (payload.clear === true) {
758
774
  try {
759
775
  return await bridgeCommand(input, ["hide-step"]);
@@ -763,7 +779,10 @@ async function handleMobileHud(payload, context) {
763
779
  }
764
780
  const hud = mobileHudPayload(payload, context);
765
781
  try {
766
- const result = await bridgeCommand(input, ["show-step-json", JSON.stringify(hud.step)]);
782
+ const result = await bridgeCommand(input, [
783
+ automaticProgress && !captureProgress ? "show-step-json-deferred" : "show-step-json",
784
+ JSON.stringify(hud.step)
785
+ ]);
767
786
  return { hud: true, nodeId: hud.nodeId, status: hud.status, result };
768
787
  } catch (error) {
769
788
  return mobileHudSkippedOrThrow(error, { nodeId: hud.nodeId, status: hud.status });
@@ -777,7 +796,7 @@ async function hideMobileHudOnTeardown(projectRoot, env = {}) {
777
796
  context: { nodeId: "teardown", projectRoot, artifactsDir: projectRoot, env }
778
797
  };
779
798
  try {
780
- await bridgeCommand(input, ["hide-step"]);
799
+ await bridgeCommand(input, ["hide-step-deferred"]);
781
800
  } catch {
782
801
  }
783
802
  }
@@ -33,6 +33,7 @@ import {
33
33
  emitHealViolation,
34
34
  executeWithHealBounds,
35
35
  prepareHeal,
36
+ persistRunEffects,
36
37
  recoverRunInfra,
37
38
  resolveMetaMaskLibrarySources,
38
39
  preflightRecipe,
@@ -52,6 +53,7 @@ import {
52
53
  import { recipeTrustFailure } from "../recipe-security.js";
53
54
  import { isSensitiveKey, recordCommandEvidence, redactStructuredValue } from "../command-journal.js";
54
55
  import { closest } from "../command-contract.js";
56
+ import { ProvenanceDriftError } from "../execution-provenance.js";
55
57
  async function handleCall(argv) {
56
58
  if (argv.includes("--list")) {
57
59
  const { options: options2 } = parseArgs(argv, "call");
@@ -282,11 +284,20 @@ async function handleCall(argv) {
282
284
  return checkoutBusyOut(json, "call", lock.message, lock.path);
283
285
  }
284
286
  try {
287
+ const authoredMobileRestart = adapter === "mobile" && resolvedAction === "app.lifecycle" && args.command === "restart";
285
288
  const prepared = await prepareHeal(adapter, target, options, json, {
286
- skipMobileSourceFreshness: adapter === "mobile" && (resolvedAction === "app.lifecycle" || readMobileReleaseArtifactState(target) !== null)
289
+ skipMobileSourceFreshness: authoredMobileRestart || adapter === "mobile" && readMobileReleaseArtifactState(target) !== null
287
290
  });
288
291
  if (typeof prepared === "number") return prepared;
289
292
  const { state, heal } = prepared;
293
+ preflightedExecution = await preflightRecipe(
294
+ adapter,
295
+ recipe,
296
+ artifactsDir,
297
+ target,
298
+ actionManifestOverride,
299
+ callRuntimeOptions
300
+ );
290
301
  networkObservation = await startRunNetworkObservation(
291
302
  adapter,
292
303
  target,
@@ -320,7 +331,8 @@ async function handleCall(argv) {
320
331
  target,
321
332
  actionManifestOverride,
322
333
  callRuntimeOptions,
323
- execution
334
+ execution,
335
+ state
324
336
  );
325
337
  },
326
338
  adapter,
@@ -337,6 +349,31 @@ async function handleCall(argv) {
337
349
  networkObservation = void 0;
338
350
  await performanceObservation?.finalize().catch(() => void 0);
339
351
  performanceObservation = void 0;
352
+ if (error instanceof ProvenanceDriftError) {
353
+ const failure = {
354
+ code: error.code,
355
+ message: error.message,
356
+ userAction: error.userAction,
357
+ provenancePath: error.provenancePath,
358
+ drift: error.drift
359
+ };
360
+ if (json) {
361
+ console.log(JSON.stringify({
362
+ schemaVersion: 1,
363
+ command: "call",
364
+ adapter,
365
+ action: shortName,
366
+ resolvedAction,
367
+ status: "fail",
368
+ error: failure,
369
+ exitCode: error.exitCode
370
+ }, null, 2));
371
+ } else {
372
+ console.error(`\u2717 mm-harness call: ${error.message}`);
373
+ console.error(` Next: ${error.userAction}`);
374
+ }
375
+ return error.exitCode;
376
+ }
340
377
  throw error;
341
378
  }
342
379
  const { result, violation } = executionResult;
@@ -344,6 +381,7 @@ async function handleCall(argv) {
344
381
  networkObservation = void 0;
345
382
  await performanceObservation?.finalize(result.artifactManifestPath);
346
383
  performanceObservation = void 0;
384
+ persistRunEffects(result.summaryPath, result.artifactManifestPath, state);
347
385
  if (violation !== null) {
348
386
  const conciseFailure = violation.originalError ? conciseFailureForHuman(violation.originalError) : "";
349
387
  const example = describedAction ? actionExampleCommand(