@deeeed/metamask-harness 0.14.7 → 0.15.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.
- package/CHANGELOG.md +81 -6
- package/adapters/core/cleanup.sh +0 -0
- package/adapters/core/inject.sh +0 -0
- package/adapters/extension/cleanup.mjs +0 -0
- package/adapters/extension/ensure-browser.sh +4 -2
- package/adapters/extension/inject.mjs +0 -0
- package/adapters/extension/launch-browser.cjs +73 -34
- package/adapters/extension/launch.sh +0 -0
- package/adapters/extension/live.sh +21 -10
- package/adapters/extension/readiness.mjs +0 -0
- package/adapters/extension/reattach.sh +3 -3
- package/adapters/extension/refresh-build.sh +0 -0
- package/adapters/extension/seed-fixture.sh +0 -0
- package/adapters/extension/sidepanel-toggle.sh +10 -4
- package/adapters/extension/snapshot-dist.sh +0 -0
- package/adapters/extension/start-watch.sh +2 -2
- package/adapters/extension/stop-viewers.sh +0 -0
- package/adapters/extension/verify.sh +19 -2
- package/adapters/extension/wallet-fixture-state.cjs +14 -1
- package/adapters/manifest.json +19 -3
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +0 -0
- package/adapters/mobile/bridge-runtime/setup-wallet.sh +6 -4
- package/adapters/mobile/cleanup.sh +0 -0
- package/adapters/mobile/inject.sh +0 -0
- package/adapters/mobile/launch-metro.cjs +74 -0
- package/adapters/mobile/lib/metro-listener.sh +0 -0
- package/adapters/mobile/lib/tmux-viewer.sh +4 -4
- package/adapters/mobile/open-device.sh +3 -1
- package/adapters/mobile/prewarm-bundle.sh +0 -0
- package/adapters/mobile/start-metro.sh +12 -18
- package/adapters/mobile/stop-metro.sh +1 -0
- package/adapters/mobile/verify.sh +5 -5
- package/adapters/mobile/wait-for-bridge.sh +0 -0
- package/adapters/mobile/yarn-setup.sh +0 -0
- package/adapters/shared/activate-repo-node.sh +0 -0
- package/adapters/shared/activate-repo-ruby.sh +0 -0
- package/adapters/shared/cli-ux.sh +0 -0
- package/adapters/shared/ensure-runner-deps.sh +0 -0
- package/adapters/shared/harness-path.sh +0 -0
- package/adapters/shared/hash-helpers.sh +0 -0
- package/adapters/shared/install-repo-deps.sh +29 -0
- package/adapters/shared/json-field.sh +0 -0
- package/adapters/shared/open-debug.mjs +35 -4
- package/adapters/shared/open-log-window.sh +1 -1
- package/adapters/shared/reap-checkout-metros.sh +0 -0
- package/adapters/shared/resolve-farmslot-ports.mjs +0 -0
- package/adapters/shared/resolve-farmslot-ports.sh +0 -0
- package/adapters/shared/resolve-slot-ports-core.mjs +72 -1
- package/adapters/shared/resolve-slot-ports.mjs +0 -0
- package/adapters/shared/resolve-slot-ports.sh +21 -17
- package/adapters/shared/sync-wallet-fixture.sh +0 -0
- package/adapters/shared/tmux-session.sh +0 -5
- package/adapters/shared/tmux-viewer.sh +7 -2
- package/dist/adapters/core/surface.js +3 -0
- package/dist/adapters/extension/ensure-ready.js +0 -7
- package/dist/adapters/extension/runtime-decision.js +93 -3
- package/dist/adapters/extension/surface.js +17 -6
- package/dist/adapters/mobile/metro-env.js +67 -0
- package/dist/adapters/mobile/perps-env.js +101 -0
- package/dist/adapters/mobile/prepare.js +32 -14
- package/dist/adapters/mobile/runtime-decision.js +21 -2
- package/dist/adapters/mobile/source-freshness.js +116 -0
- package/dist/adapters/mobile/surface.js +3 -0
- package/dist/adapters/resolve-slot-ports.js +4 -0
- package/dist/adapters/slot-ports.js +131 -49
- package/dist/adapters.js +7 -2
- package/dist/checkout-lock.js +72 -0
- package/dist/cli.js +2 -0
- package/dist/commands/call.js +91 -54
- package/dist/commands/check.js +266 -46
- package/dist/commands/checklist.js +136 -0
- package/dist/commands/debug.js +21 -2
- package/dist/commands/doctor.js +187 -12
- package/dist/commands/fixtures.js +177 -42
- package/dist/commands/launch/extension.js +6 -1
- package/dist/commands/launch/index.js +54 -22
- package/dist/commands/logs.js +24 -4
- package/dist/commands/run-engine.js +223 -7
- package/dist/commands/run.js +67 -50
- package/dist/commands/shared.js +86 -5
- package/dist/commands/stop.js +1 -2
- package/dist/doctor.js +39 -17
- package/dist/harness.js +5 -2
- package/dist/heal-bounds.js +1 -1
- package/dist/mm-harness-cli.js +37 -9
- package/dist/runner.js +35 -5
- package/dist/runtime-context.js +228 -0
- package/docs/CLI-SPEC.md +15 -11
- package/docs/MENTAL-MODEL.md +6 -6
- package/library/actions/extension/perps/perps.mjs +29 -8
- package/library/actions/extension/platform/cdp.mjs +128 -28
- package/library/actions/extension/ui/navigate.mjs +1 -1
- package/library/actions/mobile/perps/perps.mjs +13 -1
- package/library/actions/mobile/platform/bridge.mjs +302 -29
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +7 -2
- package/library/manifests/extension.action-manifest.json +32 -12
- package/library/manifests/mobile.action-manifest.json +25 -3
- package/package.json +5 -5
- package/scripts/completions.sh +7 -4
- 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 {
|
|
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
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const
|
|
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({
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.15.0",
|
|
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.
|
|
20
|
-
"@farmslot/protocol": "^0.
|
|
21
|
-
"@farmslot/recipe-harness": "^0.
|
|
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
|
+
}
|
package/scripts/completions.sh
CHANGED
|
@@ -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
|