@deeeed/metamask-harness 0.13.0 → 0.14.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.
- package/CHANGELOG.md +43 -4
- package/adapters/extension/inject.mjs +1 -0
- package/adapters/extension/launch-browser.cjs +15 -3
- package/adapters/extension/lib/extension-id.cjs +36 -0
- package/adapters/extension/live.sh +5 -3
- package/adapters/extension/readiness.mjs +48 -0
- package/adapters/extension/reattach.sh +163 -55
- package/adapters/extension/sidepanel-toggle.sh +96 -24
- package/adapters/extension/verify.sh +15 -4
- package/adapters/extension/wallet-fixture-state.cjs +6 -11
- package/adapters/mobile/open-device.sh +32 -5
- package/adapters/mobile/start-metro.sh +15 -1
- package/adapters/mobile/verify.sh +50 -13
- package/adapters/shared/log-tui.mjs +7 -3
- package/adapters/shared/resolve-slot-ports-core.mjs +14 -4
- package/dist/adapters/core/surface.js +1 -0
- package/dist/adapters/extension/runtime.js +14 -5
- package/dist/adapters/extension/surface.js +1 -0
- package/dist/adapters/mobile/provision.js +49 -4
- package/dist/adapters/mobile/surface.js +1 -0
- package/dist/adapters/slot-ports.js +11 -4
- package/dist/cli-commands.js +5 -4
- package/dist/cli.js +4 -0
- package/dist/commands/call.js +79 -6
- package/dist/commands/check.js +326 -0
- package/dist/commands/core-readiness.js +75 -0
- package/dist/commands/device-target.js +123 -13
- package/dist/commands/doctor.js +30 -18
- package/dist/commands/fixtures.js +2 -1
- package/dist/commands/launch/extension.js +105 -9
- package/dist/commands/launch/index.js +116 -15
- package/dist/commands/mobile-device-view.js +140 -0
- package/dist/commands/parse-args.js +4 -1
- package/dist/commands/recipe-quality.js +6 -2
- package/dist/commands/run-engine.js +90 -5
- package/dist/commands/run-report.js +115 -0
- package/dist/commands/run.js +115 -8
- package/dist/commands/self-test.js +1 -1
- package/dist/commands/shared.js +2 -1
- package/dist/commands/status-probe.js +9 -3
- package/dist/commands/status.js +45 -50
- package/dist/live-adapter-contract.js +5 -1
- package/dist/mm-harness-cli.js +68 -27
- package/dist/recipe-files.js +14 -0
- package/docs/CLI-SPEC.md +25 -0
- package/docs/architecture.md +2 -2
- package/docs/live-adapter-contract.md +1 -1
- package/docs/recipe-libraries.md +21 -18
- package/library/actions/extension/platform/cdp.mjs +7 -4
- package/library/recipes/{app-lifecycle-android-smoke.mobile.recipe.json → app/lifecycle.android-smoke.mobile.recipe.json} +1 -1
- package/library/recipes/{perps-performance.mobile.recipe.json → perps/performance.mobile.recipe.json} +1 -1
- package/library/recipes/perps/smoke.core.recipe.json +39 -0
- package/library/recipes/perps/smoke.extension.recipe.json +51 -0
- package/library/recipes/perps/smoke.mobile.recipe.json +51 -0
- package/library/recipes/{action-validation.extension.recipe.json → runner/action-validation.extension.recipe.json} +7 -7
- package/library/recipes/{action-validation.mobile.recipe.json → runner/action-validation.mobile.recipe.json} +7 -7
- package/package.json +1 -1
- /package/library/recipes/{perps-lifecycle.recipe.json → perps/lifecycle.recipe.json} +0 -0
- /package/library/recipes/{order-lifecycle.core.recipe.json → perps/order-lifecycle.core.recipe.json} +0 -0
- /package/library/recipes/{perps-performance-background-resume.mobile.recipe.json → perps/performance.background-resume.mobile.recipe.json} +0 -0
- /package/library/recipes/{perps-performance-cold-start.mobile.recipe.json → perps/performance.cold-start.mobile.recipe.json} +0 -0
- /package/library/recipes/{perps-performance-warm-start.mobile.recipe.json → perps/performance.warm-start.mobile.recipe.json} +0 -0
- /package/library/recipes/{read-markets.core.recipe.json → perps/read-markets.core.recipe.json} +0 -0
- /package/library/recipes/{trading-lifecycle.core.recipe.json → perps/trading-lifecycle.core.recipe.json} +0 -0
- /package/library/recipes/{smoke.extension.recipe.json → runner/smoke.extension.recipe.json} +0 -0
- /package/library/recipes/{smoke.mobile.recipe.json → runner/smoke.mobile.recipe.json} +0 -0
|
@@ -457,11 +457,13 @@ function preapproveDeepLinkScheme(device, bundleId, options) {
|
|
|
457
457
|
function ensureSimulator(name, runtime, deviceType) {
|
|
458
458
|
const existing = findSimulator(name);
|
|
459
459
|
if (existing) return { name, udid: existing, created: false, runtime, deviceType };
|
|
460
|
-
|
|
461
|
-
|
|
460
|
+
const resolvedRuntime = runtime || latestIosRuntime();
|
|
461
|
+
const resolvedDeviceType = deviceType || preferredIphoneDeviceType();
|
|
462
|
+
if (!resolvedRuntime || !resolvedDeviceType) {
|
|
463
|
+
throw new Error(`simulator ${name} is missing and no available iOS simulator runtime/device type could be resolved from xcrun simctl.`);
|
|
462
464
|
}
|
|
463
|
-
const udid = execFileSync("xcrun", ["simctl", "create", name,
|
|
464
|
-
return { name, udid: udid || name, created: true, runtime, deviceType };
|
|
465
|
+
const udid = execFileSync("xcrun", ["simctl", "create", name, resolvedDeviceType, resolvedRuntime], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
466
|
+
return { name, udid: udid || name, created: true, runtime: resolvedRuntime, deviceType: resolvedDeviceType };
|
|
465
467
|
}
|
|
466
468
|
function findSimulator(name) {
|
|
467
469
|
const data = execJson("xcrun", ["simctl", "list", "devices", "--json"]);
|
|
@@ -471,6 +473,49 @@ function findSimulator(name) {
|
|
|
471
473
|
}
|
|
472
474
|
return void 0;
|
|
473
475
|
}
|
|
476
|
+
function latestIosRuntime() {
|
|
477
|
+
const data = execJson(
|
|
478
|
+
"xcrun",
|
|
479
|
+
["simctl", "list", "runtimes", "--json"]
|
|
480
|
+
);
|
|
481
|
+
const runtimes = (data.runtimes ?? []).filter((runtime) => runtime.isAvailable !== false && runtime.identifier && (runtime.platform === "iOS" || /(^|[.-])iOS([.-]|$)/u.test(runtime.identifier) || /^iOS\b/u.test(runtime.name ?? ""))).sort((a, b) => compareVersions(runtimeVersion(b), runtimeVersion(a)));
|
|
482
|
+
return runtimes[0]?.identifier;
|
|
483
|
+
}
|
|
484
|
+
function preferredIphoneDeviceType() {
|
|
485
|
+
const data = execJson("xcrun", ["simctl", "list", "devicetypes", "--json"]);
|
|
486
|
+
const devices = (data.devicetypes ?? []).filter((device) => device.identifier && /^iPhone\b/u.test(device.name ?? "")).sort((a, b) => {
|
|
487
|
+
const generation = iphoneGeneration(b) - iphoneGeneration(a);
|
|
488
|
+
if (generation !== 0) return generation;
|
|
489
|
+
const proScore = iphoneProScore(b) - iphoneProScore(a);
|
|
490
|
+
if (proScore !== 0) return proScore;
|
|
491
|
+
return String(b.name ?? "").localeCompare(String(a.name ?? ""));
|
|
492
|
+
});
|
|
493
|
+
return devices[0]?.identifier;
|
|
494
|
+
}
|
|
495
|
+
function runtimeVersion(runtime) {
|
|
496
|
+
const raw = runtime.version || /iOS[-. ]([0-9][0-9A-Za-z_.-]*)/u.exec(runtime.identifier ?? "")?.[1] || runtime.name || "";
|
|
497
|
+
const parts = raw.match(/\d+/gu)?.map((part) => Number(part)) ?? [];
|
|
498
|
+
return parts.length ? parts : [0];
|
|
499
|
+
}
|
|
500
|
+
function compareVersions(a, b) {
|
|
501
|
+
const length = Math.max(a.length, b.length);
|
|
502
|
+
for (let i = 0; i < length; i += 1) {
|
|
503
|
+
const diff = (a[i] ?? 0) - (b[i] ?? 0);
|
|
504
|
+
if (diff !== 0) return diff;
|
|
505
|
+
}
|
|
506
|
+
return 0;
|
|
507
|
+
}
|
|
508
|
+
function iphoneGeneration(device) {
|
|
509
|
+
const match = /^iPhone\s+(\d+)/u.exec(device.name ?? "");
|
|
510
|
+
return match ? Number(match[1]) : 0;
|
|
511
|
+
}
|
|
512
|
+
function iphoneProScore(device) {
|
|
513
|
+
const name = device.name ?? "";
|
|
514
|
+
if (/\bPro Max\b/u.test(name)) return 3;
|
|
515
|
+
if (/\bPro\b/u.test(name)) return 2;
|
|
516
|
+
if (/\bPlus\b/u.test(name)) return 1;
|
|
517
|
+
return 0;
|
|
518
|
+
}
|
|
474
519
|
function baselineSimulatorCandidates(data, current) {
|
|
475
520
|
const candidates = [];
|
|
476
521
|
if (current.simulator) candidates.push(current.simulator);
|
|
@@ -10,10 +10,8 @@ import {
|
|
|
10
10
|
resolveMobileSlotDefaults
|
|
11
11
|
} from "./resolve-slot-ports.js";
|
|
12
12
|
function applyKVLines(output, overwrite) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if (!m) continue;
|
|
16
|
-
const [, key, val] = m;
|
|
13
|
+
const entries = output.split("\n").map((line) => /^([A-Z_]+)=(.+)$/u.exec(line.trim())).filter((match) => match !== null).map((match) => ({ key: match[1], val: match[2] }));
|
|
14
|
+
for (const { key, val } of entries) {
|
|
17
15
|
if (val === "null" || val === "undefined" || val === "") continue;
|
|
18
16
|
switch (key) {
|
|
19
17
|
case "WATCHER_PORT":
|
|
@@ -26,6 +24,15 @@ function applyKVLines(output, overwrite) {
|
|
|
26
24
|
case "IOS_SIMULATOR":
|
|
27
25
|
if (overwrite || !process.env["IOS_SIMULATOR"]) process.env["IOS_SIMULATOR"] = val;
|
|
28
26
|
break;
|
|
27
|
+
case "ADB_SERIAL":
|
|
28
|
+
if (overwrite || !process.env["ADB_SERIAL"]) {
|
|
29
|
+
process.env["ADB_SERIAL"] = val;
|
|
30
|
+
process.env["ANDROID_SERIAL"] = val;
|
|
31
|
+
}
|
|
32
|
+
break;
|
|
33
|
+
case "ANDROID_DEVICE":
|
|
34
|
+
if (overwrite || !process.env["ANDROID_DEVICE"]) process.env["ANDROID_DEVICE"] = val;
|
|
35
|
+
break;
|
|
29
36
|
case "SLOT_ID":
|
|
30
37
|
if (overwrite || !process.env["RECIPE_SLOT_ID"]) process.env["RECIPE_SLOT_ID"] = val;
|
|
31
38
|
break;
|
package/dist/cli-commands.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { listRecipeFiles } from "./recipe-files.js";
|
|
4
5
|
const SPEC = {
|
|
5
6
|
shim: [
|
|
6
7
|
{ name: "sync", desc: "Copy fixture, write slot context, install harness", flags: ["--dry-run", "--no-install"] },
|
|
@@ -16,7 +17,7 @@ const SPEC = {
|
|
|
16
17
|
{ name: "fixtures", desc: "Manage the canonical wallet fixture (sync/set/generate)", args: ["sync", "set", "generate"], flags: ["--fixture", "--out", "--adapter", "--target", "--device", "--json"] },
|
|
17
18
|
{ name: "actions", desc: "List runnable recipe actions", flags: ["--json"] },
|
|
18
19
|
{ name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--cdp-port", "--device"] },
|
|
19
|
-
{ name: "run", desc: "Execute a proof recipe (path or library name, e.g. run smoke)", args: ["recipe.json|name"], flags: ["--list", "--device"] },
|
|
20
|
+
{ name: "run", desc: "Execute a proof recipe (path or library name, e.g. run perps.smoke)", args: ["recipe.json|name"], flags: ["--list", "--device"] },
|
|
20
21
|
{ name: "recipe-quality", desc: "Build the recipe-quality artifact from compact JSON", args: ["build"], flags: ["--input", "--output", "--json"] },
|
|
21
22
|
{ name: "interactive", aliases: ["menu"], desc: "Interactive command menu" },
|
|
22
23
|
{ name: "prepare", desc: "Install harness (+ optional validate)", flags: ["--target", "--runtime-dir", "--json"] },
|
|
@@ -124,9 +125,9 @@ function listRunRecipes(adapter, root, cwd = process.cwd()) {
|
|
|
124
125
|
for (const resolveDir of RUN_RECIPE_DIRS) {
|
|
125
126
|
const dir = resolveDir(adapter, root);
|
|
126
127
|
if (!fs.existsSync(dir)) continue;
|
|
127
|
-
for (const
|
|
128
|
-
if (
|
|
129
|
-
byBase.set(
|
|
128
|
+
for (const rel of listRecipeFiles(dir)) {
|
|
129
|
+
if (byBase.has(rel)) continue;
|
|
130
|
+
byBase.set(rel, path.join(dir, rel));
|
|
130
131
|
}
|
|
131
132
|
}
|
|
132
133
|
return [...byBase.values()].map((abs) => path.relative(cwd, abs)).sort();
|
package/dist/cli.js
CHANGED
|
@@ -19,6 +19,7 @@ import { handleDebug } from "./commands/debug.js";
|
|
|
19
19
|
import { handleFixtures } from "./commands/fixtures.js";
|
|
20
20
|
import { handleRecipeQuality } from "./commands/recipe-quality.js";
|
|
21
21
|
import { handleStatus } from "./commands/status.js";
|
|
22
|
+
import { handleCheck } from "./commands/check.js";
|
|
22
23
|
import { parseArgs, targetPath } from "./commands/parse-args.js";
|
|
23
24
|
import { runOneNode } from "./commands/run-engine.js";
|
|
24
25
|
const COMMANDS = {
|
|
@@ -56,6 +57,8 @@ PROVE \u2014 run recipes and inspect capabilities:
|
|
|
56
57
|
mm-harness flows list
|
|
57
58
|
doctor Readiness check for a checkout (no app launch).
|
|
58
59
|
mm-harness doctor
|
|
60
|
+
check Run bounded repo-local checks for an active diff.
|
|
61
|
+
mm-harness check diff --profile fast --artifacts-dir artifacts/validation
|
|
59
62
|
actions Describe the actions a manifest declares.
|
|
60
63
|
mm-harness actions --adapter mobile
|
|
61
64
|
manifest Print/validate the action manifest for an adapter.
|
|
@@ -127,6 +130,7 @@ async function main(argv) {
|
|
|
127
130
|
if (command === "debug") return handleDebug(argv.slice(1));
|
|
128
131
|
if (command === "fixtures") return handleFixtures(argv.slice(1), { runOneNode });
|
|
129
132
|
if (command === "recipe-quality") return handleRecipeQuality(argv.slice(1));
|
|
133
|
+
if (command === "check") return handleCheck(argv.slice(1));
|
|
130
134
|
const handler = COMMANDS[command];
|
|
131
135
|
if (!handler) throw new Error(`Unknown command: ${command}`);
|
|
132
136
|
return handler(parseArgs(argv.slice(1), command));
|
package/dist/commands/call.js
CHANGED
|
@@ -5,6 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { loadActionManifest } from "../manifest.js";
|
|
6
6
|
import { importRecipeProtocol } from "../paths.js";
|
|
7
7
|
import { recipeRunning } from "../heal-bounds.js";
|
|
8
|
+
import { color } from "../cli-color.js";
|
|
8
9
|
import { EXIT } from "./shared.js";
|
|
9
10
|
import { describeManifestActions, fuzzyResolveActions } from "./manifest.js";
|
|
10
11
|
import {
|
|
@@ -16,8 +17,10 @@ import {
|
|
|
16
17
|
runtimeOptionsFromCli,
|
|
17
18
|
usageError
|
|
18
19
|
} from "./parse-args.js";
|
|
20
|
+
import { getAdapterSurface } from "../adapters/surface.js";
|
|
19
21
|
import { handleListExecutables } from "./list-executables.js";
|
|
20
22
|
import { applyDeviceTargeting } from "./device-target.js";
|
|
23
|
+
import { coreDependencyBlock } from "./core-readiness.js";
|
|
21
24
|
import {
|
|
22
25
|
emitHealViolation,
|
|
23
26
|
executeWithHealBounds,
|
|
@@ -33,7 +36,7 @@ async function handleCall(argv) {
|
|
|
33
36
|
return handleListExecutables("call", options2);
|
|
34
37
|
}
|
|
35
38
|
if (argv.length > 0 && argv[0].startsWith("--")) {
|
|
36
|
-
const message = "call requires <action> first: mm-harness call <action> [--arg k=v ...] [flags]";
|
|
39
|
+
const message = "call requires <action> first: mm-harness call <action> [key=value ...] [--arg k=v ...] [flags]";
|
|
37
40
|
console.error(message);
|
|
38
41
|
return EXIT.usage;
|
|
39
42
|
}
|
|
@@ -60,6 +63,7 @@ async function handleCall(argv) {
|
|
|
60
63
|
return EXIT.usage;
|
|
61
64
|
}
|
|
62
65
|
const { adapter, target } = resolveAdapter(options);
|
|
66
|
+
getAdapterSurface(adapter).resolveSlotPorts(target);
|
|
63
67
|
const dtResult = applyDeviceTargeting("call", adapter, options, { gate: true, rerun: "" });
|
|
64
68
|
if ("code" in dtResult) {
|
|
65
69
|
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, error: { code: dtResult.code, message: dtResult.message } }, null, 2));
|
|
@@ -94,6 +98,16 @@ async function handleCall(argv) {
|
|
|
94
98
|
return EXIT.usage;
|
|
95
99
|
}
|
|
96
100
|
const resolvedAction = resolution.resolved;
|
|
101
|
+
const depsBlock = adapter === "core" && resolvedAction.startsWith("metamask.perps.") ? coreDependencyBlock(target) : null;
|
|
102
|
+
if (depsBlock) {
|
|
103
|
+
if (json) {
|
|
104
|
+
console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, status: "fail", exitCode: EXIT.usage, error: depsBlock }, null, 2));
|
|
105
|
+
} else {
|
|
106
|
+
console.error(`\u2717 call: ${depsBlock.message}`);
|
|
107
|
+
console.error(` Next: ${depsBlock.userAction}`);
|
|
108
|
+
}
|
|
109
|
+
return EXIT.usage;
|
|
110
|
+
}
|
|
97
111
|
const recipe = synthesizeOneNodeRecipe(resolvedAction, args);
|
|
98
112
|
const validation = await validateRecipeAdapterAware(recipe, manifest);
|
|
99
113
|
if (validation.status === "invalid") {
|
|
@@ -127,6 +141,7 @@ async function handleCall(argv) {
|
|
|
127
141
|
state
|
|
128
142
|
);
|
|
129
143
|
if (violation !== null) return emitHealViolation(json, "call", result, violation, state, adapter);
|
|
144
|
+
const callOutput = readCallOutput(result.tracePath);
|
|
130
145
|
if (json) {
|
|
131
146
|
console.log(
|
|
132
147
|
JSON.stringify(
|
|
@@ -141,6 +156,7 @@ async function handleCall(argv) {
|
|
|
141
156
|
summaryPath: result.summaryPath,
|
|
142
157
|
tracePath: result.tracePath,
|
|
143
158
|
artifactManifestPath: result.artifactManifestPath,
|
|
159
|
+
...callOutput !== void 0 ? { output: callOutput } : {},
|
|
144
160
|
recovered: state.recovered,
|
|
145
161
|
mutations: state.mutations,
|
|
146
162
|
exitCode: result.status === "pass" ? EXIT.ok : EXIT.runtime
|
|
@@ -150,11 +166,34 @@ async function handleCall(argv) {
|
|
|
150
166
|
)
|
|
151
167
|
);
|
|
152
168
|
} else {
|
|
153
|
-
|
|
154
|
-
|
|
169
|
+
const rendered = callOutput !== void 0 ? `
|
|
170
|
+
Result:
|
|
171
|
+
${formatCallOutput(callOutput)}` : "";
|
|
172
|
+
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
173
|
+
console.log(
|
|
174
|
+
`${out("label", "call")} ${out("cmd", resolvedAction)}: ${out(result.status === "pass" ? "ok" : "err", result.status)}${rendered ? `
|
|
175
|
+
${out("label", "Result:")}
|
|
176
|
+
${formatCallOutput(callOutput)}` : ""}
|
|
177
|
+
${out("label", "Artifacts:")} ${out("path", result.artifactManifestPath)}`
|
|
178
|
+
);
|
|
155
179
|
}
|
|
156
180
|
return result.status === "pass" ? EXIT.ok : EXIT.runtime;
|
|
157
181
|
}
|
|
182
|
+
function readCallOutput(tracePath) {
|
|
183
|
+
try {
|
|
184
|
+
const trace = JSON.parse(fs.readFileSync(tracePath, "utf8"));
|
|
185
|
+
const entries = Array.isArray(trace) ? trace : isRecord(trace) && Array.isArray(trace.entries) ? trace.entries : [];
|
|
186
|
+
const entry = entries.find((item) => isRecord(item) && item.nodeId === "call");
|
|
187
|
+
return isRecord(entry) && Object.prototype.hasOwnProperty.call(entry, "output") ? entry.output : void 0;
|
|
188
|
+
} catch {
|
|
189
|
+
return void 0;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function formatCallOutput(value) {
|
|
193
|
+
if (typeof value === "string") return value;
|
|
194
|
+
if (typeof value === "number" || typeof value === "boolean" || value === null) return String(value);
|
|
195
|
+
return JSON.stringify(value, null, 2);
|
|
196
|
+
}
|
|
158
197
|
async function handleCallHelp(argv, genericHelp) {
|
|
159
198
|
const { action: shortName, rest } = parseCallArgs(argv.filter((arg) => arg !== "--help" && arg !== "-h"));
|
|
160
199
|
const { options } = parseArgs(rest, "call");
|
|
@@ -194,13 +233,13 @@ function renderCallActionHelp(entry) {
|
|
|
194
233
|
const required = new Set(
|
|
195
234
|
Array.isArray(schema.required) ? schema.required.filter((r) => typeof r === "string") : []
|
|
196
235
|
);
|
|
197
|
-
const lines = [`mm-harness call ${entry.name} [--arg k=v ...] [flags]`, ""];
|
|
236
|
+
const lines = [`mm-harness call ${entry.name} [key=value ...] [--arg k=v ...] [flags]`, ""];
|
|
198
237
|
if (entry.description) lines.push(` ${entry.description}`, "");
|
|
199
238
|
const names = Object.keys(properties).sort();
|
|
200
239
|
if (names.length === 0) {
|
|
201
240
|
lines.push(" Fields: (none)");
|
|
202
241
|
} else {
|
|
203
|
-
lines.push(" Fields (pass
|
|
242
|
+
lines.push(" Fields (pass as <name>=<value> or --arg <name>=<value>):");
|
|
204
243
|
const width = Math.max(...names.map((name) => name.length));
|
|
205
244
|
for (const name of names) {
|
|
206
245
|
const prop = isRecord(properties[name]) ? properties[name] : {};
|
|
@@ -226,7 +265,7 @@ function renderCallExamples(short, examples) {
|
|
|
226
265
|
for (const example of examples.slice(0, 2)) {
|
|
227
266
|
const node = isRecord(example) && isRecord(example.node) ? example.node : void 0;
|
|
228
267
|
if (!node) continue;
|
|
229
|
-
const tokens = Object.entries(node).filter(([key]) => key !== "action" && key !== "intent").map(([key, value]) =>
|
|
268
|
+
const tokens = Object.entries(node).filter(([key]) => key !== "action" && key !== "intent").map(([key, value]) => `${key}=${typeof value === "string" ? value : JSON.stringify(value)}`);
|
|
230
269
|
out.push(`mm-harness call ${short} ${tokens.join(" ")}`.trim());
|
|
231
270
|
}
|
|
232
271
|
return out;
|
|
@@ -249,10 +288,44 @@ function parseCallArgs(argv) {
|
|
|
249
288
|
action = arg;
|
|
250
289
|
continue;
|
|
251
290
|
}
|
|
291
|
+
if (action !== void 0 && !isForwardedOptionValue(argv, i) && isArgPair(arg)) {
|
|
292
|
+
const eq = arg.indexOf("=");
|
|
293
|
+
args[arg.slice(0, eq)] = arg.slice(eq + 1);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
252
296
|
rest.push(arg);
|
|
253
297
|
}
|
|
254
298
|
return { action, args, rest };
|
|
255
299
|
}
|
|
300
|
+
function isArgPair(value) {
|
|
301
|
+
const eq = value.indexOf("=");
|
|
302
|
+
return eq > 0;
|
|
303
|
+
}
|
|
304
|
+
function isForwardedOptionValue(argv, index) {
|
|
305
|
+
if (index === 0) return false;
|
|
306
|
+
const previous = argv[index - 1];
|
|
307
|
+
if (!previous.startsWith("--") || previous.includes("=")) return false;
|
|
308
|
+
return VALUE_TAKING_CALL_FLAGS.has(normalizeFlagName(previous));
|
|
309
|
+
}
|
|
310
|
+
const VALUE_TAKING_CALL_FLAGS = /* @__PURE__ */ new Set([
|
|
311
|
+
"action-manifest",
|
|
312
|
+
"adapter",
|
|
313
|
+
"artifacts-dir",
|
|
314
|
+
"cdp-port",
|
|
315
|
+
"device",
|
|
316
|
+
"heal",
|
|
317
|
+
"library",
|
|
318
|
+
"metro-port",
|
|
319
|
+
"platform",
|
|
320
|
+
"project-root",
|
|
321
|
+
"slot",
|
|
322
|
+
"target",
|
|
323
|
+
"validation-runtime-dir",
|
|
324
|
+
"watcher-port"
|
|
325
|
+
]);
|
|
326
|
+
function normalizeFlagName(flag) {
|
|
327
|
+
return flag.replace(/^--/u, "");
|
|
328
|
+
}
|
|
256
329
|
function resolveActionName(shortName, names) {
|
|
257
330
|
if (names.includes(shortName)) return { status: "ok", resolved: shortName, candidates: [shortName] };
|
|
258
331
|
const finalSegment = (name) => name.split(".").pop() ?? name;
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { detectAdapter } from "../harness.js";
|
|
5
|
+
import { EXIT, usageOut } from "./shared.js";
|
|
6
|
+
import { optionFlag, optionString, parseArgs } from "./parse-args.js";
|
|
7
|
+
const USAGE = "mm-harness check diff [--target <repo>] [--adapter mobile|extension|core] [--base <ref>] [--profile fast|full] [--artifacts-dir <dir>] [--json]";
|
|
8
|
+
const JS_TS_EXT_RE = /\.(?:c|m)?jsx?$|\.tsx?$/u;
|
|
9
|
+
const FORMAT_EXT_RE = /\.(?:c|m)?jsx?$|\.tsx?$|\.json$|\.md$|\.css$|\.scss$|\.ya?ml$/u;
|
|
10
|
+
const TEST_FILE_RE = /(?:^|[./_-])(?:test|spec)\.(?:c|m)?jsx?$|(?:^|[./_-])(?:test|spec)\.tsx?$/u;
|
|
11
|
+
async function handleCheck(argv) {
|
|
12
|
+
const { positional, options } = parseArgs(argv, "check");
|
|
13
|
+
const action = positional[0];
|
|
14
|
+
const json = optionFlag(options, "json");
|
|
15
|
+
if (action !== "diff") {
|
|
16
|
+
const message = action ? `unknown action '${action}'` : "missing action";
|
|
17
|
+
return usageOut(json, "check", message, USAGE);
|
|
18
|
+
}
|
|
19
|
+
const target = path.resolve(optionString(options, "target") ?? optionString(options, "projectRoot") ?? process.cwd());
|
|
20
|
+
const explicitAdapter = optionString(options, "adapter") ?? optionString(options, "platform");
|
|
21
|
+
const detected = explicitAdapter ?? detectAdapter(target);
|
|
22
|
+
if (detected && !["mobile", "extension", "core"].includes(detected)) {
|
|
23
|
+
return usageOut(json, "check", `unsupported adapter '${detected}'`, USAGE);
|
|
24
|
+
}
|
|
25
|
+
const adapter = detected;
|
|
26
|
+
const profileInput = optionString(options, "profile");
|
|
27
|
+
if (profileInput && profileInput !== "fast" && profileInput !== "full") {
|
|
28
|
+
return usageOut(json, "check", "--profile must be fast or full", USAGE);
|
|
29
|
+
}
|
|
30
|
+
const profile = profileInput || "fast";
|
|
31
|
+
const explicitBaseRef = optionString(options, "base");
|
|
32
|
+
const base = explicitBaseRef ? { ref: explicitBaseRef, source: "flag" } : defaultBaseRef(target);
|
|
33
|
+
const baseRef = base.ref;
|
|
34
|
+
const artifactsDir = path.resolve(
|
|
35
|
+
optionString(options, "artifactsDir") ?? path.join(target, "temp", "recipe", "check-diff", timestampSlug())
|
|
36
|
+
);
|
|
37
|
+
fs.mkdirSync(artifactsDir, { recursive: true });
|
|
38
|
+
if (!isGitRepo(target)) {
|
|
39
|
+
return failEnvelope({
|
|
40
|
+
json,
|
|
41
|
+
adapter,
|
|
42
|
+
target,
|
|
43
|
+
profile,
|
|
44
|
+
baseRef,
|
|
45
|
+
artifactsDir,
|
|
46
|
+
message: `${target} is not a git checkout`,
|
|
47
|
+
code: "CHECK_DIFF_NOT_GIT"
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const packageJson = readPackageJson(target);
|
|
51
|
+
const mergeBase = resolveMergeBase(target, baseRef);
|
|
52
|
+
const changedFiles = listChangedFiles(target, mergeBase, artifactsDir);
|
|
53
|
+
const existingChangedFiles = changedFiles.filter((file) => fs.existsSync(path.join(target, file)));
|
|
54
|
+
const checks = [];
|
|
55
|
+
checks.push(
|
|
56
|
+
runToolCheck({
|
|
57
|
+
id: "eslint",
|
|
58
|
+
label: "Changed-file ESLint",
|
|
59
|
+
target,
|
|
60
|
+
artifactsDir,
|
|
61
|
+
files: existingChangedFiles.filter((file) => JS_TS_EXT_RE.test(file)),
|
|
62
|
+
command: executable(target, "eslint"),
|
|
63
|
+
args: ["--cache", "--cache-location", path.join(artifactsDir, "eslintcache"), "--max-warnings=0", "--no-warn-ignored"]
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
checks.push(
|
|
67
|
+
runToolCheck({
|
|
68
|
+
id: "prettier",
|
|
69
|
+
label: "Changed-file Prettier",
|
|
70
|
+
target,
|
|
71
|
+
artifactsDir,
|
|
72
|
+
files: existingChangedFiles.filter((file) => FORMAT_EXT_RE.test(file)),
|
|
73
|
+
command: executable(target, "prettier"),
|
|
74
|
+
args: ["--check"]
|
|
75
|
+
})
|
|
76
|
+
);
|
|
77
|
+
checks.push(
|
|
78
|
+
runToolCheck({
|
|
79
|
+
id: "jest",
|
|
80
|
+
label: "Changed test files",
|
|
81
|
+
target,
|
|
82
|
+
artifactsDir,
|
|
83
|
+
files: existingChangedFiles.filter((file) => TEST_FILE_RE.test(file)),
|
|
84
|
+
command: executable(target, "jest"),
|
|
85
|
+
args: ["--no-coverage"]
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
if (profile === "full") {
|
|
89
|
+
checks.push(runTypecheck({ target, artifactsDir, packageJson }));
|
|
90
|
+
} else {
|
|
91
|
+
checks.push({
|
|
92
|
+
id: "typecheck",
|
|
93
|
+
label: "TypeScript project check",
|
|
94
|
+
status: "skip",
|
|
95
|
+
reason: "profile=fast; run with --profile full for repo-wide typecheck"
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const status = checks.some((check) => check.status === "fail") ? "fail" : "pass";
|
|
99
|
+
const exitCode = status === "pass" ? EXIT.ok : EXIT.validation;
|
|
100
|
+
const summary = {
|
|
101
|
+
schemaVersion: 1,
|
|
102
|
+
command: "check",
|
|
103
|
+
action: "diff",
|
|
104
|
+
status,
|
|
105
|
+
exitCode,
|
|
106
|
+
adapter: adapter ?? null,
|
|
107
|
+
target,
|
|
108
|
+
profile,
|
|
109
|
+
baseRef,
|
|
110
|
+
baseSource: base.source,
|
|
111
|
+
baseLabel: base.label ?? null,
|
|
112
|
+
mergeBase,
|
|
113
|
+
changedFiles,
|
|
114
|
+
checks,
|
|
115
|
+
artifacts: {
|
|
116
|
+
dir: artifactsDir,
|
|
117
|
+
summaryJson: path.join(artifactsDir, "validation-summary.json"),
|
|
118
|
+
summaryMarkdown: path.join(artifactsDir, "validation-summary.md")
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
writeSummaryArtifacts(summary);
|
|
122
|
+
if (json) console.log(JSON.stringify(summary, null, 2));
|
|
123
|
+
else renderHuman(summary);
|
|
124
|
+
return exitCode;
|
|
125
|
+
}
|
|
126
|
+
function timestampSlug() {
|
|
127
|
+
return (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/gu, "").replace(/\.\d{3}Z$/u, "Z");
|
|
128
|
+
}
|
|
129
|
+
function isGitRepo(target) {
|
|
130
|
+
return runCommand("git", ["rev-parse", "--is-inside-work-tree"], target).status === 0;
|
|
131
|
+
}
|
|
132
|
+
function defaultBaseRef(target) {
|
|
133
|
+
const prBase = githubPullRequestBase(target);
|
|
134
|
+
if (prBase) return prBase;
|
|
135
|
+
const remoteHead = runCommand("git", ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], target).stdout.trim();
|
|
136
|
+
if (remoteHead && verifyRef(target, remoteHead)) return { ref: remoteHead, source: "remote-head" };
|
|
137
|
+
for (const ref of ["origin/develop", "origin/main", "develop", "main", "HEAD"]) {
|
|
138
|
+
if (verifyRef(target, ref)) return { ref, source: "fallback" };
|
|
139
|
+
}
|
|
140
|
+
return { ref: "", source: "fallback" };
|
|
141
|
+
}
|
|
142
|
+
function githubPullRequestBase(target) {
|
|
143
|
+
const result = runCommand("gh", ["pr", "view", "--json", "baseRefName", "--jq", ".baseRefName"], target);
|
|
144
|
+
if (result.status !== 0) return null;
|
|
145
|
+
const baseName = result.stdout.trim();
|
|
146
|
+
if (!baseName) return null;
|
|
147
|
+
for (const ref of [`origin/${baseName}`, `upstream/${baseName}`, baseName]) {
|
|
148
|
+
if (verifyRef(target, ref)) return { ref, source: "github-pr", label: baseName };
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
function verifyRef(target, ref) {
|
|
153
|
+
return runCommand("git", ["rev-parse", "--verify", ref], target).status === 0;
|
|
154
|
+
}
|
|
155
|
+
function resolveMergeBase(target, baseRef) {
|
|
156
|
+
if (!baseRef) return null;
|
|
157
|
+
const result = runCommand("git", ["merge-base", "HEAD", baseRef], target);
|
|
158
|
+
if (result.status === 0) return result.stdout.trim() || null;
|
|
159
|
+
const verify = runCommand("git", ["rev-parse", "--verify", baseRef], target);
|
|
160
|
+
return verify.status === 0 ? verify.stdout.trim() || null : null;
|
|
161
|
+
}
|
|
162
|
+
function listChangedFiles(target, mergeBase, artifactsDir) {
|
|
163
|
+
const files = /* @__PURE__ */ new Set();
|
|
164
|
+
const addLines = (output) => {
|
|
165
|
+
for (const line of output.split("\n")) {
|
|
166
|
+
const file = line.trim();
|
|
167
|
+
if (file) files.add(file);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
if (mergeBase) {
|
|
171
|
+
addLines(runCommand("git", ["diff", "--name-only", "--diff-filter=ACMR", `${mergeBase}...HEAD`], target).stdout);
|
|
172
|
+
}
|
|
173
|
+
addLines(runCommand("git", ["diff", "--name-only", "--diff-filter=ACMR", "HEAD"], target).stdout);
|
|
174
|
+
addLines(runCommand("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR"], target).stdout);
|
|
175
|
+
addLines(runCommand("git", ["ls-files", "--others", "--exclude-standard"], target).stdout);
|
|
176
|
+
return [...files].filter((file) => !isUnderArtifactsDir(target, artifactsDir, file)).sort();
|
|
177
|
+
}
|
|
178
|
+
function isUnderArtifactsDir(target, artifactsDir, file) {
|
|
179
|
+
const artifactRel = normalizeRelativePath(path.relative(path.resolve(target), path.resolve(artifactsDir)));
|
|
180
|
+
if (!artifactRel || artifactRel.startsWith("../") || artifactRel === "..") return false;
|
|
181
|
+
const normalized = normalizeRelativePath(file);
|
|
182
|
+
return normalized === artifactRel || normalized.startsWith(`${artifactRel}/`);
|
|
183
|
+
}
|
|
184
|
+
function normalizeRelativePath(value) {
|
|
185
|
+
return value.split(path.sep).join("/");
|
|
186
|
+
}
|
|
187
|
+
function executable(target, name) {
|
|
188
|
+
const local = path.join(target, "node_modules", ".bin", name);
|
|
189
|
+
return fs.existsSync(local) ? local : null;
|
|
190
|
+
}
|
|
191
|
+
function runToolCheck(input) {
|
|
192
|
+
if (input.files.length === 0) {
|
|
193
|
+
return { id: input.id, label: input.label, status: "skip", reason: "no changed matching files" };
|
|
194
|
+
}
|
|
195
|
+
if (!input.command) {
|
|
196
|
+
return { id: input.id, label: input.label, status: "skip", reason: `${input.id} binary not found in node_modules/.bin` };
|
|
197
|
+
}
|
|
198
|
+
return runCheckCommand(input.id, input.label, input.target, input.artifactsDir, input.command, [...input.args, ...input.files]);
|
|
199
|
+
}
|
|
200
|
+
function runTypecheck(input) {
|
|
201
|
+
const scripts = input.packageJson?.scripts && typeof input.packageJson.scripts === "object" ? input.packageJson.scripts : {};
|
|
202
|
+
if (typeof scripts["lint:tsc"] === "string") {
|
|
203
|
+
return runCheckCommand("typecheck", "TypeScript project check", input.target, input.artifactsDir, "yarn", ["lint:tsc"]);
|
|
204
|
+
}
|
|
205
|
+
const tsc = executable(input.target, "tsc");
|
|
206
|
+
if (tsc) {
|
|
207
|
+
return runCheckCommand("typecheck", "TypeScript project check", input.target, input.artifactsDir, tsc, ["--noEmit"]);
|
|
208
|
+
}
|
|
209
|
+
return { id: "typecheck", label: "TypeScript project check", status: "skip", reason: "no lint:tsc script or tsc binary found" };
|
|
210
|
+
}
|
|
211
|
+
function runCheckCommand(id, label, target, artifactsDir, command, args) {
|
|
212
|
+
const result = runCommand(command, args, target);
|
|
213
|
+
const logPath = path.join(artifactsDir, `${id}.log`);
|
|
214
|
+
fs.writeFileSync(logPath, renderCommandLog(command, args, result));
|
|
215
|
+
return {
|
|
216
|
+
id,
|
|
217
|
+
label,
|
|
218
|
+
status: result.status === 0 ? "pass" : "fail",
|
|
219
|
+
command: [command, ...args].join(" "),
|
|
220
|
+
exitCode: result.status,
|
|
221
|
+
logPath,
|
|
222
|
+
durationMs: result.durationMs
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function runCommand(command, args, cwd) {
|
|
226
|
+
const started = Date.now();
|
|
227
|
+
const result = spawnSync(command, args, {
|
|
228
|
+
cwd,
|
|
229
|
+
encoding: "utf8",
|
|
230
|
+
env: process.env,
|
|
231
|
+
maxBuffer: 64 * 1024 * 1024
|
|
232
|
+
});
|
|
233
|
+
const status = result.error ? 127 : result.status ?? 1;
|
|
234
|
+
return {
|
|
235
|
+
status,
|
|
236
|
+
stdout: result.stdout ?? "",
|
|
237
|
+
stderr: result.error ? String(result.error) : result.stderr ?? "",
|
|
238
|
+
durationMs: Date.now() - started
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function renderCommandLog(command, args, result) {
|
|
242
|
+
return [
|
|
243
|
+
`$ ${[command, ...args].join(" ")}`,
|
|
244
|
+
`exit: ${result.status}`,
|
|
245
|
+
`duration_ms: ${result.durationMs}`,
|
|
246
|
+
"",
|
|
247
|
+
"--- stdout ---",
|
|
248
|
+
result.stdout.trimEnd(),
|
|
249
|
+
"",
|
|
250
|
+
"--- stderr ---",
|
|
251
|
+
result.stderr.trimEnd(),
|
|
252
|
+
""
|
|
253
|
+
].join("\n");
|
|
254
|
+
}
|
|
255
|
+
function readPackageJson(target) {
|
|
256
|
+
try {
|
|
257
|
+
return JSON.parse(fs.readFileSync(path.join(target, "package.json"), "utf8"));
|
|
258
|
+
} catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function writeSummaryArtifacts(summary) {
|
|
263
|
+
fs.writeFileSync(summary.artifacts.summaryJson, `${JSON.stringify(summary, null, 2)}
|
|
264
|
+
`);
|
|
265
|
+
fs.writeFileSync(summary.artifacts.summaryMarkdown, renderMarkdown(summary));
|
|
266
|
+
}
|
|
267
|
+
function renderMarkdown(summary) {
|
|
268
|
+
const lines = [
|
|
269
|
+
"# mm-harness check diff",
|
|
270
|
+
"",
|
|
271
|
+
`Verdict: ${summary.status}`,
|
|
272
|
+
`Profile: ${summary.profile}`,
|
|
273
|
+
`Base: ${summary.baseRef || "(none)"} (${summary.baseSource || "unknown"}${summary.baseLabel ? `: ${summary.baseLabel}` : ""})`,
|
|
274
|
+
`Changed files: ${summary.changedFiles.length}`,
|
|
275
|
+
"",
|
|
276
|
+
"## Checks",
|
|
277
|
+
""
|
|
278
|
+
];
|
|
279
|
+
for (const check of summary.checks) {
|
|
280
|
+
lines.push(`- ${check.status.toUpperCase()} ${check.id}${check.reason ? ` \u2014 ${check.reason}` : ""}${check.logPath ? ` (${check.logPath})` : ""}`);
|
|
281
|
+
}
|
|
282
|
+
lines.push("", "## Changed Files", "");
|
|
283
|
+
for (const file of summary.changedFiles) lines.push(`- ${file}`);
|
|
284
|
+
return `${lines.join("\n")}
|
|
285
|
+
`;
|
|
286
|
+
}
|
|
287
|
+
function renderHuman(summary) {
|
|
288
|
+
const mark = summary.status === "pass" ? "\u2713" : "\u2717";
|
|
289
|
+
const baseText = `${summary.baseRef || "(none)"}${summary.baseSource ? ` [${summary.baseSource}]` : ""}`;
|
|
290
|
+
console.log(`${mark} check diff: ${summary.status} \xB7 ${summary.changedFiles.length} changed file(s) \xB7 profile=${summary.profile} \xB7 base=${baseText}`);
|
|
291
|
+
if (summary.changedFiles.length > 0) {
|
|
292
|
+
console.log("Changed files:");
|
|
293
|
+
for (const file of summary.changedFiles.slice(0, 100)) console.log(` - ${file}`);
|
|
294
|
+
if (summary.changedFiles.length > 100) console.log(` ... ${summary.changedFiles.length - 100} more; full list in validation-summary.json`);
|
|
295
|
+
}
|
|
296
|
+
for (const check of summary.checks) {
|
|
297
|
+
const prefix = check.status === "pass" ? "\u2713" : check.status === "fail" ? "\u2717" : "-";
|
|
298
|
+
console.log(` ${prefix} ${check.id}: ${check.status}${check.reason ? ` (${check.reason})` : ""}`);
|
|
299
|
+
if (check.status === "fail" && check.logPath) console.log(` log: ${check.logPath}`);
|
|
300
|
+
}
|
|
301
|
+
console.log(`Artifacts: ${summary.artifacts.summaryJson}`);
|
|
302
|
+
}
|
|
303
|
+
function failEnvelope(input) {
|
|
304
|
+
const exitCode = EXIT.usage;
|
|
305
|
+
const body = {
|
|
306
|
+
schemaVersion: 1,
|
|
307
|
+
command: "check",
|
|
308
|
+
action: "diff",
|
|
309
|
+
status: "fail",
|
|
310
|
+
exitCode,
|
|
311
|
+
adapter: input.adapter ?? null,
|
|
312
|
+
target: input.target,
|
|
313
|
+
profile: input.profile,
|
|
314
|
+
baseRef: input.baseRef,
|
|
315
|
+
baseSource: "unknown",
|
|
316
|
+
artifacts: { dir: input.artifactsDir },
|
|
317
|
+
error: { code: input.code, message: input.message }
|
|
318
|
+
};
|
|
319
|
+
if (input.json) console.log(JSON.stringify(body, null, 2));
|
|
320
|
+
else console.error(`\u2717 check diff: ${input.message}
|
|
321
|
+
Next: ${USAGE}`);
|
|
322
|
+
return exitCode;
|
|
323
|
+
}
|
|
324
|
+
export {
|
|
325
|
+
handleCheck
|
|
326
|
+
};
|