@deeeed/metamask-harness 0.35.0 → 0.37.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 +39 -0
- package/README.md +9 -1
- package/adapters/extension/artifact-runtime-state.cjs +128 -0
- package/adapters/extension/check-infura-readiness.cjs +102 -0
- package/adapters/extension/inject.mjs +2 -0
- package/adapters/extension/launch-browser.cjs +22 -0
- package/adapters/extension/live.sh +103 -18
- package/adapters/extension/readiness.mjs +77 -36
- package/adapters/extension/snapshot-dist.sh +88 -3
- package/adapters/extension/start-watch.sh +15 -0
- package/adapters/extension/verify.sh +6 -2
- package/adapters/extension/wallet-fixture-state.cjs +3 -1
- package/adapters/manifest.json +25 -1
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +73 -42
- package/adapters/mobile/reset-app-data.sh +154 -0
- package/adapters/shared/log-tui.mjs +1 -1
- package/dist/adapters/extension/artifact-integrity.js +38 -0
- package/dist/adapters/extension/extension-id.js +23 -4
- package/dist/adapters/extension/product-config.js +29 -1
- package/dist/adapters/extension/release-artifact.js +386 -0
- package/dist/adapters/extension/runtime-decision.js +161 -20
- package/dist/adapters/extension/runtime.js +127 -0
- package/dist/adapters/mobile/release-artifact-state.js +124 -0
- package/dist/adapters/mobile/release-artifact.js +295 -0
- package/dist/adapters.js +29 -4
- package/dist/cli-commands.js +1 -1
- package/dist/cli.js +2 -2
- package/dist/command-contract.js +17 -1
- package/dist/commands/call.js +2 -1
- package/dist/commands/device-target.js +5 -0
- package/dist/commands/fixtures.js +106 -31
- package/dist/commands/launch/extension.js +92 -7
- package/dist/commands/launch/index.js +13 -0
- package/dist/commands/launch/mobile.js +2 -0
- package/dist/commands/provision.js +2 -0
- package/dist/commands/run-engine.js +89 -5
- package/dist/commands/run.js +3 -1
- package/dist/commands/runtime-launch.js +178 -10
- package/dist/heal-bounds.js +1 -1
- package/dist/live-adapter-contract.js +3 -1
- package/dist/metamask-action-validation.js +47 -1
- package/dist/mm-harness-cli.js +37 -4
- package/dist/recipe-security.js +3 -0
- package/dist/run-diagnostics.js +1 -1
- package/docs/RECIPES.md +29 -0
- package/docs/RELEASE-QA-CAPABILITY-MAP.md +150 -0
- package/library/actions/extension/perps/perps.mjs +2 -0
- package/library/actions/extension/perps/read_snapshot.mjs +470 -0
- package/library/actions/extension/platform/cdp.mjs +6 -3
- package/library/actions/extension/wallet/import.mjs +201 -0
- package/library/actions/extension/wallet/reset.mjs +98 -0
- package/library/actions/extension/wallet/secret-input.mjs +98 -0
- package/library/actions/extension/wallet/state.mjs +1 -0
- package/library/actions/mobile/analytics/consent-settings.mjs +112 -0
- package/library/actions/mobile/analytics/set_consent.mjs +4 -112
- package/library/actions/mobile/platform/bridge.mjs +8 -0
- package/library/actions/mobile/platform/observe-ui.mjs +84 -2
- package/library/actions/mobile/ui/native-navigation.mjs +225 -0
- package/library/actions/mobile/ui/navigate.mjs +7 -0
- package/library/actions/mobile/wallet/import.mjs +328 -0
- package/library/actions/mobile/wallet/native-ui.mjs +493 -0
- package/library/actions/mobile/wallet/read_state.mjs +16 -0
- package/library/actions/mobile/wallet/reset-helper.mjs +99 -0
- package/library/actions/mobile/wallet/reset.mjs +20 -0
- package/library/actions/shared/ui/locators.mjs +7 -0
- package/library/actions/shared/wallet/import-source.mjs +101 -0
- package/library/manifests/extension.action-manifest.json +228 -0
- package/library/manifests/mobile.action-manifest.json +124 -0
- package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
- package/library/recipes/wallet/import.recipe.json +102 -0
- package/library/recipes/wallet/reset-import.recipe.json +107 -0
- package/package.json +1 -1
- package/scripts/completions.sh +2 -2
|
@@ -147,6 +147,12 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
|
|
|
147
147
|
};
|
|
148
148
|
}
|
|
149
149
|
const runtime = await evaluateHealth(session, cdpCallTimeoutMs);
|
|
150
|
+
if (runtime.hasSubmitRequest !== true) {
|
|
151
|
+
const providerProbe = await probeUiEthereumProvider(session, cdpCallTimeoutMs);
|
|
152
|
+
runtime.evmRpcProbeOk = providerProbe.ok;
|
|
153
|
+
runtime.evmRpcProbeError = providerProbe.error;
|
|
154
|
+
runtime.evmRpcProbeSource = providerProbe.source;
|
|
155
|
+
}
|
|
150
156
|
if (runtime.href && !String(runtime.href).startsWith("chrome-extension://")) {
|
|
151
157
|
findings.push(`Extension page href is not an extension URL: ${runtime.href}`);
|
|
152
158
|
}
|
|
@@ -316,6 +322,127 @@ async function evaluateHealth(session, timeoutMs) {
|
|
|
316
322
|
}
|
|
317
323
|
return result.result?.value ?? {};
|
|
318
324
|
}
|
|
325
|
+
async function probeUiEthereumProvider(session, timeoutMs) {
|
|
326
|
+
const bridgeKey = "mm-harness.runtime.evm-provider-probe";
|
|
327
|
+
const preload = await boundedCdpCall(
|
|
328
|
+
session,
|
|
329
|
+
"Page.addScriptToEvaluateOnNewDocument",
|
|
330
|
+
{
|
|
331
|
+
source: `(() => {
|
|
332
|
+
const bridgeKey = Symbol.for(${JSON.stringify(bridgeKey)});
|
|
333
|
+
let provider;
|
|
334
|
+
Object.defineProperty(globalThis, 'ethereumProvider', {
|
|
335
|
+
configurable: true,
|
|
336
|
+
get() { return provider; },
|
|
337
|
+
set(value) { provider = value; },
|
|
338
|
+
});
|
|
339
|
+
Object.defineProperty(globalThis, bridgeKey, {
|
|
340
|
+
configurable: true,
|
|
341
|
+
value: {
|
|
342
|
+
async probe() {
|
|
343
|
+
if (!provider || typeof provider.request !== 'function') {
|
|
344
|
+
return { ok: false, error: 'Ethereum provider is unavailable' };
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
const code = await provider.request({
|
|
348
|
+
method: 'eth_getCode',
|
|
349
|
+
params: ['0x0000000000000000000000000000000000000000', 'latest'],
|
|
350
|
+
});
|
|
351
|
+
const ok = typeof code === 'string' && /^0x[0-9a-f]*$/iu.test(code);
|
|
352
|
+
return {
|
|
353
|
+
ok,
|
|
354
|
+
error: ok ? null : 'Ethereum provider returned invalid contract code',
|
|
355
|
+
};
|
|
356
|
+
} catch (error) {
|
|
357
|
+
return { ok: false, error: String(error?.message || error) };
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
cleanup() {
|
|
361
|
+
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'ethereumProvider');
|
|
362
|
+
if (descriptor?.configurable === true) {
|
|
363
|
+
Object.defineProperty(globalThis, 'ethereumProvider', {
|
|
364
|
+
configurable: true,
|
|
365
|
+
writable: true,
|
|
366
|
+
value: provider,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
return delete globalThis[bridgeKey];
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
})();`
|
|
374
|
+
},
|
|
375
|
+
timeoutMs
|
|
376
|
+
);
|
|
377
|
+
if (typeof preload?.identifier !== "string" || preload.identifier.length === 0) {
|
|
378
|
+
return {
|
|
379
|
+
ok: false,
|
|
380
|
+
error: "Ethereum provider preload was not installed",
|
|
381
|
+
source: "ui-ethereum-provider"
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
let outcome = {
|
|
385
|
+
ok: false,
|
|
386
|
+
error: "Ethereum provider is unavailable",
|
|
387
|
+
source: "ui-ethereum-provider"
|
|
388
|
+
};
|
|
389
|
+
const cleanupErrors = [];
|
|
390
|
+
try {
|
|
391
|
+
await boundedCdpCall(session, "Page.reload", { ignoreCache: false }, timeoutMs);
|
|
392
|
+
const deadline = Date.now() + timeoutMs;
|
|
393
|
+
while (Date.now() <= deadline) {
|
|
394
|
+
try {
|
|
395
|
+
const result = await boundedCdpCall(session, "Runtime.evaluate", {
|
|
396
|
+
expression: `globalThis[Symbol.for(${JSON.stringify(bridgeKey)})]?.probe()`,
|
|
397
|
+
awaitPromise: true,
|
|
398
|
+
returnByValue: true
|
|
399
|
+
}, Math.max(1, deadline - Date.now()));
|
|
400
|
+
const value = result.result?.value;
|
|
401
|
+
if (value?.ok === true) {
|
|
402
|
+
outcome = { ok: true, error: null, source: "ui-ethereum-provider" };
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
if (typeof value?.error === "string") outcome.error = value.error;
|
|
406
|
+
} catch (error) {
|
|
407
|
+
outcome.error = messageOf(error);
|
|
408
|
+
}
|
|
409
|
+
if (outcome.ok) break;
|
|
410
|
+
await sleep(100);
|
|
411
|
+
}
|
|
412
|
+
} catch (error) {
|
|
413
|
+
outcome = { ok: false, error: messageOf(error), source: "ui-ethereum-provider" };
|
|
414
|
+
} finally {
|
|
415
|
+
try {
|
|
416
|
+
const cleanup = await boundedCdpCall(session, "Runtime.evaluate", {
|
|
417
|
+
expression: `(() => {
|
|
418
|
+
const key = Symbol.for(${JSON.stringify(bridgeKey)});
|
|
419
|
+
const bridge = globalThis[key];
|
|
420
|
+
if (!bridge) return true;
|
|
421
|
+
return bridge.cleanup() === true && !(key in globalThis);
|
|
422
|
+
})()`,
|
|
423
|
+
returnByValue: true
|
|
424
|
+
}, timeoutMs);
|
|
425
|
+
if (cleanup.result?.value !== true) cleanupErrors.push("page bridge removal was not confirmed");
|
|
426
|
+
} catch (error) {
|
|
427
|
+
cleanupErrors.push(messageOf(error));
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
await boundedCdpCall(
|
|
431
|
+
session,
|
|
432
|
+
"Page.removeScriptToEvaluateOnNewDocument",
|
|
433
|
+
{ identifier: preload.identifier },
|
|
434
|
+
timeoutMs
|
|
435
|
+
);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
cleanupErrors.push(messageOf(error));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return cleanupErrors.length > 0 ? {
|
|
441
|
+
ok: false,
|
|
442
|
+
error: `Ethereum provider probe cleanup failed: ${cleanupErrors.join("; ")}`,
|
|
443
|
+
source: "ui-ethereum-provider"
|
|
444
|
+
} : outcome;
|
|
445
|
+
}
|
|
319
446
|
function extensionBackgroundProbeTimeoutMs(cdpCallTimeoutMs) {
|
|
320
447
|
return Math.max(1, Math.floor(cdpCallTimeoutMs * 0.8));
|
|
321
448
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { recipeRuntimePath } from "../../paths.js";
|
|
5
|
+
import {
|
|
6
|
+
assertAndroidReleaseArtifactRuntime
|
|
7
|
+
} from "./release-artifact.js";
|
|
8
|
+
function mobileReleaseArtifactStatePath(target) {
|
|
9
|
+
return recipeRuntimePath(target, "mobile-release-artifact.json");
|
|
10
|
+
}
|
|
11
|
+
function writeMobileReleaseArtifactState(target, report) {
|
|
12
|
+
const state = {
|
|
13
|
+
schemaVersion: 1,
|
|
14
|
+
adapter: "mobile",
|
|
15
|
+
platform: "android",
|
|
16
|
+
packageId: report.packageId,
|
|
17
|
+
versionName: report.versionName,
|
|
18
|
+
versionCode: report.versionCode,
|
|
19
|
+
sha256: report.sha256,
|
|
20
|
+
deviceSerial: report.deviceSerial,
|
|
21
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
22
|
+
};
|
|
23
|
+
const file = mobileReleaseArtifactStatePath(target);
|
|
24
|
+
const directory = ensureSafeRuntimeDirectory(target, file);
|
|
25
|
+
const existing = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
26
|
+
if (existing && (!existing.isFile() || existing.isSymbolicLink())) throw invalidState(file);
|
|
27
|
+
const temporary = path.join(
|
|
28
|
+
directory,
|
|
29
|
+
`.mobile-release-artifact.${process.pid}.${randomBytes(8).toString("hex")}.tmp`
|
|
30
|
+
);
|
|
31
|
+
let descriptor;
|
|
32
|
+
try {
|
|
33
|
+
descriptor = fs.openSync(temporary, "wx", 384);
|
|
34
|
+
fs.writeFileSync(descriptor, `${JSON.stringify(state, null, 2)}
|
|
35
|
+
`, "utf8");
|
|
36
|
+
fs.fsyncSync(descriptor);
|
|
37
|
+
fs.closeSync(descriptor);
|
|
38
|
+
descriptor = void 0;
|
|
39
|
+
fs.renameSync(temporary, file);
|
|
40
|
+
} finally {
|
|
41
|
+
if (descriptor !== void 0) fs.closeSync(descriptor);
|
|
42
|
+
const temporaryStat = fs.lstatSync(temporary, { throwIfNoEntry: false });
|
|
43
|
+
if (temporaryStat) fs.unlinkSync(temporary);
|
|
44
|
+
}
|
|
45
|
+
return state;
|
|
46
|
+
}
|
|
47
|
+
function requireCurrentMobileReleaseArtifactState(target, verify = assertAndroidReleaseArtifactRuntime) {
|
|
48
|
+
const state = readMobileReleaseArtifactState(target);
|
|
49
|
+
if (state) verify(state);
|
|
50
|
+
return state;
|
|
51
|
+
}
|
|
52
|
+
function clearMobileReleaseArtifactState(target) {
|
|
53
|
+
const file = mobileReleaseArtifactStatePath(target);
|
|
54
|
+
const existing = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
55
|
+
if (!existing) return;
|
|
56
|
+
ensureSafeRuntimeDirectory(target, file);
|
|
57
|
+
if (!existing.isFile() || existing.isSymbolicLink()) throw invalidState(file);
|
|
58
|
+
fs.unlinkSync(file);
|
|
59
|
+
}
|
|
60
|
+
function readMobileReleaseArtifactState(target) {
|
|
61
|
+
const file = mobileReleaseArtifactStatePath(target);
|
|
62
|
+
let stat;
|
|
63
|
+
try {
|
|
64
|
+
stat = fs.lstatSync(file);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (isMissing(error)) return null;
|
|
67
|
+
throw invalidState(file);
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 16 * 1024) {
|
|
71
|
+
throw invalidState(file);
|
|
72
|
+
}
|
|
73
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
74
|
+
if (value.schemaVersion !== 1 || value.adapter !== "mobile" || value.platform !== "android" || !text(value.packageId) || !text(value.versionName) || !text(value.versionCode) || !/^[a-f0-9]{64}$/u.test(value.sha256 ?? "") || !text(value.deviceSerial) || !text(value.installedAt)) throw invalidState(file);
|
|
75
|
+
return value;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error instanceof Error && error.message.startsWith("The Mobile release artifact state is invalid")) {
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
throw invalidState(file);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function text(value) {
|
|
84
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
85
|
+
}
|
|
86
|
+
function isMissing(error) {
|
|
87
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
88
|
+
}
|
|
89
|
+
function invalidState(file) {
|
|
90
|
+
return new Error(`The Mobile release artifact state is invalid: ${file}. Next: rerun runtime-launch with the same artifact source.`);
|
|
91
|
+
}
|
|
92
|
+
function ensureSafeRuntimeDirectory(target, file) {
|
|
93
|
+
const root = path.resolve(target);
|
|
94
|
+
const directory = path.dirname(path.resolve(file));
|
|
95
|
+
const relative = path.relative(root, directory);
|
|
96
|
+
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
97
|
+
throw invalidState(file);
|
|
98
|
+
}
|
|
99
|
+
let current = root;
|
|
100
|
+
const rootStat = fs.lstatSync(root, { throwIfNoEntry: false });
|
|
101
|
+
if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) throw invalidState(file);
|
|
102
|
+
for (const segment of relative.split(path.sep)) {
|
|
103
|
+
current = path.join(current, segment);
|
|
104
|
+
const stat = fs.lstatSync(current, { throwIfNoEntry: false });
|
|
105
|
+
if (stat) {
|
|
106
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw invalidState(file);
|
|
107
|
+
} else {
|
|
108
|
+
fs.mkdirSync(current, { mode: 448 });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const realRoot = fs.realpathSync(root);
|
|
112
|
+
const realDirectory = fs.realpathSync(directory);
|
|
113
|
+
if (realDirectory !== realRoot && !realDirectory.startsWith(`${realRoot}${path.sep}`)) {
|
|
114
|
+
throw invalidState(file);
|
|
115
|
+
}
|
|
116
|
+
return directory;
|
|
117
|
+
}
|
|
118
|
+
export {
|
|
119
|
+
clearMobileReleaseArtifactState,
|
|
120
|
+
mobileReleaseArtifactStatePath,
|
|
121
|
+
readMobileReleaseArtifactState,
|
|
122
|
+
requireCurrentMobileReleaseArtifactState,
|
|
123
|
+
writeMobileReleaseArtifactState
|
|
124
|
+
};
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { promises as fsp } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
async function installAndroidReleaseArtifact(options, deps = defaultDeps) {
|
|
8
|
+
validateInputs(options);
|
|
9
|
+
const sourcePath = path.resolve(options.file);
|
|
10
|
+
const stagingDirectory = await fsp.mkdtemp(path.join(os.tmpdir(), "mm-harness-android-artifact-"));
|
|
11
|
+
try {
|
|
12
|
+
const stagedPath = path.join(stagingDirectory, "release.apk");
|
|
13
|
+
const { sha256, bytes } = await stageRegularFile(sourcePath, stagedPath);
|
|
14
|
+
if (sha256 !== options.expectedSha256) {
|
|
15
|
+
throw new Error(`Android artifact SHA-256 ${sha256} does not match expected ${options.expectedSha256}.`);
|
|
16
|
+
}
|
|
17
|
+
const { packageId, versionName, versionCode } = readAndroidPackageIdentity(deps, stagedPath);
|
|
18
|
+
if (packageId !== options.packageId) {
|
|
19
|
+
throw new Error(`Android artifact package ${packageId || "missing"} does not match expected ${options.packageId}.`);
|
|
20
|
+
}
|
|
21
|
+
if (versionName !== options.expectedVersion) {
|
|
22
|
+
throw new Error(`Android artifact version ${versionName || "missing"} does not match expected ${options.expectedVersion}.`);
|
|
23
|
+
}
|
|
24
|
+
if (versionCode !== options.expectedBuildId) {
|
|
25
|
+
throw new Error(`Android artifact build ${versionCode || "missing"} does not match expected ${options.expectedBuildId}.`);
|
|
26
|
+
}
|
|
27
|
+
const adb = resolveAdb(deps);
|
|
28
|
+
const state = runChecked(deps, adb, ["-s", options.deviceSerial, "get-state"]).trim();
|
|
29
|
+
if (state !== "device") {
|
|
30
|
+
throw new Error(`Android device ${options.deviceSerial} is not ready.`);
|
|
31
|
+
}
|
|
32
|
+
runChecked(deps, adb, ["-s", options.deviceSerial, "install", "-r", "-d", stagedPath]);
|
|
33
|
+
assertInstalledAndroidReleaseArtifact({
|
|
34
|
+
packageId,
|
|
35
|
+
versionName,
|
|
36
|
+
versionCode,
|
|
37
|
+
sha256,
|
|
38
|
+
deviceSerial: options.deviceSerial
|
|
39
|
+
}, deps, adb);
|
|
40
|
+
runChecked(deps, adb, [
|
|
41
|
+
"-s",
|
|
42
|
+
options.deviceSerial,
|
|
43
|
+
"shell",
|
|
44
|
+
"monkey",
|
|
45
|
+
"-p",
|
|
46
|
+
packageId,
|
|
47
|
+
"-c",
|
|
48
|
+
"android.intent.category.LAUNCHER",
|
|
49
|
+
"1"
|
|
50
|
+
]);
|
|
51
|
+
let pid = "";
|
|
52
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
53
|
+
const result = deps.run(adb, ["-s", options.deviceSerial, "shell", "pidof", packageId]);
|
|
54
|
+
if (result.status === 0 && result.stdout.trim()) {
|
|
55
|
+
pid = result.stdout.trim();
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
await deps.sleep(250);
|
|
59
|
+
}
|
|
60
|
+
if (!pid) throw new Error(`Android package ${packageId} did not start on ${options.deviceSerial}.`);
|
|
61
|
+
return {
|
|
62
|
+
schemaVersion: 1,
|
|
63
|
+
status: "pass",
|
|
64
|
+
adapter: "mobile",
|
|
65
|
+
platform: "android",
|
|
66
|
+
source: { kind: "local", path: sourcePath },
|
|
67
|
+
sha256,
|
|
68
|
+
bytes,
|
|
69
|
+
packageId,
|
|
70
|
+
versionName,
|
|
71
|
+
versionCode,
|
|
72
|
+
deviceSerial: options.deviceSerial,
|
|
73
|
+
installed: true,
|
|
74
|
+
launched: true
|
|
75
|
+
};
|
|
76
|
+
} finally {
|
|
77
|
+
await fsp.rm(stagingDirectory, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function assertAndroidReleaseArtifactRuntime(identity, deps = defaultDeps) {
|
|
81
|
+
validateInputs({
|
|
82
|
+
file: "runtime.apk",
|
|
83
|
+
expectedVersion: identity.versionName,
|
|
84
|
+
expectedBuildId: identity.versionCode,
|
|
85
|
+
expectedSha256: identity.sha256,
|
|
86
|
+
packageId: identity.packageId,
|
|
87
|
+
deviceSerial: identity.deviceSerial
|
|
88
|
+
});
|
|
89
|
+
const adb = resolveAdb(deps);
|
|
90
|
+
assertInstalledAndroidReleaseArtifact(identity, deps, adb);
|
|
91
|
+
}
|
|
92
|
+
async function relaunchAndroidReleaseArtifactRuntime(identity, deps = defaultDeps) {
|
|
93
|
+
assertAndroidReleaseArtifactRuntime(identity, deps);
|
|
94
|
+
const adb = resolveAdb(deps);
|
|
95
|
+
runChecked(deps, adb, [
|
|
96
|
+
"-s",
|
|
97
|
+
identity.deviceSerial,
|
|
98
|
+
"shell",
|
|
99
|
+
"monkey",
|
|
100
|
+
"-p",
|
|
101
|
+
identity.packageId,
|
|
102
|
+
"-c",
|
|
103
|
+
"android.intent.category.LAUNCHER",
|
|
104
|
+
"1"
|
|
105
|
+
]);
|
|
106
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
107
|
+
const result = deps.run(adb, ["-s", identity.deviceSerial, "shell", "pidof", identity.packageId]);
|
|
108
|
+
if (result.status === 0 && result.stdout.trim()) return;
|
|
109
|
+
await deps.sleep(250);
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`Android package ${identity.packageId} did not start on ${identity.deviceSerial}.`);
|
|
112
|
+
}
|
|
113
|
+
function validateInputs(options) {
|
|
114
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9._-]+)?$/u.test(options.expectedVersion)) {
|
|
115
|
+
throw new Error("--artifact-version must be an Android version name such as X.Y.Z.");
|
|
116
|
+
}
|
|
117
|
+
if (!/^\d+$/u.test(options.expectedBuildId)) {
|
|
118
|
+
throw new Error("--artifact-build-id must be the numeric Android version code.");
|
|
119
|
+
}
|
|
120
|
+
if (!/^[a-f0-9]{64}$/u.test(options.expectedSha256)) {
|
|
121
|
+
throw new Error("--artifact-sha256 must be 64 lowercase hexadecimal characters.");
|
|
122
|
+
}
|
|
123
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/u.test(options.packageId)) {
|
|
124
|
+
throw new Error("--artifact-package-id must be an Android application ID.");
|
|
125
|
+
}
|
|
126
|
+
if (!/^[A-Za-z0-9._:-]+$/u.test(options.deviceSerial)) {
|
|
127
|
+
throw new Error("Android artifact launch requires an explicit device serial.");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function assertInstalledAndroidReleaseArtifact(identity, deps, adb) {
|
|
131
|
+
const state = runChecked(deps, adb, ["-s", identity.deviceSerial, "get-state"]).trim();
|
|
132
|
+
if (state !== "device") throw new Error(`Android device ${identity.deviceSerial} is not ready.`);
|
|
133
|
+
const paths = runChecked(deps, adb, [
|
|
134
|
+
"-s",
|
|
135
|
+
identity.deviceSerial,
|
|
136
|
+
"shell",
|
|
137
|
+
"pm",
|
|
138
|
+
"path",
|
|
139
|
+
identity.packageId
|
|
140
|
+
]).split(/\r?\n/u).filter(Boolean);
|
|
141
|
+
if (paths.length !== 1 || !paths[0].startsWith("package:/") || !paths[0].endsWith("/base.apk")) {
|
|
142
|
+
throw new Error(`Android package ${identity.packageId} is not a single installed APK on ${identity.deviceSerial}.`);
|
|
143
|
+
}
|
|
144
|
+
const installedPath = paths[0].slice("package:".length);
|
|
145
|
+
if (!/^\/data\/app\/[A-Za-z0-9._~+=/-]+\/base\.apk$/u.test(installedPath)) {
|
|
146
|
+
throw new Error(`Android package ${identity.packageId} returned an unsafe installed path.`);
|
|
147
|
+
}
|
|
148
|
+
const installedSha256 = runChecked(deps, adb, [
|
|
149
|
+
"-s",
|
|
150
|
+
identity.deviceSerial,
|
|
151
|
+
"shell",
|
|
152
|
+
"sha256sum",
|
|
153
|
+
installedPath
|
|
154
|
+
]).trim().split(/\s+/u)[0];
|
|
155
|
+
if (installedSha256 !== identity.sha256) {
|
|
156
|
+
throw new Error(`Android package ${identity.packageId} no longer matches the trusted release artifact SHA-256.`);
|
|
157
|
+
}
|
|
158
|
+
const installed = runChecked(deps, adb, [
|
|
159
|
+
"-s",
|
|
160
|
+
identity.deviceSerial,
|
|
161
|
+
"shell",
|
|
162
|
+
"dumpsys",
|
|
163
|
+
"package",
|
|
164
|
+
identity.packageId
|
|
165
|
+
]);
|
|
166
|
+
if (!new RegExp(`^\\s*versionName=${escapeRegExp(identity.versionName)}\\s*$`, "mu").test(installed) || !new RegExp(`^\\s*versionCode=${escapeRegExp(identity.versionCode)}(?:\\s|$)`, "mu").test(installed)) {
|
|
167
|
+
throw new Error(`Android package ${identity.packageId} installed with an unexpected version on ${identity.deviceSerial}.`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function escapeRegExp(value) {
|
|
171
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
172
|
+
}
|
|
173
|
+
async function stageRegularFile(file, destination) {
|
|
174
|
+
const descriptor = await fsp.open(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
175
|
+
const output = await fsp.open(destination, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 384);
|
|
176
|
+
try {
|
|
177
|
+
const stat = await descriptor.stat();
|
|
178
|
+
if (!stat.isFile() || stat.size <= 0 || stat.size > 2 * 1024 * 1024 * 1024) {
|
|
179
|
+
throw new Error(`Android artifact must be a non-empty regular file smaller than 2 GiB: ${file}`);
|
|
180
|
+
}
|
|
181
|
+
const digest = createHash("sha256");
|
|
182
|
+
const stream = descriptor.createReadStream({ autoClose: false });
|
|
183
|
+
for await (const chunk of stream) {
|
|
184
|
+
digest.update(chunk);
|
|
185
|
+
await output.writeFile(chunk);
|
|
186
|
+
}
|
|
187
|
+
await output.sync();
|
|
188
|
+
return { sha256: digest.digest("hex"), bytes: stat.size };
|
|
189
|
+
} finally {
|
|
190
|
+
await output.close();
|
|
191
|
+
await descriptor.close();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function resolveAdb(deps) {
|
|
195
|
+
const configured = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
|
196
|
+
const candidates = [
|
|
197
|
+
configured ? path.join(configured, "platform-tools", "adb") : "",
|
|
198
|
+
path.join(os.homedir(), "Library", "Android", "sdk", "platform-tools", "adb"),
|
|
199
|
+
"adb"
|
|
200
|
+
].filter(Boolean);
|
|
201
|
+
return firstRunnable(deps, candidates, ["version"], "adb");
|
|
202
|
+
}
|
|
203
|
+
function readAndroidPackageIdentity(deps, file) {
|
|
204
|
+
const analyzer = resolveApkAnalyzer(deps);
|
|
205
|
+
if (analyzer) {
|
|
206
|
+
const packageId2 = deps.run(analyzer, ["manifest", "application-id", file]);
|
|
207
|
+
const versionName2 = deps.run(analyzer, ["manifest", "version-name", file]);
|
|
208
|
+
const versionCode2 = deps.run(analyzer, ["manifest", "version-code", file]);
|
|
209
|
+
if (packageId2.status === 0 && packageId2.stdout.trim() && versionName2.status === 0 && versionName2.stdout.trim() && versionCode2.status === 0 && versionCode2.stdout.trim()) {
|
|
210
|
+
return {
|
|
211
|
+
packageId: packageId2.stdout.trim(),
|
|
212
|
+
versionName: versionName2.stdout.trim(),
|
|
213
|
+
versionCode: versionCode2.stdout.trim()
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const aapt2 = resolveAapt2(deps);
|
|
218
|
+
const badging = runChecked(deps, aapt2, ["dump", "badging", file]);
|
|
219
|
+
const line = badging.split(/\r?\n/u).find((entry) => entry.startsWith("package: ")) ?? "";
|
|
220
|
+
const packageId = /\bname='([^']+)'/u.exec(line)?.[1] ?? "";
|
|
221
|
+
const versionCode = /\bversionCode='([^']+)'/u.exec(line)?.[1] ?? "";
|
|
222
|
+
const versionName = /\bversionName='([^']+)'/u.exec(line)?.[1] ?? "";
|
|
223
|
+
if (!packageId || !versionCode || !versionName) {
|
|
224
|
+
throw new Error("aapt2 could not read the Android artifact package identity.");
|
|
225
|
+
}
|
|
226
|
+
return { packageId, versionName, versionCode };
|
|
227
|
+
}
|
|
228
|
+
function resolveApkAnalyzer(deps) {
|
|
229
|
+
const configured = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
|
230
|
+
const candidates = [
|
|
231
|
+
configured ? path.join(configured, "cmdline-tools", "latest", "bin", "apkanalyzer") : "",
|
|
232
|
+
path.join(os.homedir(), "Library", "Android", "sdk", "cmdline-tools", "latest", "bin", "apkanalyzer")
|
|
233
|
+
].filter(Boolean);
|
|
234
|
+
for (const candidate of candidates) {
|
|
235
|
+
try {
|
|
236
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
237
|
+
if (fs.statSync(candidate).isFile()) return candidate;
|
|
238
|
+
} catch {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const probe = deps.run("apkanalyzer", []);
|
|
243
|
+
if (`${probe.stdout}
|
|
244
|
+
${probe.stderr}`.includes("manifest application-id")) return "apkanalyzer";
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
function resolveAapt2(deps) {
|
|
248
|
+
const configured = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
|
249
|
+
const buildToolsRoot = configured ? path.join(configured, "build-tools") : path.join(os.homedir(), "Library", "Android", "sdk", "build-tools");
|
|
250
|
+
let candidates = [];
|
|
251
|
+
try {
|
|
252
|
+
candidates = fs.readdirSync(buildToolsRoot).sort((left, right) => right.localeCompare(left, void 0, { numeric: true })).map((version) => path.join(buildToolsRoot, version, "aapt2"));
|
|
253
|
+
} catch {
|
|
254
|
+
candidates = [];
|
|
255
|
+
}
|
|
256
|
+
candidates.push("aapt2");
|
|
257
|
+
return firstRunnable(deps, candidates, ["version"], "apkanalyzer or aapt2");
|
|
258
|
+
}
|
|
259
|
+
function firstRunnable(deps, candidates, args, label) {
|
|
260
|
+
for (const candidate of candidates) {
|
|
261
|
+
if (deps.run(candidate, args).status === 0) return candidate;
|
|
262
|
+
}
|
|
263
|
+
throw new Error(`${label} is required to validate and install an Android release artifact.`);
|
|
264
|
+
}
|
|
265
|
+
function runChecked(deps, command, args) {
|
|
266
|
+
const result = deps.run(command, args);
|
|
267
|
+
if (result.status !== 0) {
|
|
268
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`;
|
|
269
|
+
throw new Error(`${path.basename(command)} failed: ${detail}`);
|
|
270
|
+
}
|
|
271
|
+
return result.stdout;
|
|
272
|
+
}
|
|
273
|
+
const defaultDeps = {
|
|
274
|
+
run(command, args) {
|
|
275
|
+
const result = spawnSync(command, args, {
|
|
276
|
+
encoding: "utf8",
|
|
277
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
278
|
+
timeout: 12e4,
|
|
279
|
+
maxBuffer: 4 * 1024 * 1024
|
|
280
|
+
});
|
|
281
|
+
return {
|
|
282
|
+
status: result.status ?? 1,
|
|
283
|
+
stdout: result.stdout || "",
|
|
284
|
+
stderr: result.stderr || result.error?.message || ""
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
sleep(ms) {
|
|
288
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
export {
|
|
292
|
+
assertAndroidReleaseArtifactRuntime,
|
|
293
|
+
installAndroidReleaseArtifact,
|
|
294
|
+
relaunchAndroidReleaseArtifactRuntime
|
|
295
|
+
};
|
package/dist/adapters.js
CHANGED
|
@@ -9,12 +9,18 @@ import { withExtensionPage } from "../library/actions/extension/platform/cdp.mjs
|
|
|
9
9
|
import { bridgeCommand, evalAsync, evalSync, MOBILE_BRIDGE_ERROR_CODES, selectBridgeStatusEntry, simulatorScreenshot } from "../library/actions/mobile/platform/bridge.mjs";
|
|
10
10
|
import { observeNativeUi } from "../library/actions/mobile/platform/observe-ui.mjs";
|
|
11
11
|
import { resolveMobileToolPath } from "../library/actions/mobile/platform/tool-paths.mjs";
|
|
12
|
+
import { resolveWalletImportCredentials, validateWalletImportOptions } from "../library/actions/shared/wallet/import-source.mjs";
|
|
12
13
|
const execFileAsync = promisify(execFile);
|
|
13
14
|
const NATIVE_PROVIDER_UI_ACTIONS = /* @__PURE__ */ new Set([
|
|
15
|
+
"ui.press",
|
|
16
|
+
"ui.set_input",
|
|
17
|
+
"ui.scroll",
|
|
14
18
|
"ui.swipe",
|
|
15
19
|
"ui.pan",
|
|
16
20
|
"ui.drag",
|
|
17
21
|
"ui.long_press",
|
|
22
|
+
"ui.wait_for",
|
|
23
|
+
"ui.screenshot",
|
|
18
24
|
"ui.capture_surface"
|
|
19
25
|
]);
|
|
20
26
|
function sleep(ms) {
|
|
@@ -29,6 +35,7 @@ function simpleAdapter(action, executor) {
|
|
|
29
35
|
};
|
|
30
36
|
}
|
|
31
37
|
const LIVE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set([
|
|
38
|
+
"metamask.perps.read_snapshot",
|
|
32
39
|
"metamask.perps.read_positions",
|
|
33
40
|
"metamask.perps.ensure_positions",
|
|
34
41
|
"metamask.perps.assert_positions",
|
|
@@ -46,11 +53,12 @@ const LIVE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set([
|
|
|
46
53
|
"metamask.perps.capture_performance"
|
|
47
54
|
]);
|
|
48
55
|
const CORE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set([
|
|
49
|
-
"metamask.perps.read_account"
|
|
50
|
-
"metamask.perps.read_snapshot"
|
|
56
|
+
"metamask.perps.read_account"
|
|
51
57
|
]);
|
|
52
58
|
const LIVE_ONLY_WALLET_ACTIONS = /* @__PURE__ */ new Set([
|
|
53
59
|
"metamask.wallet.setup",
|
|
60
|
+
"metamask.wallet.import",
|
|
61
|
+
"metamask.wallet.reset",
|
|
54
62
|
"metamask.wallet.ensure_unlocked",
|
|
55
63
|
"metamask.wallet.select_account",
|
|
56
64
|
"metamask.wallet.list_accounts",
|
|
@@ -110,6 +118,18 @@ async function semanticResult(platform, action, node, context, forceLive = false
|
|
|
110
118
|
}
|
|
111
119
|
const output = { platform, action, redacted: true };
|
|
112
120
|
if (action === "metamask.wallet.fixture_status") return { output: fixtureSummary(context.projectRoot) };
|
|
121
|
+
if (action === "metamask.wallet.validate_import") {
|
|
122
|
+
validateWalletImportOptions(platform, node);
|
|
123
|
+
const credentials = await resolveWalletImportCredentials({ node, context });
|
|
124
|
+
return {
|
|
125
|
+
output: {
|
|
126
|
+
...output,
|
|
127
|
+
credentialSource: credentials.source,
|
|
128
|
+
credentialSourceName: credentials.sourceName,
|
|
129
|
+
expectedAddress: credentials.expectedAddress
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
113
133
|
return {
|
|
114
134
|
output: {
|
|
115
135
|
...output,
|
|
@@ -183,7 +203,10 @@ function probeHttpJson(url, timeoutMs = 1e3) {
|
|
|
183
203
|
function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], preparedLiveAdapters) {
|
|
184
204
|
const walletActions = [
|
|
185
205
|
"metamask.wallet.fixture_status",
|
|
206
|
+
"metamask.wallet.validate_import",
|
|
186
207
|
"metamask.wallet.setup",
|
|
208
|
+
"metamask.wallet.import",
|
|
209
|
+
"metamask.wallet.reset",
|
|
187
210
|
"metamask.wallet.ensure_unlocked",
|
|
188
211
|
"metamask.wallet.select_account",
|
|
189
212
|
"metamask.wallet.list_accounts",
|
|
@@ -203,13 +226,14 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
|
|
|
203
226
|
"metamask.perps.start_state",
|
|
204
227
|
"metamask.perps.teardown_state",
|
|
205
228
|
"metamask.perps.capture_performance",
|
|
229
|
+
...platform === "extension" || platform === "core" ? ["metamask.perps.read_snapshot"] : [],
|
|
206
230
|
...platform === "mobile" ? [
|
|
207
231
|
"metamask.perps.clear_performance_caches",
|
|
208
232
|
"metamask.perps.measure_homepage_visible",
|
|
209
233
|
"metamask.perps.prepare_local_snapshot_endpoint"
|
|
210
234
|
] : [],
|
|
211
235
|
// read_account is core-only: only the headless core adapter implements it.
|
|
212
|
-
...platform === "core" ? ["metamask.perps.read_account"
|
|
236
|
+
...platform === "core" ? ["metamask.perps.read_account"] : []
|
|
213
237
|
];
|
|
214
238
|
const bundled = new Set(bundledActions);
|
|
215
239
|
const actions = [.../* @__PURE__ */ new Set([...bundledActions, ...declaredCustomActions])];
|
|
@@ -644,6 +668,7 @@ async function handleMobileHud(payload, context) {
|
|
|
644
668
|
}
|
|
645
669
|
}
|
|
646
670
|
async function hideMobileHudOnTeardown(projectRoot, env = {}) {
|
|
671
|
+
if (env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1" || process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1") return;
|
|
647
672
|
if (process.env.METAMASK_RECIPE_AUTO_HUD === "0" || process.env.METAMASK_RECIPE_AUTO_HUD === "false") return;
|
|
648
673
|
const input = {
|
|
649
674
|
node: { bridge_timeout_ms: 8e3, cdp_timeout_ms: 5e3 },
|
|
@@ -785,7 +810,7 @@ function createMetaMaskUiTransport(platform, harness, preparedLiveAdapters) {
|
|
|
785
810
|
if (live) return live.output;
|
|
786
811
|
throw new Error(`ui.navigate requires library/actions/${platform}/ui/navigate.mjs.`);
|
|
787
812
|
}
|
|
788
|
-
if (platform === "mobile" && NATIVE_PROVIDER_UI_ACTIONS.has(action)) {
|
|
813
|
+
if (platform === "mobile" && NATIVE_PROVIDER_UI_ACTIONS.has(action) && (context.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1" || process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1" || ["ui.swipe", "ui.pan", "ui.drag", "ui.long_press", "ui.capture_surface"].includes(action))) {
|
|
789
814
|
return executeNativeProviderAction(action, node, context, harness.createNativeUiTransport);
|
|
790
815
|
}
|
|
791
816
|
return base.execute(action, action === "ui.wait_for" ? normalizeUiWaitNode(node) : node, context);
|
package/dist/cli-commands.js
CHANGED
|
@@ -15,7 +15,7 @@ const SPEC = {
|
|
|
15
15
|
{ name: "sync", desc: "Refresh harness + canonicalize wallet fixture", flags: ["--json"] },
|
|
16
16
|
{ name: "logs", aliases: ["tail"], desc: "Compact build events or full log", flags: ["--full", "-f", "--window", "--events", "--source", "--json"] },
|
|
17
17
|
{ name: "debug", aliases: ["devtools", "inspect"], desc: "Open DevTools UI", flags: ["--json", "--no-open"] },
|
|
18
|
-
{ name: "fixtures", desc: "Manage the canonical wallet fixture (sync/set/generate)", args: ["sync", "set", "generate"], flags: ["--fixture", "--out", "--adapter", "--target", "--device", "--json"] },
|
|
18
|
+
{ name: "fixtures", desc: "Manage the canonical wallet fixture (sync/set/reset/generate)", args: ["sync", "set", "reset", "generate"], flags: ["--fixture", "--out", "--adapter", "--target", "--device", "--json"] },
|
|
19
19
|
{ name: "actions", desc: "List runnable recipe actions", flags: ["--json", "--matrix", "--categories", "--category", "--action", "--library"] },
|
|
20
20
|
{ name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--print-ready", "--cdp-port", "--device"] },
|
|
21
21
|
{ name: "run", desc: "Execute a proof recipe (path or library name, e.g. run perps.smoke)", args: ["recipe.json|name"], flags: ["--list", "--device"] },
|
package/dist/cli.js
CHANGED
|
@@ -47,8 +47,8 @@ DAILY LOOP \u2014 what a teammate runs many times a day:
|
|
|
47
47
|
mm-harness logs
|
|
48
48
|
debug Open the debug console (extension DevTools / mobile RN).
|
|
49
49
|
mm-harness debug
|
|
50
|
-
fixtures Sync files
|
|
51
|
-
mm-harness fixtures sync # or: set | generate --fixture <f> --out <o> | finalize \u2026
|
|
50
|
+
fixtures Sync files, set/reset the wallet, generate fixture-state, or finalize labels over CDP.
|
|
51
|
+
mm-harness fixtures sync # or: set | reset | generate --fixture <f> --out <o> | finalize \u2026
|
|
52
52
|
|
|
53
53
|
PROVE \u2014 run recipes and inspect capabilities:
|
|
54
54
|
run Run a recipe and write evidence (summary/trace/artifacts).
|