@deeeed/metamask-harness 0.14.7 → 0.15.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +87 -6
  2. package/adapters/core/cleanup.sh +0 -0
  3. package/adapters/core/inject.sh +0 -0
  4. package/adapters/extension/cleanup.mjs +0 -0
  5. package/adapters/extension/ensure-browser.sh +4 -2
  6. package/adapters/extension/inject.mjs +0 -0
  7. package/adapters/extension/launch-browser.cjs +73 -34
  8. package/adapters/extension/launch.sh +0 -0
  9. package/adapters/extension/live.sh +21 -10
  10. package/adapters/extension/readiness.mjs +0 -0
  11. package/adapters/extension/reattach.sh +3 -3
  12. package/adapters/extension/refresh-build.sh +0 -0
  13. package/adapters/extension/seed-fixture.sh +0 -0
  14. package/adapters/extension/sidepanel-toggle.sh +10 -4
  15. package/adapters/extension/snapshot-dist.sh +0 -0
  16. package/adapters/extension/start-watch.sh +2 -2
  17. package/adapters/extension/stop-viewers.sh +0 -0
  18. package/adapters/extension/verify.sh +19 -2
  19. package/adapters/extension/wallet-fixture-state.cjs +14 -1
  20. package/adapters/manifest.json +19 -3
  21. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +0 -0
  22. package/adapters/mobile/bridge-runtime/setup-wallet.sh +6 -4
  23. package/adapters/mobile/cleanup.sh +0 -0
  24. package/adapters/mobile/inject.sh +0 -0
  25. package/adapters/mobile/launch-metro.cjs +74 -0
  26. package/adapters/mobile/lib/metro-listener.sh +0 -0
  27. package/adapters/mobile/lib/tmux-viewer.sh +4 -4
  28. package/adapters/mobile/open-device.sh +3 -1
  29. package/adapters/mobile/prewarm-bundle.sh +0 -0
  30. package/adapters/mobile/start-metro.sh +12 -18
  31. package/adapters/mobile/stop-metro.sh +1 -0
  32. package/adapters/mobile/verify.sh +5 -5
  33. package/adapters/mobile/wait-for-bridge.sh +0 -0
  34. package/adapters/mobile/yarn-setup.sh +0 -0
  35. package/adapters/shared/activate-repo-node.sh +0 -0
  36. package/adapters/shared/activate-repo-ruby.sh +0 -0
  37. package/adapters/shared/cli-ux.sh +0 -0
  38. package/adapters/shared/ensure-runner-deps.sh +0 -0
  39. package/adapters/shared/harness-path.sh +0 -0
  40. package/adapters/shared/hash-helpers.sh +0 -0
  41. package/adapters/shared/install-repo-deps.sh +29 -0
  42. package/adapters/shared/json-field.sh +0 -0
  43. package/adapters/shared/open-debug.mjs +35 -4
  44. package/adapters/shared/open-log-window.sh +1 -1
  45. package/adapters/shared/reap-checkout-metros.sh +0 -0
  46. package/adapters/shared/resolve-farmslot-ports.mjs +0 -0
  47. package/adapters/shared/resolve-farmslot-ports.sh +0 -0
  48. package/adapters/shared/resolve-slot-ports-core.mjs +72 -1
  49. package/adapters/shared/resolve-slot-ports.mjs +0 -0
  50. package/adapters/shared/resolve-slot-ports.sh +21 -17
  51. package/adapters/shared/sync-wallet-fixture.sh +0 -0
  52. package/adapters/shared/tmux-session.sh +0 -5
  53. package/adapters/shared/tmux-viewer.sh +7 -2
  54. package/bin/mm-harness +7 -0
  55. package/dist/adapters/core/surface.js +3 -0
  56. package/dist/adapters/extension/ensure-ready.js +0 -7
  57. package/dist/adapters/extension/runtime-decision.js +93 -3
  58. package/dist/adapters/extension/surface.js +17 -6
  59. package/dist/adapters/mobile/metro-env.js +67 -0
  60. package/dist/adapters/mobile/perps-env.js +101 -0
  61. package/dist/adapters/mobile/prepare.js +32 -14
  62. package/dist/adapters/mobile/runtime-decision.js +21 -2
  63. package/dist/adapters/mobile/source-freshness.js +116 -0
  64. package/dist/adapters/mobile/surface.js +3 -0
  65. package/dist/adapters/resolve-slot-ports.js +4 -0
  66. package/dist/adapters/slot-ports.js +131 -49
  67. package/dist/adapters.js +7 -2
  68. package/dist/checkout-lock.js +72 -0
  69. package/dist/cli.js +2 -0
  70. package/dist/commands/call.js +91 -54
  71. package/dist/commands/check.js +266 -46
  72. package/dist/commands/checklist.js +136 -0
  73. package/dist/commands/debug.js +21 -2
  74. package/dist/commands/doctor.js +196 -13
  75. package/dist/commands/fixtures.js +177 -42
  76. package/dist/commands/launch/extension.js +6 -1
  77. package/dist/commands/launch/index.js +54 -22
  78. package/dist/commands/logs.js +24 -4
  79. package/dist/commands/run-engine.js +223 -7
  80. package/dist/commands/run.js +67 -50
  81. package/dist/commands/shared.js +86 -5
  82. package/dist/commands/stop.js +1 -2
  83. package/dist/doctor.js +39 -17
  84. package/dist/harness.js +5 -2
  85. package/dist/heal-bounds.js +1 -1
  86. package/dist/mm-harness-cli.js +37 -9
  87. package/dist/runner.js +35 -5
  88. package/dist/runtime-context.js +228 -0
  89. package/docs/CLI-SPEC.md +15 -11
  90. package/docs/MENTAL-MODEL.md +6 -6
  91. package/library/actions/extension/perps/perps.mjs +29 -8
  92. package/library/actions/extension/platform/cdp.mjs +128 -28
  93. package/library/actions/extension/ui/navigate.mjs +1 -1
  94. package/library/actions/mobile/perps/perps.mjs +13 -1
  95. package/library/actions/mobile/platform/bridge.mjs +302 -29
  96. package/library/actions/mobile/wallet/ensure_unlocked.mjs +7 -2
  97. package/library/manifests/extension.action-manifest.json +32 -12
  98. package/library/manifests/mobile.action-manifest.json +25 -3
  99. package/package.json +5 -5
  100. package/scripts/completions.sh +7 -4
  101. package/scripts/install-completions.sh +0 -0
