@deeeed/metamask-harness 0.26.4 → 0.27.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.
@@ -84,76 +84,107 @@ function resolveRelativeArtifactPath(artifactsDir, relPath) {
84
84
  if (absolute !== artifactsRoot && !absolute.startsWith(`${artifactsRoot}${path.sep}`)) {
85
85
  throw new Error(`Refusing Extension screenshot artifact path outside artifacts dir: ${relative}`);
86
86
  }
87
- return { relative: normalized, absolute };
87
+ return { relative: normalized, absolute, artifactsRoot };
88
88
  }
89
89
 
90
- async function preparePrivateArtifactStage(artifactsDir, relPath) {
91
- const destination = resolveRelativeArtifactPath(artifactsDir, relPath);
92
- const artifactsRoot = path.resolve(artifactsDir);
93
- await mkdir(artifactsRoot, { recursive: true });
94
- await ensureArtifactParent(artifactsRoot, path.dirname(destination.absolute));
95
- await refuseUnsafeDestination(destination.absolute);
96
- const stagingDir = await mkdtemp(path.join(artifactsRoot, '.extension-evidence-'));
90
+ async function preparePrivateEvidenceDestination(absolute, artifactsRoot) {
91
+ const outputDir = path.dirname(absolute);
92
+ await ensureEvidenceDirectory(artifactsRoot, outputDir);
93
+ await refuseEvidenceDestinationSymlink(absolute);
94
+ const stagingDir = await mkdtemp(path.join(outputDir, '.mm-harness-extension-evidence-'));
97
95
  await chmod(stagingDir, 0o700);
98
96
  return {
99
- ...destination,
97
+ absolute,
100
98
  stagingDir,
101
- staged: path.join(stagingDir, 'artifact'),
99
+ stagedPath: path.join(stagingDir, 'captured.png'),
102
100
  };
103
101
  }
104
102
 
105
- async function ensureArtifactParent(artifactsRoot, parent) {
106
- const relative = path.relative(artifactsRoot, parent);
103
+ async function ensureEvidenceDirectory(artifactsRoot, outputDir) {
104
+ await mkdir(artifactsRoot, { recursive: true });
105
+ await requireEvidenceDirectory(artifactsRoot);
106
+ const relative = path.relative(artifactsRoot, outputDir);
107
107
  let current = artifactsRoot;
108
108
  for (const segment of relative.split(path.sep).filter(Boolean)) {
109
109
  current = path.join(current, segment);
110
110
  try {
111
- const info = await lstat(current);
112
- if (info.isSymbolicLink() || !info.isDirectory()) {
113
- throw new Error(`Refusing Extension evidence parent that is not a real directory: ${current}`);
114
- }
111
+ await mkdir(current);
115
112
  } catch (error) {
116
- if (error?.code !== 'ENOENT') throw error;
117
- await mkdir(current, { mode: 0o700 });
113
+ if (error?.code !== 'EEXIST') throw error;
118
114
  }
115
+ await requireEvidenceDirectory(current);
119
116
  }
120
117
  }
121
118
 
122
- async function refuseUnsafeDestination(destination) {
119
+ async function requireEvidenceDirectory(directory) {
120
+ const entry = await lstat(directory);
121
+ if (entry.isDirectory() && !entry.isSymbolicLink()) return;
122
+ throw new Error(
123
+ `Refusing unsafe Extension evidence directory: ${directory}\n` +
124
+ `Next: replace ${JSON.stringify(directory)} with a regular directory and retry the recipe.`,
125
+ );
126
+ }
127
+
128
+ async function publishPrivateEvidence(destination) {
129
+ let handle;
123
130
  try {
124
- const info = await lstat(destination);
125
- if (info.isSymbolicLink()) {
126
- throw new Error(`Refusing Extension evidence destination symlink: ${destination}`);
127
- }
128
- if (!info.isFile()) {
129
- throw new Error(`Refusing Extension evidence destination that is not a regular file: ${destination}`);
131
+ try {
132
+ handle = await open(
133
+ destination.stagedPath,
134
+ constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
135
+ );
136
+ const stagedStat = await handle.stat();
137
+ if (!stagedStat.isFile()) {
138
+ throw unsafeEvidenceArtifactError(destination.stagedPath);
139
+ }
140
+ await handle.chmod(0o600);
141
+ } catch (error) {
142
+ if (error instanceof Error && error.message.includes('Next:')) throw error;
143
+ throw unsafeEvidenceArtifactError(
144
+ destination.stagedPath,
145
+ error instanceof Error ? error.message : String(error),
146
+ );
147
+ } finally {
148
+ await handle?.close();
130
149
  }
131
- } catch (error) {
132
- if (error?.code !== 'ENOENT') throw error;
150
+ await refuseEvidenceDestinationSymlink(destination.absolute);
151
+ await rename(destination.stagedPath, destination.absolute);
152
+ } finally {
153
+ await cleanupEvidenceStaging(destination.stagingDir);
133
154
  }
134
155
  }
135
156
 
136
- async function publishPrivateArtifact(stage) {
137
- const handle = await open(
138
- stage.staged,
139
- constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
140
- );
157
+ async function refuseEvidenceDestinationSymlink(absolute) {
158
+ let destination;
141
159
  try {
142
- const info = await handle.stat();
143
- if (!info.isFile()) {
144
- throw new Error(`Captured Extension evidence is not a safe regular file: ${stage.staged}`);
145
- }
146
- await handle.chmod(0o600);
147
- await refuseUnsafeDestination(stage.absolute);
148
- await rename(stage.staged, stage.absolute);
149
- } finally {
150
- await handle.close();
160
+ destination = await lstat(absolute);
161
+ } catch (error) {
162
+ if (error?.code === 'ENOENT') return;
163
+ throw error;
164
+ }
165
+ if (destination.isSymbolicLink()) {
166
+ throw new Error(
167
+ `Refusing Extension evidence destination symlink: ${absolute}\n` +
168
+ `Next: rm -- ${JSON.stringify(absolute)} and retry the recipe.`,
169
+ );
151
170
  }
152
- await rm(stage.stagingDir, { recursive: true, force: true });
171
+ if (!destination.isFile()) {
172
+ throw new Error(
173
+ `Refusing non-file Extension evidence destination: ${absolute}\n` +
174
+ `Next: remove ${JSON.stringify(absolute)} and retry the recipe.`,
175
+ );
176
+ }
177
+ }
178
+
179
+ function unsafeEvidenceArtifactError(stagedPath, detail) {
180
+ return new Error(
181
+ `Captured Extension evidence is not a safe regular file: ${stagedPath}${detail ? ` (${detail})` : ''}.\n` +
182
+ 'Next: mm-harness doctor --adapter extension --json',
183
+ );
153
184
  }
154
185
 
155
- async function cleanupPrivateArtifactStage(stage) {
156
- await rm(stage.stagingDir, { recursive: true, force: true });
186
+ async function cleanupEvidenceStaging(stagingDir) {
187
+ await rm(stagingDir, { recursive: true, force: true });
157
188
  }
158
189
 
159
190
  function captureHelperPath() {
@@ -203,10 +234,12 @@ async function captureHelperBrowserPid(context, port) {
203
234
  }
204
235
 
205
236
  async function captureCdpViewportSnapshot(page, context, relPath, metadata, captureHelperError = null) {
206
- const stage = await preparePrivateArtifactStage(context.artifactsDir, relPath);
237
+ const { relative, absolute, artifactsRoot } = resolveRelativeArtifactPath(context.artifactsDir, relPath);
238
+ const destination = await preparePrivateEvidenceDestination(absolute, artifactsRoot);
239
+ let result;
207
240
  try {
208
241
  const timeoutMs = Number(metadata?.cdpTimeoutMs ?? 5000);
209
- const result = await Promise.race([
242
+ result = await Promise.race([
210
243
  page.session.call('Page.captureScreenshot', {
211
244
  format: 'png',
212
245
  fromSurface: true,
@@ -217,33 +250,41 @@ async function captureCdpViewportSnapshot(page, context, relPath, metadata, capt
217
250
  if (typeof result?.data !== 'string' || result.data.length === 0) {
218
251
  throw new Error('Chrome Page.captureScreenshot returned no image data.');
219
252
  }
220
- await writeFile(stage.staged, Buffer.from(result.data, 'base64'), {
221
- flag: 'wx',
222
- mode: 0o600,
223
- });
224
- await publishPrivateArtifact(stage);
225
- return {
226
- path: stage.relative,
227
- type: 'screenshot',
228
- nodeId: context.nodeId,
229
- label: metadata?.label ?? `${context.nodeId} screenshot`,
230
- category: metadata?.category ?? 'evidence',
231
- mimeType: 'image/png',
232
- metadata: {
233
- provider: 'cdp',
234
- mode: 'Page.captureScreenshot',
235
- ...(captureHelperError ? { fallbackFrom: 'capture-helper', captureHelperError } : {}),
236
- },
237
- };
238
253
  } catch (error) {
239
- await cleanupPrivateArtifactStage(stage);
254
+ await cleanupEvidenceStaging(destination.stagingDir);
240
255
  const cdpError = error instanceof Error ? error.message : String(error);
241
256
  return captureDomRasterSnapshot(page, context, relPath, metadata, captureHelperError, cdpError);
242
257
  }
258
+ try {
259
+ await writeFile(
260
+ destination.stagedPath,
261
+ Buffer.from(result.data, 'base64'),
262
+ { flag: 'wx', mode: 0o600 },
263
+ );
264
+ } catch (error) {
265
+ await cleanupEvidenceStaging(destination.stagingDir);
266
+ throw error;
267
+ }
268
+ await publishPrivateEvidence(destination);
269
+ return {
270
+ path: relative,
271
+ type: 'screenshot',
272
+ nodeId: context.nodeId,
273
+ label: metadata?.label ?? `${context.nodeId} screenshot`,
274
+ category: metadata?.category ?? 'evidence',
275
+ mimeType: 'image/png',
276
+ metadata: {
277
+ provider: 'cdp',
278
+ mode: 'Page.captureScreenshot',
279
+ ...(captureHelperError ? { fallbackFrom: 'capture-helper', captureHelperError } : {}),
280
+ },
281
+ };
243
282
  }
244
283
 
245
284
  async function captureDomRasterSnapshot(page, context, relPath, metadata, captureHelperError, cdpError) {
246
- const dataUrl = await page.evaluate(`(async () => {
285
+ const { relative, absolute, artifactsRoot } = resolveRelativeArtifactPath(context.artifactsDir, relPath);
286
+ const destination = await preparePrivateEvidenceDestination(absolute, artifactsRoot);
287
+ const dataUrlPromise = page.evaluate(`(async () => {
247
288
  const width = Math.max(1, window.innerWidth);
248
289
  const height = Math.max(1, window.innerHeight);
249
290
  const source = document.documentElement;
@@ -282,23 +323,24 @@ async function captureDomRasterSnapshot(page, context, relPath, metadata, captur
282
323
  context.drawImage(image, 0, 0, width, height);
283
324
  return canvas.toDataURL('image/png');
284
325
  })()`);
285
- if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/png;base64,')) {
286
- throw new Error(`Extension screenshot fallbacks failed: capture-helper=${captureHelperError ?? 'not attempted'}; cdp=${cdpError}; DOM raster returned no PNG.`);
287
- }
288
- const stage = await preparePrivateArtifactStage(context.artifactsDir, relPath);
326
+ let dataUrl;
289
327
  try {
328
+ dataUrl = await dataUrlPromise;
329
+ if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/png;base64,')) {
330
+ throw new Error(`Extension screenshot fallbacks failed: capture-helper=${captureHelperError ?? 'not attempted'}; cdp=${cdpError}; DOM raster returned no PNG.`);
331
+ }
290
332
  await writeFile(
291
- stage.staged,
333
+ destination.stagedPath,
292
334
  Buffer.from(dataUrl.slice('data:image/png;base64,'.length), 'base64'),
293
335
  { flag: 'wx', mode: 0o600 },
294
336
  );
295
- await publishPrivateArtifact(stage);
296
337
  } catch (error) {
297
- await cleanupPrivateArtifactStage(stage);
338
+ await cleanupEvidenceStaging(destination.stagingDir);
298
339
  throw error;
299
340
  }
341
+ await publishPrivateEvidence(destination);
300
342
  return {
301
- path: stage.relative,
343
+ path: relative,
302
344
  type: 'screenshot',
303
345
  nodeId: context.nodeId,
304
346
  label: metadata?.label ?? `${context.nodeId} screenshot`,
@@ -318,59 +360,70 @@ export async function captureHelperSnapshot(page, context, relPath, metadata) {
318
360
  if (process.platform !== 'darwin') {
319
361
  return captureCdpViewportSnapshot(page, context, relPath, metadata);
320
362
  }
321
- const stage = await preparePrivateArtifactStage(context.artifactsDir, relPath);
363
+ const { relative, absolute, artifactsRoot } = resolveRelativeArtifactPath(context.artifactsDir, relPath);
364
+ const destination = await preparePrivateEvidenceDestination(absolute, artifactsRoot);
322
365
 
366
+ let pid;
367
+ let mode;
368
+ let details;
323
369
  try {
324
- const pid = await captureHelperBrowserPid(context, page.port);
370
+ pid = await captureHelperBrowserPid(context, page.port);
325
371
  const timeoutMs = Number(metadata?.timeoutMs ?? 30000);
326
- const sessionSnapshot = await captureActiveRecipeRecordingSnapshot(pid, stage.staged, timeoutMs);
372
+ const sessionSnapshot = await captureActiveRecipeRecordingSnapshot(
373
+ pid,
374
+ destination.stagedPath,
375
+ timeoutMs,
376
+ );
327
377
  if (sessionSnapshot) {
328
- await publishPrivateArtifact(stage);
329
- return {
330
- path: stage.relative,
331
- type: 'screenshot',
332
- nodeId: context.nodeId,
333
- label: metadata?.label ?? `${context.nodeId} screenshot`,
334
- category: metadata?.category ?? 'evidence',
335
- mimeType: 'image/png',
336
- metadata: {
337
- provider: 'capture-helper',
338
- mode: 'record_session_snapshot',
339
- pid,
340
- captureHelper: sessionSnapshot,
378
+ mode = 'record_session_snapshot';
379
+ details = sessionSnapshot;
380
+ } else if (
381
+ parsePositivePid(process.env.METAMASK_RECIPE_EXTENSION_ACTIVE_RECORDING_PID) === pid
382
+ ) {
383
+ await cleanupEvidenceStaging(destination.stagingDir);
384
+ return captureCdpViewportSnapshot(
385
+ page,
386
+ context,
387
+ relPath,
388
+ metadata,
389
+ 'native snapshot skipped while full-run recording owns the browser window',
390
+ );
391
+ } else {
392
+ const result = await runProcess(
393
+ captureHelperPath(),
394
+ ['snapshot', '--pid', String(pid), '--output', destination.stagedPath],
395
+ {
396
+ cwd: context.projectRoot,
397
+ env: process.env,
398
+ timeoutMs,
341
399
  },
342
- };
343
- }
344
-
345
- const result = await runProcess(captureHelperPath(), ['snapshot', '--pid', String(pid), '--output', stage.staged], {
346
- cwd: context.projectRoot,
347
- env: process.env,
348
- timeoutMs,
349
- });
350
- if (result.exitCode !== 0) {
351
- throw new Error(`capture-helper snapshot failed for pid ${pid}: ${result.stderr || result.stdout}`);
400
+ );
401
+ if (result.exitCode !== 0) {
402
+ throw new Error(`capture-helper snapshot failed for pid ${pid}: ${result.stderr || result.stdout}`);
403
+ }
404
+ mode = 'snapshot';
405
+ details = parseJsonObject(result.stdout);
352
406
  }
353
- const details = parseJsonObject(result.stdout);
354
- await publishPrivateArtifact(stage);
355
- return {
356
- path: stage.relative,
357
- type: 'screenshot',
358
- nodeId: context.nodeId,
359
- label: metadata?.label ?? `${context.nodeId} screenshot`,
360
- category: metadata?.category ?? 'evidence',
361
- mimeType: 'image/png',
362
- metadata: {
363
- provider: 'capture-helper',
364
- mode: 'snapshot',
365
- pid,
366
- ...(details ? { captureHelper: details } : {}),
367
- },
368
- };
369
407
  } catch (error) {
370
- await cleanupPrivateArtifactStage(stage);
408
+ await cleanupEvidenceStaging(destination.stagingDir);
371
409
  const message = error instanceof Error ? error.message : String(error);
372
410
  return captureCdpViewportSnapshot(page, context, relPath, metadata, message);
373
411
  }
412
+ await publishPrivateEvidence(destination);
413
+ return {
414
+ path: relative,
415
+ type: 'screenshot',
416
+ nodeId: context.nodeId,
417
+ label: metadata?.label ?? `${context.nodeId} screenshot`,
418
+ category: metadata?.category ?? 'evidence',
419
+ mimeType: 'image/png',
420
+ metadata: {
421
+ provider: 'capture-helper',
422
+ mode,
423
+ pid,
424
+ ...(details ? { captureHelper: details } : {}),
425
+ },
426
+ };
374
427
  }
375
428
 
376
429
  function autolaunchEnabled(input) {
@@ -6,11 +6,8 @@ const SWITCH_IDS = {
6
6
  marketing: 'data-collection-switch',
7
7
  };
8
8
 
9
- function toggleExpression(testId, expected, timeoutMs) {
10
- return `(async () => {
11
- const deadline = Date.now() + ${JSON.stringify(timeoutMs)};
12
- let invoked = false;
13
- const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
9
+ function toggleExpression(testId, expected, invoke) {
10
+ return `(() => {
14
11
  const find = () => {
15
12
  const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
16
13
  const rootsFor = hook?.getFiberRoots;
@@ -28,57 +25,96 @@ function toggleExpression(testId, expected, timeoutMs) {
28
25
  }
29
26
  return null;
30
27
  };
31
- while (Date.now() < deadline) {
32
- const target = find();
33
- const props = target?.memoizedProps;
34
- if (props && Boolean(props.value) === ${JSON.stringify(expected)}) {
35
- return { ok: true, testId: ${JSON.stringify(testId)}, changed: invoked };
36
- }
37
- if (props && !invoked) {
38
- if (typeof props.onValueChange !== 'function') {
39
- throw new Error('Consent switch has no onValueChange handler: ${testId}');
40
- }
41
- invoked = true;
42
- await props.onValueChange(${JSON.stringify(expected)});
43
- }
44
- await delay(100);
28
+ const target = find();
29
+ const props = target?.memoizedProps;
30
+ if (!props) return { matched: false, found: false, invoked: false };
31
+ if (Boolean(props.value) === ${JSON.stringify(expected)}) {
32
+ return { matched: true, found: true, invoked: false };
33
+ }
34
+ if (!${JSON.stringify(invoke)}) {
35
+ return { matched: false, found: true, invoked: false };
36
+ }
37
+ if (typeof props.onValueChange !== 'function') {
38
+ throw new Error('Consent switch has no onValueChange handler: ${testId}');
45
39
  }
46
- throw new Error('Timed out setting consent switch ${testId} to ${expected}.');
40
+ return Promise.resolve(
41
+ props.onValueChange(${JSON.stringify(expected)})
42
+ ).then(() => ({
43
+ matched: false,
44
+ found: true,
45
+ invoked: true
46
+ }));
47
47
  })()`;
48
48
  }
49
49
 
50
- function stateExpression(participate, marketing, timeoutMs) {
51
- return `(async () => {
52
- const deadline = Date.now() + ${JSON.stringify(timeoutMs)};
53
- const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
54
- while (Date.now() < deadline) {
55
- const state = globalThis.store?.getState?.();
56
- const analytics = state?.engine?.backgroundState?.AnalyticsController ?? {};
57
- const consent = {
58
- optedIn: Boolean(analytics.optedIn),
59
- dataCollectionForMarketing: Boolean(state?.security?.dataCollectionForMarketing),
60
- analyticsId: analytics.analyticsId ? 'set' : null
61
- };
62
- if (
50
+ function stateExpression(participate, marketing) {
51
+ return `(() => {
52
+ const state = globalThis.store?.getState?.();
53
+ const analytics = state?.engine?.backgroundState?.AnalyticsController ?? {};
54
+ const consent = {
55
+ optedIn: Boolean(analytics.optedIn),
56
+ dataCollectionForMarketing: Boolean(state?.security?.dataCollectionForMarketing),
57
+ analyticsId: analytics.analyticsId ? 'set' : null
58
+ };
59
+ return {
60
+ ready:
63
61
  consent.optedIn === ${JSON.stringify(participate)} &&
64
62
  consent.dataCollectionForMarketing === ${JSON.stringify(marketing)} &&
65
- (!${JSON.stringify(participate)} || consent.analyticsId === 'set')
66
- ) return consent;
67
- await delay(100);
68
- }
69
- throw new Error('Timed out reading back Mobile analytics consent.');
63
+ (!${JSON.stringify(participate)} || consent.analyticsId === 'set'),
64
+ consent
65
+ };
70
66
  })()`;
71
67
  }
72
68
 
69
+ function delay(ms) {
70
+ return new Promise((resolve) => setTimeout(resolve, ms));
71
+ }
72
+
73
+ async function setSwitch(input, testId, expected, timeoutMs) {
74
+ const deadline = Date.now() + timeoutMs;
75
+ let invoked = false;
76
+ while (Date.now() < deadline) {
77
+ const result = await evalAsync(
78
+ input,
79
+ toggleExpression(testId, expected, !invoked),
80
+ );
81
+ invoked ||= Boolean(result?.invoked);
82
+ if (result?.matched) {
83
+ return { ...result, changed: invoked };
84
+ }
85
+ await delay(100);
86
+ }
87
+ throw new Error(`Timed out setting consent switch ${testId} to ${expected}.`);
88
+ }
89
+
90
+ async function readConsent(input, participate, marketing, timeoutMs) {
91
+ const deadline = Date.now() + timeoutMs;
92
+ while (Date.now() < deadline) {
93
+ const result = await evalAsync(
94
+ input,
95
+ stateExpression(participate, marketing),
96
+ );
97
+ if (result?.ready) {
98
+ return result.consent;
99
+ }
100
+ await delay(100);
101
+ }
102
+ throw new Error('Timed out reading back Mobile analytics consent.');
103
+ }
104
+
73
105
  runAdapter(async (input) => {
74
106
  const { participate, marketing, timeoutMs } = consentParams(input.node);
75
107
 
76
- const navigation = await navigate(input, 'SecuritySettings');
77
- await evalAsync(input, toggleExpression(SWITCH_IDS.participate, participate, timeoutMs));
78
- await evalAsync(input, toggleExpression(SWITCH_IDS.marketing, marketing, timeoutMs));
79
- const consent = await evalAsync(
108
+ const navigation = await navigate(input, 'SettingsView', {
109
+ screen: 'SecuritySettings',
110
+ }, 'SecuritySettings');
111
+ await setSwitch(input, SWITCH_IDS.participate, participate, timeoutMs);
112
+ await setSwitch(input, SWITCH_IDS.marketing, marketing, timeoutMs);
113
+ const consent = await readConsent(
80
114
  input,
81
- stateExpression(participate, marketing, timeoutMs),
115
+ participate,
116
+ marketing,
117
+ timeoutMs,
82
118
  );
83
119
 
84
120
  return {
@@ -7,6 +7,7 @@ import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import bridgeErrors from '../../../../adapters/mobile/bridge-runtime/lib/bridge-errors.cjs';
10
+ import { resolveMobileToolPath } from './tool-paths.mjs';
10
11
 
11
12
  const {
12
13
  BRIDGE_ERROR_CODES,
@@ -72,9 +73,11 @@ function runtimeDir() {
72
73
  * or the serial does not respond. Uses execFile (never shell interpolation).
73
74
  */
74
75
  async function resolveAndroidModel(adbSerial) {
76
+ const adbPath = resolveMobileToolPath('adb');
77
+ if (!adbPath) return null;
75
78
  try {
76
79
  const { stdout } = await execFileAsync(
77
- 'adb',
80
+ adbPath,
78
81
  ['-s', adbSerial, 'shell', 'getprop', 'ro.product.model'],
79
82
  { timeout: 5000, encoding: 'utf8' },
80
83
  );
@@ -90,12 +93,16 @@ async function resolveAndroidModel(adbSerial) {
90
93
  }
91
94
 
92
95
  export async function bridgeEnv(input) {
96
+ const adbPath = resolveMobileToolPath('adb');
97
+ const idbPath = resolveMobileToolPath('idb');
93
98
  /** @type {NodeJS.ProcessEnv} */
94
99
  const env = {
95
100
  ...process.env,
96
101
  ...(input.context?.env || {}),
97
102
  CDP_TIMEOUT: String(input.node?.cdp_timeout_ms ?? process.env.CDP_TIMEOUT ?? '10000'),
98
103
  };
104
+ if (adbPath) env.MM_HARNESS_ADB_PATH = adbPath;
105
+ if (idbPath) env.MM_HARNESS_IDB_PATH = idbPath;
99
106
  const target = resolveMobileTarget(input);
100
107
  const watcherPort = target.watcherPort;
101
108
  const simulator = target.iosSimulator;
@@ -355,11 +362,14 @@ export async function evalSync(input, expression) {
355
362
  return parseMaybeJson(await bridgeCommand(input, ['eval', expression]));
356
363
  }
357
364
 
358
- export async function navigate(input, route, params = {}) {
365
+ export async function navigate(input, route, params = {}, expectedRoute) {
359
366
  const navigation = await bridgeCommand(input, ['navigate', route, JSON.stringify(params)]);
360
- const verifiedRoute = navigation && typeof navigation === 'object' && navigation.navigated
361
- ? String(navigation.navigated)
362
- : String(route);
367
+ const verifiedRoute = String(
368
+ expectedRoute ??
369
+ (navigation && typeof navigation === 'object' && navigation.navigated
370
+ ? navigation.navigated
371
+ : route),
372
+ );
363
373
  const currentRoute = await waitForRoute(input, verifiedRoute, Number(input.node?.navigation_timeout_ms ?? 15000));
364
374
  return { ...navigation, currentRoute, verifiedRoute };
365
375
  }
@@ -480,13 +490,14 @@ async function androidScreenshot(input, relPath) {
480
490
  try {
481
491
  temporaryDirectory = await createScreenshotTemporaryDirectory();
482
492
  const adbSerial = await resolveAndroidScreenshotSerial(input);
493
+ const adbPath = resolveMobileToolPath('adb', { required: true });
483
494
  let result;
484
495
  try {
485
- result = await runScreenshotProcess('adb', ['-s', adbSerial, 'exec-out', 'screencap', '-p'], true);
496
+ result = await runScreenshotProcess(adbPath, ['-s', adbSerial, 'exec-out', 'screencap', '-p'], true);
486
497
  } catch (error) {
487
498
  throw new Error(
488
499
  `adb screenshot could not start for ${adbSerial}: ${error instanceof Error ? error.message : String(error)}\n` +
489
- ' Next: install Android SDK Platform-Tools, ensure adb is on PATH, then rerun the same mm-harness command.',
500
+ ' Next: install Android SDK Platform-Tools, then rerun the same mm-harness command.',
490
501
  );
491
502
  }
492
503
  if (result.exitCode !== 0) {
@@ -538,11 +549,12 @@ async function resolveAndroidScreenshotSerial(input) {
538
549
 
539
550
  let result;
540
551
  try {
541
- result = await runScreenshotProcess('adb', ['devices'], false);
552
+ const adbPath = resolveMobileToolPath('adb', { required: true });
553
+ result = await runScreenshotProcess(adbPath, ['devices'], false);
542
554
  } catch (error) {
543
555
  throw new Error(
544
556
  `Android screenshot could not resolve an adb serial: ${error instanceof Error ? error.message : String(error)}\n` +
545
- ' Next: install Android SDK Platform-Tools, ensure adb is on PATH, then rerun with --device <adb-serial>.',
557
+ ' Next: install Android SDK Platform-Tools, then rerun with --device <adb-serial>.',
546
558
  );
547
559
  }
548
560
  if (result.exitCode !== 0) {