@deeeed/metamask-harness 0.43.0 → 0.44.0

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.
@@ -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(