@@ -1,5 +1,6 @@
1
1
  import { pathToFileURL } from 'node:url';
2
2
  import { evalAsync, navigate, runAdapter } from '../platform/bridge.mjs';
3
+ import { ensureUnlocked } from '../wallet/ensure_unlocked.mjs';
3
4
 
4
5
  function sleep(ms) {
5
6
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -637,6 +638,17 @@ async function applyTutorialState(input, config) {
637
638
  export async function startState(input) {
638
639
  const params = paramsForState(input);
639
640
  const config = mergeStateConfig(profileDefaults(params.profile), params);
641
+ // Product edits require an app restart before proof. Mobile relocks on restart,
642
+ // so a deterministic Perps start state must restore the fixture-backed wallet
643
+ // prerequisite before it navigates or converges controller state.
644
+ const wallet = await ensureUnlocked({
645
+ ...input,
646
+ action: 'metamask.wallet.ensure_unlocked',
647
+ node: { ...input.node, action: 'metamask.wallet.ensure_unlocked' },
648
+ });
649
+ // Entering Perps initializes the provider client on a freshly launched app.
650
+ // State reads before this navigation fail with CLIENT_NOT_INITIALIZED.
651
+ const navigation = await applyStateNavigation(input, config);
640
652
  const provider = await ensureProvider(input, config);
641
653
  const network = await ensureNetwork(input, config);
642
654
  const tutorial = await applyTutorialState(input, config);
@@ -644,11 +656,11 @@ export async function startState(input) {
644
656
  const balance = await assertBalance(input, config);
645
657
  const orders = await applyOrdersState(input, config.orders);
646
658
  const positions = await applyPositionsState(input, config.positions);
647
- const navigation = await applyStateNavigation(input, config);
648
659
  return {
649
660
  action: input.action,
650
661
  profile: config.profile ?? params.profile ?? 'clean_market_testnet',
651
662
  phase: 'start_state',
663
+ wallet,
652
664
  provider,
653
665
  network,
654
666
  tutorial,
@@ -1,4 +1,6 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants as fsConstants } from 'node:fs';
3
+ import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
4
  import { execFile, spawn } from 'node:child_process';
3
5
  import { promisify } from 'node:util';
4
6
  import path from 'node:path';
@@ -405,25 +407,54 @@ export async function simulatorScreenshot(input, relPath) {
405
407
  const adbSerial = targetInfo.adbSerial;
406
408
  const platform = String(input.node?.platform ?? process.env.PLATFORM ?? '').toLowerCase();
407
409
  if (androidTarget || adbSerial || platform === 'android') {
408
- return androidScreenshot(input, relPath, adbSerial);
410
+ return androidScreenshot(input, relPath);
409
411
  }
410
412
  const target = targetInfo.iosSimulator;
411
413
  if (!target) {
414
+ const { absolute } = resolveArtifactPath(input.context.artifactsDir, relPath || `screenshots/${input.context.nodeId}.png`);
415
+ await rm(absolute, { force: true });
412
416
  throw new Error('iOS screenshot requires node.simulator, node.ios_simulator, or IOS_SIMULATOR so the proof is tied to the same device as the bridge commands.');
413
417
  }
414
418
  const { relative, absolute } = resolveArtifactPath(input.context.artifactsDir, relPath || `screenshots/${input.context.nodeId}.png`);
415
419
  await mkdir(path.dirname(absolute), { recursive: true });
416
- const result = await new Promise((resolve, reject) => {
417
- const child = spawn('xcrun', ['simctl', 'io', String(target), 'screenshot', absolute], { stdio: ['ignore', 'pipe', 'pipe'] });
418
- let stdout = '';
419
- let stderr = '';
420
- child.stdout.on('data', (chunk) => { stdout += chunk; });
421
- child.stderr.on('data', (chunk) => { stderr += chunk; });
422
- child.on('error', reject);
423
- child.on('close', (exitCode) => resolve({ exitCode, stdout, stderr }));
424
- });
425
- if (result.exitCode !== 0) {
426
- throw new Error(`simctl screenshot failed for ${target}: ${result.stderr || result.stdout}`);
420
+ await rm(absolute, { force: true });
421
+ let temporaryDirectory;
422
+ try {
423
+ temporaryDirectory = await createScreenshotTemporaryDirectory(absolute);
424
+ const captured = path.join(temporaryDirectory, 'captured.png');
425
+ await createPrivateScreenshotFile(captured);
426
+ let result;
427
+ try {
428
+ result = await runScreenshotProcess('xcrun', ['simctl', 'io', String(target), 'screenshot', captured], false);
429
+ } catch (error) {
430
+ throw new Error(
431
+ `simctl screenshot could not start for ${target}: ${error instanceof Error ? error.message : String(error)}\n` +
432
+ ' Next: install Xcode Command Line Tools (`xcode-select --install`), then rerun the same mm-harness command.',
433
+ );
434
+ }
435
+ if (result.exitCode !== 0) {
436
+ throw new Error(
437
+ `simctl screenshot failed for ${target}: ${result.stderr || result.stdout}\n` +
438
+ ` Next: mm-harness doctor --adapter mobile --device ${target} --json`,
439
+ );
440
+ }
441
+ const png = await readPrivateScreenshotFile(captured);
442
+ const invalid = pngStructureError(png);
443
+ if (invalid) {
444
+ throw new Error(
445
+ `simctl screenshot produced invalid PNG bytes for ${target}: ${invalid}\n` +
446
+ ` Next: mm-harness doctor --adapter mobile --device ${target} --json`,
447
+ );
448
+ }
449
+ await publishPrivateScreenshot(png, temporaryDirectory, absolute);
450
+ await rm(temporaryDirectory, { recursive: true, force: true });
451
+ } catch (error) {
452
+ await removeScreenshotResidue(temporaryDirectory, absolute);
453
+ if (String(error?.message ?? error).includes('\n Next:')) throw error;
454
+ throw new Error(
455
+ `simctl screenshot could not publish for ${target}: ${error instanceof Error ? error.message : String(error)}\n` +
456
+ ` Next: mm-harness doctor --adapter mobile --device ${target} --json`,
457
+ );
427
458
  }
428
459
  return {
429
460
  path: relative,
@@ -431,35 +462,277 @@ export async function simulatorScreenshot(input, relPath) {
431
462
  nodeId: input.context.nodeId,
432
463
  label: input.node?.description || `${input.action} screenshot`,
433
464
  category: 'evidence',
465
+ mimeType: 'image/png',
466
+ metadata: {
467
+ provider: 'simctl',
468
+ mode: 'xcrun simctl io screenshot',
469
+ selectedDevice: String(target),
470
+ },
434
471
  };
435
472
  }
436
473
 
437
- async function androidScreenshot(input, relPath, androidDevice) {
474
+ async function androidScreenshot(input, relPath) {
438
475
  const { relative, absolute } = resolveArtifactPath(input.context.artifactsDir, relPath || `screenshots/${input.context.nodeId}.png`);
439
476
  await mkdir(path.dirname(absolute), { recursive: true });
440
- const args = [];
441
- if (androidDevice) args.push('-s', String(androidDevice));
442
- args.push('exec-out', 'screencap', '-p');
443
- const result = await new Promise((resolve, reject) => {
444
- const child = spawn('adb', args, { stdio: ['ignore', 'pipe', 'pipe'] });
477
+ await rm(absolute, { force: true });
478
+ let temporaryDirectory;
479
+ try {
480
+ temporaryDirectory = await createScreenshotTemporaryDirectory(absolute);
481
+ const adbSerial = await resolveAndroidScreenshotSerial(input);
482
+ let result;
483
+ try {
484
+ result = await runScreenshotProcess('adb', ['-s', adbSerial, 'exec-out', 'screencap', '-p'], true);
485
+ } catch (error) {
486
+ throw new Error(
487
+ `adb screenshot could not start for ${adbSerial}: ${error instanceof Error ? error.message : String(error)}\n` +
488
+ ' Next: install Android SDK Platform-Tools, ensure adb is on PATH, then rerun the same mm-harness command.',
489
+ );
490
+ }
491
+ if (result.exitCode !== 0) {
492
+ throw new Error(
493
+ `adb screenshot failed for ${adbSerial}: ${result.stderr}\n` +
494
+ ` Next: mm-harness doctor --adapter mobile --device ${adbSerial} --json`,
495
+ );
496
+ }
497
+ const invalid = pngStructureError(result.stdout);
498
+ if (invalid) {
499
+ throw new Error(
500
+ `adb screenshot produced invalid PNG bytes for ${adbSerial}: ${invalid}\n` +
501
+ ` Next: mm-harness doctor --adapter mobile --device ${adbSerial} --json`,
502
+ );
503
+ }
504
+ await publishPrivateScreenshot(result.stdout, temporaryDirectory, absolute);
505
+ await rm(temporaryDirectory, { recursive: true, force: true });
506
+ return {
507
+ path: relative,
508
+ type: 'screenshot',
509
+ nodeId: input.context.nodeId,
510
+ label: input.node?.description || `${input.action} screenshot`,
511
+ category: 'evidence',
512
+ mimeType: 'image/png',
513
+ metadata: {
514
+ provider: 'adb',
515
+ mode: 'adb exec-out screencap -p',
516
+ selectedDevice: adbSerial,
517
+ },
518
+ };
519
+ } catch (error) {
520
+ await removeScreenshotResidue(temporaryDirectory, absolute);
521
+ if (String(error?.message ?? error).includes('\n Next:')) throw error;
522
+ throw new Error(
523
+ `adb screenshot could not publish: ${error instanceof Error ? error.message : String(error)}\n` +
524
+ ' Next: mm-harness doctor --adapter mobile --json',
525
+ );
526
+ }
527
+ }
528
+
529
+ async function resolveAndroidScreenshotSerial(input) {
530
+ const contextEnv = input.context?.env || {};
531
+ const explicit = input.node?.adb_serial ??
532
+ input.node?.android_device ??
533
+ input.node?.device ??
534
+ contextEnv.ADB_SERIAL ??
535
+ contextEnv.ANDROID_SERIAL ??
536
+ contextEnv.ANDROID_DEVICE ??
537
+ process.env.ADB_SERIAL ??
538
+ process.env.ANDROID_SERIAL ??
539
+ process.env.ANDROID_DEVICE;
540
+ if (explicit !== undefined && explicit !== null && String(explicit).trim()) return String(explicit).trim();
541
+
542
+ let result;
543
+ try {
544
+ result = await runScreenshotProcess('adb', ['devices'], false);
545
+ } catch (error) {
546
+ throw new Error(
547
+ `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>.',
549
+ );
550
+ }
551
+ if (result.exitCode !== 0) {
552
+ throw new Error(
553
+ `Android screenshot could not resolve an adb serial: ${result.stderr || result.stdout}\n` +
554
+ ' Next: mm-harness doctor --adapter mobile --json',
555
+ );
556
+ }
557
+ const serials = result.stdout
558
+ .split(/\r?\n/u)
559
+ .map((line) => line.trim().split(/\s+/u))
560
+ .filter((fields) => fields.length >= 2 && fields[1] === 'device')
561
+ .map(([serial]) => serial);
562
+ if (serials.length === 1) return serials[0];
563
+ throw new Error(
564
+ `Android screenshot requires one exact adb serial, but ${serials.length} ready devices were discovered.\n` +
565
+ ' Next: mm-harness doctor --adapter mobile --json # choose devices[].id and rerun with --device <id>',
566
+ );
567
+ }
568
+
569
+ function runScreenshotProcess(command, args, binaryOutput) {
570
+ return new Promise((resolve, reject) => {
571
+ const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
445
572
  const stdout = [];
446
573
  let stderr = '';
447
574
  child.stdout.on('data', (chunk) => { stdout.push(Buffer.from(chunk)); });
448
575
  child.stderr.on('data', (chunk) => { stderr += chunk; });
449
576
  child.on('error', reject);
450
- child.on('close', (exitCode) => resolve({ exitCode, stdout: Buffer.concat(stdout), stderr }));
577
+ child.on('close', (exitCode) => resolve({
578
+ exitCode,
579
+ stdout: binaryOutput ? Buffer.concat(stdout) : Buffer.concat(stdout).toString('utf8'),
580
+ stderr,
581
+ }));
451
582
  });
452
- if (result.exitCode !== 0) {
453
- throw new Error(`adb screenshot failed${androidDevice ? ` for ${androidDevice}` : ''}: ${result.stderr}`);
583
+ }
584
+
585
+ function pngStructureError(bytes) {
586
+ const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
587
+ if (!Buffer.isBuffer(bytes) || bytes.length < signature.length || !bytes.subarray(0, signature.length).equals(signature)) {
588
+ return `bad signature (${Buffer.isBuffer(bytes) ? bytes.length : 0} bytes)`;
454
589
  }
455
- await writeFile(absolute, result.stdout);
456
- return {
457
- path: relative,
458
- type: 'screenshot',
459
- nodeId: input.context.nodeId,
460
- label: input.node?.description || `${input.action} screenshot`,
461
- category: 'evidence',
590
+ let offset = signature.length;
591
+ let sawHeader = false;
592
+ let sawPalette = false;
593
+ let sawImageData = false;
594
+ let imageDataClosed = false;
595
+ let colorType;
596
+ let bitDepth;
597
+ while (offset < bytes.length) {
598
+ if (bytes.length - offset < 12) return `truncated chunk at byte ${offset}`;
599
+ const length = bytes.readUInt32BE(offset);
600
+ const typeBytes = bytes.subarray(offset + 4, offset + 8);
601
+ const type = typeBytes.toString('ascii');
602
+ const end = offset + 12 + length;
603
+ if (end > bytes.length) return `truncated ${type || 'unknown'} chunk at byte ${offset}`;
604
+ if (![...typeBytes].every((byte) => (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122))) {
605
+ return `invalid chunk type at byte ${offset}`;
606
+ }
607
+ const expectedCrc = bytes.readUInt32BE(offset + 8 + length);
608
+ const actualCrc = pngCrc32(bytes, offset + 4, offset + 8 + length);
609
+ if (actualCrc !== expectedCrc) return `CRC mismatch for ${type} chunk at byte ${offset}`;
610
+ if (!sawHeader && (type !== 'IHDR' || length !== 13)) return `first chunk must be IHDR length 13 (got ${type} length ${length})`;
611
+ if (type === 'IHDR') {
612
+ if (sawHeader) return 'duplicate IHDR chunk';
613
+ const invalidHeader = pngHeaderError(bytes.subarray(offset + 8, offset + 8 + length));
614
+ if (invalidHeader) return invalidHeader;
615
+ sawHeader = true;
616
+ bitDepth = bytes[offset + 16];
617
+ colorType = bytes[offset + 17];
618
+ } else if (type === 'PLTE') {
619
+ if (sawPalette) return 'duplicate PLTE chunk';
620
+ if (sawImageData) return 'PLTE chunk must precede IDAT';
621
+ if (colorType === 0 || colorType === 4) return `PLTE chunk is forbidden for color type ${colorType}`;
622
+ if (length === 0 || length % 3 !== 0 || length > 768) return `invalid PLTE length ${length}`;
623
+ if (colorType === 3 && length / 3 > 2 ** bitDepth) return `PLTE has too many entries for bit depth ${bitDepth}`;
624
+ sawPalette = true;
625
+ } else if (type === 'IDAT') {
626
+ if (imageDataClosed) return 'IDAT chunks must be consecutive';
627
+ if (colorType === 3 && !sawPalette) return 'indexed-color PNG requires PLTE before IDAT';
628
+ sawImageData = true;
629
+ } else {
630
+ if (sawImageData) imageDataClosed = true;
631
+ if (type[0] === type[0].toUpperCase() && type !== 'IEND') return `unknown critical chunk ${type}`;
632
+ }
633
+ if (type === 'IEND') {
634
+ if (length !== 0) return `IEND length must be 0 (got ${length})`;
635
+ if (!sawImageData) return 'missing IDAT chunk';
636
+ if (end !== bytes.length) return `trailing bytes after IEND (${bytes.length - end})`;
637
+ return null;
638
+ }
639
+ offset = end;
640
+ }
641
+ return sawHeader ? 'missing IEND chunk' : 'missing IHDR chunk';
642
+ }
643
+
644
+ function pngHeaderError(header) {
645
+ const width = header.readUInt32BE(0);
646
+ const height = header.readUInt32BE(4);
647
+ const bitDepth = header[8];
648
+ const colorType = header[9];
649
+ const allowedDepths = {
650
+ 0: [1, 2, 4, 8, 16],
651
+ 2: [8, 16],
652
+ 3: [1, 2, 4, 8],
653
+ 4: [8, 16],
654
+ 6: [8, 16],
462
655
  };
656
+ if (width === 0 || width > 0x7fffffff || height === 0 || height > 0x7fffffff) {
657
+ return `invalid IHDR dimensions ${width}x${height}`;
658
+ }
659
+ if (!allowedDepths[colorType]) return `invalid IHDR color type ${colorType}`;
660
+ if (!allowedDepths[colorType].includes(bitDepth)) return `invalid IHDR bit depth ${bitDepth} for color type ${colorType}`;
661
+ if (header[10] !== 0) return `invalid IHDR compression method ${header[10]}`;
662
+ if (header[11] !== 0) return `invalid IHDR filter method ${header[11]}`;
663
+ if (header[12] !== 0 && header[12] !== 1) return `invalid IHDR interlace method ${header[12]}`;
664
+ return null;
665
+ }
666
+
667
+ const PNG_CRC_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
668
+ let crc = value;
669
+ for (let bit = 0; bit < 8; bit += 1) crc = (crc & 1) === 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
670
+ return crc >>> 0;
671
+ });
672
+
673
+ function pngCrc32(bytes, start, end) {
674
+ let crc = 0xffffffff;
675
+ for (let index = start; index < end; index += 1) crc = PNG_CRC_TABLE[(crc ^ bytes[index]) & 0xff] ^ (crc >>> 8);
676
+ return (crc ^ 0xffffffff) >>> 0;
677
+ }
678
+
679
+ async function createScreenshotTemporaryDirectory(absolute) {
680
+ const temporary = path.join(path.dirname(absolute), `.${path.basename(absolute)}.${process.pid}.${randomUUID()}.tmp`);
681
+ await mkdir(temporary, { mode: 0o700 });
682
+ return temporary;
683
+ }
684
+
685
+ async function createPrivateScreenshotFile(file) {
686
+ const handle = await open(
687
+ file,
688
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
689
+ 0o600,
690
+ );
691
+ try {
692
+ const stats = await handle.stat();
693
+ if (!stats.isFile() || stats.nlink !== 1) throw new Error('temporary screenshot is not a private regular file');
694
+ } finally {
695
+ await handle.close();
696
+ }
697
+ }
698
+
699
+ async function readPrivateScreenshotFile(file) {
700
+ let handle;
701
+ try {
702
+ handle = await open(file, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
703
+ const stats = await handle.stat();
704
+ if (!stats.isFile() || stats.nlink !== 1) throw new Error('captured screenshot is not a private regular file');
705
+ return await handle.readFile();
706
+ } catch (error) {
707
+ throw new Error(`captured screenshot is not a safe regular file: ${error instanceof Error ? error.message : String(error)}`);
708
+ } finally {
709
+ await handle?.close();
710
+ }
711
+ }
712
+
713
+ async function publishPrivateScreenshot(png, temporaryDirectory, absolute) {
714
+ const publication = path.join(temporaryDirectory, 'validated.png');
715
+ const handle = await open(
716
+ publication,
717
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
718
+ 0o600,
719
+ );
720
+ try {
721
+ const stats = await handle.stat();
722
+ if (!stats.isFile() || stats.nlink !== 1) throw new Error('validated screenshot is not a private regular file');
723
+ await handle.writeFile(png);
724
+ await handle.sync();
725
+ await rename(publication, absolute);
726
+ } finally {
727
+ await handle.close();
728
+ }
729
+ }
730
+
731
+ async function removeScreenshotResidue(temporaryDirectory, absolute) {
732
+ await Promise.all([
733
+ temporaryDirectory ? rm(temporaryDirectory, { recursive: true, force: true }) : Promise.resolve(),
734
+ rm(absolute, { force: true }),
735
+ ]);
463
736
  }
464
737
 
465
738
  function resolveArtifactPath(artifactsDir, relativePath) {
@@ -1,4 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import { pathToFileURL } from 'node:url';
2
3
  import { bridgeCommand, runAdapter, selectBridgeStatusEntry } from '../platform/bridge.mjs';
3
4
  import { walletFixturePath } from '../../harness-exports.mjs';
4
5
 
@@ -108,7 +109,7 @@ async function waitForStableUnlocked(input, initialStatus, stableMs = 750) {
108
109
  return last;
109
110
  }
110
111
 
111
- runAdapter(async (input) => {
112
+ export async function ensureUnlocked(input) {
112
113
  const before = await waitForTargetStatus(input, Number(input.node?.target_timeout_ms ?? 20000));
113
114
  if (!selectedAccount(before, input) && routeName(before, input) !== 'Login') {
114
115
  throw new Error(
@@ -153,4 +154,8 @@ runAdapter(async (input) => {
153
154
  proofPath: 'agentic-wallet-status-after-missing-login-input',
154
155
  };
155
156
  }
156
- });
157
+ }
158
+
159
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
160
+ runAdapter(ensureUnlocked);
161
+ }
@@ -118,7 +118,7 @@
118
118
  },
119
119
  "hash": {
120
120
  "type": "string",
121
- "description": "Raw extension hash, e.g. #/?tab=perps."
121
+ "description": "Raw extension hash, e.g. #/perps-home."
122
122
  },
123
123
  "path": {
124
124
  "type": "string",
@@ -148,7 +148,7 @@
148
148
  "description": "Fallback to a raw extension hash when no alias exists",
149
149
  "node": {
150
150
  "action": "ui.navigate",
151
- "hash": "#/?tab=perps",
151
+ "hash": "#/perps-home",
152
152
  "intent": "Open a raw extension route"
153
153
  }
154
154
  }
@@ -190,6 +190,24 @@
190
190
  },
191
191
  "ui.wait_for": {
192
192
  "description": "Wait for a UI condition through the Extension CDP adapter.",
193
+ "schema": {
194
+ "type": "object",
195
+ "properties": {
196
+ "action": { "const": "ui.wait_for" },
197
+ "selector": { "type": "string" },
198
+ "text": { "type": "string" },
199
+ "expected": {
200
+ "type": "string",
201
+ "enum": ["present", "absent", "visible", "hidden", "not_present"]
202
+ },
203
+ "visible": {
204
+ "type": "boolean",
205
+ "description": "Convenience alias: true means expected=visible; false means expected=absent."
206
+ },
207
+ "timeout_ms": { "type": "number" }
208
+ },
209
+ "required": ["action"]
210
+ },
193
211
  "examples": [
194
212
  {
195
213
  "node": {
@@ -197,11 +215,20 @@
197
215
  "text": "Perps",
198
216
  "intent": "Wait for visible Extension text"
199
217
  }
218
+ },
219
+ {
220
+ "node": {
221
+ "action": "ui.wait_for",
222
+ "selector": "[data-testid=example]",
223
+ "visible": false,
224
+ "timeout_ms": 2000,
225
+ "intent": "Prove an Extension element is absent"
226
+ }
200
227
  }
201
228
  ]
202
229
  },
203
230
  "ui.screenshot": {
204
- "description": "Capture visual evidence through capture-helper snapshot; the runner registers the screenshot artifact automatically. CDP/DOM-raster fallback is not valid Extension evidence.",
231
+ "description": "Capture registered visual evidence through capture-helper, bounded Chrome-native capture, or an explicitly labeled computed-style DOM raster fallback. Always read the resulting PNG; artifact metadata records the provider.",
205
232
  "examples": [
206
233
  {
207
234
  "node": {
@@ -1717,15 +1744,8 @@
1717
1744
  }
1718
1745
  },
1719
1746
  "page": {
1720
- "oneOf": [
1721
- {
1722
- "type": "string"
1723
- },
1724
- {
1725
- "const": false
1726
- }
1727
- ],
1728
- "description": "Final page target; false skips navigation for teardown."
1747
+ "type": "string",
1748
+ "description": "Initial Perps page target; omit it to use the profile or market default. Extension start_state always enters Perps before convergence."
1729
1749
  },
1730
1750
  "positions": {
1731
1751
  "oneOf": [
@@ -191,6 +191,20 @@
191
191
  },
192
192
  "ui.wait_for": {
193
193
  "description": "Wait for a mounted React Native testID/test_id. Optional text validates text returned by that testID; text alone is not supported on Mobile.",
194
+ "schema": {
195
+ "type": "object",
196
+ "properties": {
197
+ "action": { "const": "ui.wait_for" },
198
+ "test_id": { "type": "string" },
199
+ "text": { "type": "string" },
200
+ "expected": {
201
+ "type": "string",
202
+ "enum": ["present", "absent", "hidden", "not_present"]
203
+ },
204
+ "timeout_ms": { "type": "number" }
205
+ },
206
+ "required": ["action", "test_id"]
207
+ },
194
208
  "examples": [
195
209
  {
196
210
  "node": {
@@ -199,11 +213,19 @@
199
213
  "expected": "present",
200
214
  "intent": "Wait for a React Native testID to be mounted"
201
215
  }
216
+ },
217
+ {
218
+ "node": {
219
+ "action": "ui.wait_for",
220
+ "test_id": "conditional-banner",
221
+ "expected": "absent",
222
+ "intent": "Prove a React Native testID is not mounted"
223
+ }
202
224
  }
203
225
  ]
204
226
  },
205
227
  "ui.screenshot": {
206
- "description": "Capture visual evidence through the platform adapter; the runner registers the screenshot artifact automatically.",
228
+ "description": "Capture registered PNG evidence with xcrun simctl on iOS or adb screencap on Android. Artifact metadata records the native provider, command mode, and selected device; invalid or empty PNG output fails before registration.",
207
229
  "examples": [
208
230
  {
209
231
  "node": {
@@ -676,7 +698,7 @@
676
698
  ]
677
699
  },
678
700
  "metamask.perps.start_state": {
679
- "description": "Default to testnet for Perps mutations; mainnet is read-only unless explicitly requested. Recommended configurable Perps start state that composes reusable domain operations before the proof window.",
701
+ "description": "Default to testnet for Perps mutations; mainnet is read-only unless explicitly requested. Recommended configurable Perps start state that first restores the fixture-backed unlocked wallet, then composes reusable domain operations before the proof window.",
680
702
  "examples": [
681
703
  {
682
704
  "description": "Clean BTC testnet market state",
@@ -1689,7 +1711,7 @@
1689
1711
  {
1690
1712
  "name": "metamask.perps.start_state",
1691
1713
  "owner": "metamask",
1692
- "description": "mobile Default to testnet for Perps mutations; mainnet is read-only unless explicitly requested. Recommended configurable Perps start state that composes reusable domain operations before the proof window.",
1714
+ "description": "mobile Default to testnet for Perps mutations; mainnet is read-only unless explicitly requested. Recommended configurable Perps start state that first restores the fixture-backed unlocked wallet, then composes reusable domain operations before the proof window.",
1693
1715
  "schema": {
1694
1716
  "type": "object",
1695
1717
  "properties": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.14.7",
3
+ "version": "0.15.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -16,9 +16,9 @@
16
16
  "check:syntax": "find . -name '*.mjs' -print0 | xargs -0 -n1 node --check"
17
17
  },
18
18
  "dependencies": {
19
- "@farmslot/agent-runtime": "^0.1.1",
20
- "@farmslot/protocol": "^0.7.6",
21
- "@farmslot/recipe-harness": "^0.4.3",
19
+ "@farmslot/agent-runtime": "^0.2.0",
20
+ "@farmslot/protocol": "^0.8.0",
21
+ "@farmslot/recipe-harness": "^0.5.0",
22
22
  "commander": "^12.0.0",
23
23
  "viem": "^2.54.3",
24
24
  "ws": "8.21.0"
@@ -70,4 +70,4 @@
70
70
  "url": "https://github.com/MetaMask/experimental-metamask-harness/issues"
71
71
  },
72
72
  "homepage": "https://github.com/MetaMask/experimental-metamask-harness#readme"
73
- }
73
+ }
@@ -29,7 +29,7 @@ _mmh_bin() {
29
29
  return 1
30
30
  }
31
31
 
32
- _mmh_commands="launch stop logs debug fixtures actions call flows run doctor install verify cleanup completions"
32
+ _mmh_commands="launch stop logs debug fixtures actions call flows run doctor check checklist recipe-quality install verify cleanup completions"
33
33
 
34
34
  # Per-command flags (static, from the mm-harness surface).
35
35
  _mmh_flags_for() {
@@ -38,7 +38,8 @@ _mmh_flags_for() {
38
38
  stop) printf '%s' "--port --target --json" ;;
39
39
  logs) printf '%s' "--full --events --source --adapter --target --json" ;;
40
40
  debug) printf '%s' "--worker --dev-menu --adapter --target --json" ;;
41
- fixtures) printf '%s' "--fixture --adapter --target --json" ;;
41
+ fixtures) printf '%s' "--from --dev --force --fixture --adapter --target --json" ;;
42
+ checklist) printf '%s' "" ;;
42
43
  actions) printf '%s' "--adapter --action --kind --raw --action-manifest --target --json" ;;
43
44
  call) printf '%s' "--arg --adapter --target --artifacts-dir --action-manifest --heal --json" ;;
44
45
  flows) printf '%s' "--library --json" ;;
@@ -76,7 +77,8 @@ if [ -n "${BASH_VERSION:-}" ]; then
76
77
 
77
78
  case "$cmd" in
78
79
  launch) [ "$cword" -eq 2 ] && COMPREPLY=($(compgen -W "ios android" -- "$cur")) ;;
79
- fixtures) [ "$cword" -eq 2 ] && COMPREPLY=($(compgen -W "sync set" -- "$cur")) ;;
80
+ fixtures) [ "$cword" -eq 2 ] && COMPREPLY=($(compgen -W "init sync set generate finalize" -- "$cur")) ;;
81
+ checklist) [ "$cword" -eq 2 ] && COMPREPLY=($(compgen -W "mark" -- "$cur")) ;;
80
82
  completions) [ "$cword" -eq 2 ] && COMPREPLY=($(compgen -W "install bash zsh status" -- "$cur")) ;;
81
83
  flows) [ "$cword" -eq 2 ] && COMPREPLY=($(compgen -W "list promote" -- "$cur")) ;;
82
84
  call) [ "$cword" -eq 2 ] && COMPREPLY=($(compgen -W "$(_mmh_actions)" -- "$cur")) ;;
@@ -110,7 +112,8 @@ if [ -n "${ZSH_VERSION:-}" ]; then
110
112
 
111
113
  case "$cmd" in
112
114
  launch) (( CURRENT == 3 )) && { candidates=(ios android); compadd -a candidates; } ;;
113
- fixtures) (( CURRENT == 3 )) && { candidates=(sync set); compadd -a candidates; } ;;
115
+ fixtures) (( CURRENT == 3 )) && { candidates=(init sync set generate finalize); compadd -a candidates; } ;;
116
+ checklist) (( CURRENT == 3 )) && { candidates=(mark); compadd -a candidates; } ;;
114
117
  completions) (( CURRENT == 3 )) && { candidates=(install bash zsh status); compadd -a candidates; } ;;
115
118
  flows) (( CURRENT == 3 )) && { candidates=(list promote); compadd -a candidates; } ;;
116
119
  call) (( CURRENT == 3 )) && { candidates=("${(@f)$(_mmh_actions)}"); compadd -a candidates; } ;;
File without changes