@deeeed/metamask-harness 0.26.5 → 0.28.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) {
@@ -0,0 +1,13 @@
1
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
2
+ import {
3
+ locatorsFromObservation,
4
+ requireVisibleObservation,
5
+ } from '../../shared/ui/locators.mjs';
6
+
7
+ runAdapter(async (input) => withExtensionPage(input, async (page) => {
8
+ const observed = await page.observe(['ui.visible']);
9
+ return {
10
+ action: input.action,
11
+ ...locatorsFromObservation(requireVisibleObservation(observed)),
12
+ };
13
+ }));
@@ -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;
@@ -483,13 +490,14 @@ async function androidScreenshot(input, relPath) {
483
490
  try {
484
491
  temporaryDirectory = await createScreenshotTemporaryDirectory();
485
492
  const adbSerial = await resolveAndroidScreenshotSerial(input);
493
+ const adbPath = resolveMobileToolPath('adb', { required: true });
486
494
  let result;
487
495
  try {
488
- result = await runScreenshotProcess('adb', ['-s', adbSerial, 'exec-out', 'screencap', '-p'], true);
496
+ result = await runScreenshotProcess(adbPath, ['-s', adbSerial, 'exec-out', 'screencap', '-p'], true);
489
497
  } catch (error) {
490
498
  throw new Error(
491
499
  `adb screenshot could not start for ${adbSerial}: ${error instanceof Error ? error.message : String(error)}\n` +
492
- ' 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.',
493
501
  );
494
502
  }
495
503
  if (result.exitCode !== 0) {
@@ -541,11 +549,12 @@ async function resolveAndroidScreenshotSerial(input) {
541
549
 
542
550
  let result;
543
551
  try {
544
- result = await runScreenshotProcess('adb', ['devices'], false);
552
+ const adbPath = resolveMobileToolPath('adb', { required: true });
553
+ result = await runScreenshotProcess(adbPath, ['devices'], false);
545
554
  } catch (error) {
546
555
  throw new Error(
547
556
  `Android screenshot could not resolve an adb serial: ${error instanceof Error ? error.message : String(error)}\n` +
548
- ' 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>.',
549
558
  );
550
559
  }
551
560
  if (result.exitCode !== 0) {