@deeeed/metamask-harness 0.26.5 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.28.0 - 2026-07-31
6
+
7
+ ### Added
8
+
9
+ - Add `actions --matrix` and adapter-aware action refusals that name missing capabilities and list the adapters that satisfy them.
10
+ - Add `ui.locators` for ranked, copy-pasteable recipe targets derived from the current Extension or Mobile `ui.visible` observation.
11
+
12
+ ## 0.27.0 - 2026-07-31
13
+
14
+ ### Changed
15
+
16
+ - Mobile UI actions now record bounded native-hierarchy observations, while defaulted Extension UI actions record screen and visible observations; Extension screenshots and recordings publish through symlink-safe private staging as regular `0600` files, Mobile launch and diagnostics resolve absolute device-tool paths, and iOS preparation enables application accessibility before relaunching.
17
+
18
+ ### Fixed
19
+
20
+ - Serve analytics capture on both IPv4 and IPv6 loopback so Mobile clients resolving `localhost` to `::1` can reach the collector without exposing it on external interfaces.
21
+
5
22
  ## 0.26.5 - 2026-07-31
6
23
 
7
24
  ### Fixed
@@ -25,6 +25,7 @@ PLATFORM="ios"
25
25
  TARGET="$PWD"
26
26
  SIMULATOR="${IOS_SIMULATOR:-${SIM_UDID:-booted}}"
27
27
  ADB_SERIAL_ARG="${ADB_SERIAL:-${ANDROID_SERIAL:-}}"
28
+ ADB_BIN="${MM_HARNESS_ADB_PATH:-}"
28
29
  PREFLIGHT_MODE="${MOBILE_PREFLIGHT_MODE:-fast}"
29
30
  PORT="${WATCHER_PORT:-${METRO_PORT:-8081}}"
30
31
  UI_ONLY=false
@@ -54,6 +55,17 @@ case "$PLATFORM" in
54
55
  ios|android) ;;
55
56
  *) printf 'open-device: --platform must be ios or android (got: %s)\n' "$PLATFORM" >&2; exit 2 ;;
56
57
  esac
58
+ if [ "$PLATFORM" = "android" ]; then
59
+ if [ -z "$ADB_BIN" ]; then
60
+ ADB_BIN="$(command -v adb 2>/dev/null || true)"
61
+ fi
62
+ if [ -z "$ADB_BIN" ] || [ "${ADB_BIN#/}" = "$ADB_BIN" ] || [ ! -x "$ADB_BIN" ]; then
63
+ printf 'open-device: Android SDK Platform-Tools (adb) is required but no executable was found.\n' >&2
64
+ printf 'Next: brew install android-platform-tools\n' >&2
65
+ exit 1
66
+ fi
67
+ export MM_HARNESS_ADB_PATH="$ADB_BIN"
68
+ fi
57
69
  if $UI_ONLY && [ "$PLATFORM" != "ios" ]; then
58
70
  printf 'open-device: --ui-only is supported only for ios.\n' >&2
59
71
  exit 2
@@ -207,7 +219,7 @@ focus_simulator() {
207
219
  android_app_installed() {
208
220
  local pkg="$1"
209
221
  shift
210
- adb "$@" shell pm list packages 2>/dev/null | grep -q "package:${pkg}"
222
+ "$ADB_BIN" "$@" shell pm list packages 2>/dev/null | grep -q "package:${pkg}"
211
223
  }
212
224
 
213
225
  run_android_native_build() {
@@ -315,14 +327,14 @@ else
315
327
  [ -n "$ADB_SERIAL_ARG" ] && ADB_ARGS+=("-s" "$ADB_SERIAL_ARG")
316
328
 
317
329
  # Reverse-tunnel Metro port so the device can reach localhost:PORT.
318
- adb "${ADB_ARGS[@]}" reverse "tcp:${PORT}" "tcp:${PORT}" >/dev/null 2>&1 || \
330
+ "$ADB_BIN" "${ADB_ARGS[@]}" reverse "tcp:${PORT}" "tcp:${PORT}" >/dev/null 2>&1 || \
319
331
  printf 'open-device: adb reverse failed (non-fatal — device may already be reachable)\n' >&2
320
332
 
321
333
  LAUNCHED=false
322
334
  for PKG in "${ANDROID_PKGS[@]}"; do
323
335
  ensure_android_app_for_mode "$PKG" "$PREFLIGHT_MODE" "${ADB_ARGS[@]}" || continue
324
336
  if $RESTART; then
325
- adb "${ADB_ARGS[@]}" shell am force-stop "$PKG" >/dev/null 2>&1 || true
337
+ "$ADB_BIN" "${ADB_ARGS[@]}" shell am force-stop "$PKG" >/dev/null 2>&1 || true
326
338
  printf 'Restarting Android dev client %s\n' "$PKG" >&2
327
339
  fi
328
340
 
@@ -331,7 +343,7 @@ else
331
343
  DEEP_LINK="expo-metamask://expo-development-client/?url=${ENCODED_URL}"
332
344
 
333
345
  printf 'Launching Android dev client %s\n' "$PKG" >&2
334
- if adb "${ADB_ARGS[@]}" shell am start \
346
+ if "$ADB_BIN" "${ADB_ARGS[@]}" shell am start \
335
347
  -a android.intent.action.VIEW \
336
348
  -d "$DEEP_LINK" >/dev/null 2>&1; then
337
349
  LAUNCHED=true
@@ -1,5 +1,7 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import path from "node:path";
2
3
  import { recordDepsBaseline } from "@farmslot/recipe-harness/runtime/deps-readiness";
4
+ import { resolveAndExportMobileToolPaths } from "../../../library/actions/mobile/platform/tool-paths.mjs";
3
5
  import { EXIT, spawnScriptStreaming } from "../../commands/shared.js";
4
6
  import { runnerDir } from "../../paths.js";
5
7
  import {
@@ -33,6 +35,14 @@ async function prepareMobile(target, opts = {}) {
33
35
  const preflightMode = opts.preflightMode ?? "fast";
34
36
  const clearMetro = opts.clearMetro ?? false;
35
37
  const restartApp = opts.restartApp ?? false;
38
+ try {
39
+ resolveAndExportMobileToolPaths(platform);
40
+ } catch (error) {
41
+ const message = error instanceof Error ? error.message : String(error);
42
+ if (!json) process.stderr.write(`${message}
43
+ `);
44
+ return { status: EXIT.runtime, output: message };
45
+ }
36
46
  const report = await decideMobileReadiness(target, {
37
47
  watcherPort: opts.watcherPort,
38
48
  metroLog: opts.metroLog,
@@ -49,8 +59,9 @@ async function prepareMobile(target, opts = {}) {
49
59
  `);
50
60
  return { status: EXIT.runtime, output: msg };
51
61
  }
62
+ const iosAccessibilityChanged = platform === "ios" && report.decision !== "unknown" && enableIosAccessibility(json);
52
63
  if (report.decision === "ready") {
53
- if (restartApp) {
64
+ if (restartApp || iosAccessibilityChanged) {
54
65
  const launch2 = await dispatchActionSequence(
55
66
  withAppRestart(launchActions(path.resolve(target)), true),
56
67
  target,
@@ -91,7 +102,10 @@ async function prepareMobile(target, opts = {}) {
91
102
  );
92
103
  }
93
104
  const launch = await dispatchActionSequence(
94
- withAppRestart(launchActions(path.resolve(target)), restartApp),
105
+ withAppRestart(
106
+ launchActions(path.resolve(target)),
107
+ restartApp || iosAccessibilityChanged
108
+ ),
95
109
  target,
96
110
  platform,
97
111
  json,
@@ -110,7 +124,7 @@ async function prepareMobile(target, opts = {}) {
110
124
  clearMetro ? report.actions.map(
111
125
  (a) => a.id === "start-metro" && !a.argv?.includes("--clear") ? { ...a, argv: [...a.argv ?? [], "--clear"] } : a
112
126
  ) : report.actions,
113
- restartApp
127
+ restartApp || iosAccessibilityChanged
114
128
  );
115
129
  const actionResult = await dispatchActionSequence(
116
130
  actions,
@@ -158,7 +172,10 @@ async function prepareMobile(target, opts = {}) {
158
172
  }
159
173
  case "launch": {
160
174
  const result = await dispatchActionSequence(
161
- withAppRestart(postInstall.actions, restartApp),
175
+ withAppRestart(
176
+ postInstall.actions,
177
+ restartApp || iosAccessibilityChanged
178
+ ),
162
179
  target,
163
180
  platform,
164
181
  json,
@@ -240,6 +257,99 @@ async function dispatchActionSequence(requestedActions, target, platform, json,
240
257
  }
241
258
  return { status: 0, output: "" };
242
259
  }
260
+ function enableIosAccessibility(json) {
261
+ const simulator = process.env.SIM_UDID || process.env.IOS_SIMULATOR || "booted";
262
+ const defaultsArgs = [
263
+ "simctl",
264
+ "spawn",
265
+ simulator,
266
+ "defaults"
267
+ ];
268
+ try {
269
+ execFileSync("xcrun", ["simctl", "boot", simulator], {
270
+ stdio: "ignore",
271
+ timeout: 15e3
272
+ });
273
+ } catch {
274
+ }
275
+ try {
276
+ execFileSync("xcrun", ["simctl", "bootstatus", simulator, "-b"], {
277
+ stdio: "ignore",
278
+ timeout: 12e4
279
+ });
280
+ } catch (error) {
281
+ if (!json) {
282
+ process.stderr.write(
283
+ `mobile prepare: selected iOS simulator was unavailable (${error instanceof Error ? error.message : String(error)}); continuing with native-view fallback.
284
+ `
285
+ );
286
+ }
287
+ return false;
288
+ }
289
+ try {
290
+ const current = execFileSync(
291
+ "xcrun",
292
+ [...defaultsArgs, "read", "com.apple.Accessibility", "ApplicationAccessibilityEnabled"],
293
+ {
294
+ encoding: "utf8",
295
+ stdio: ["ignore", "pipe", "ignore"],
296
+ timeout: 5e3
297
+ }
298
+ ).trim().toLowerCase();
299
+ if (current === "1" || current === "true" || current === "yes") return false;
300
+ } catch {
301
+ }
302
+ try {
303
+ execFileSync(
304
+ "xcrun",
305
+ [
306
+ ...defaultsArgs,
307
+ "write",
308
+ "com.apple.Accessibility",
309
+ "ApplicationAccessibilityEnabled",
310
+ "-bool",
311
+ "YES"
312
+ ],
313
+ {
314
+ stdio: "ignore",
315
+ timeout: 5e3
316
+ }
317
+ );
318
+ try {
319
+ execFileSync("xcrun", ["simctl", "shutdown", simulator], {
320
+ stdio: "ignore",
321
+ timeout: 15e3
322
+ });
323
+ } catch {
324
+ }
325
+ try {
326
+ execFileSync("xcrun", ["simctl", "boot", simulator], {
327
+ stdio: "ignore",
328
+ timeout: 15e3
329
+ });
330
+ } catch {
331
+ }
332
+ execFileSync("xcrun", ["simctl", "bootstatus", simulator, "-b"], {
333
+ stdio: "ignore",
334
+ timeout: 12e4
335
+ });
336
+ if (!json) {
337
+ process.stderr.write(
338
+ `Enabled iOS application accessibility on ${simulator}; restarted the simulator before app launch.
339
+ `
340
+ );
341
+ }
342
+ return true;
343
+ } catch (error) {
344
+ if (!json) {
345
+ process.stderr.write(
346
+ `mobile prepare: iOS accessibility enablement was unavailable (${error instanceof Error ? error.message : String(error)}); continuing with native-view fallback.
347
+ `
348
+ );
349
+ }
350
+ return false;
351
+ }
352
+ }
243
353
  async function dispatchAction(action, target, platform, json, preflightMode = "fast", watcherPort) {
244
354
  const cwd = action.cwd ?? target;
245
355
  switch (action.id) {
@@ -13,6 +13,7 @@ import {
13
13
  } from "@farmslot/recipe-harness/runtime/log-analysis";
14
14
  import { probeMetroPackager } from "@farmslot/recipe-harness/runtime/metro-probe";
15
15
  import { recipeRuntimePath } from "../../paths.js";
16
+ import { resolveMobileToolPath } from "../../../library/actions/mobile/platform/tool-paths.mjs";
16
17
  import { mobileProductMarkers } from "./deps-markers.js";
17
18
  import { applyMobileMetroEnvPolicy, mobileMetroEnvCheck } from "./metro-env.js";
18
19
  const BUNDLE_ERR = /Bundling failed|Unable to resolve /u;
@@ -66,9 +67,11 @@ const installActions = (target) => [
66
67
  function appRunningOnDevice(platform) {
67
68
  try {
68
69
  if (platform === "android") {
70
+ const adbPath = resolveMobileToolPath("adb");
71
+ if (!adbPath) return false;
69
72
  const serial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL;
70
73
  const args = serial ? ["-s", serial] : [];
71
- const out2 = execFileSync("adb", [...args, "shell", "ps", "-A"], {
74
+ const out2 = execFileSync(adbPath, [...args, "shell", "ps", "-A"], {
72
75
  encoding: "utf8",
73
76
  stdio: ["ignore", "pipe", "ignore"],
74
77
  timeout: 5e3
package/dist/adapters.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  } from "./live-adapter-contract.js";
6
6
  import { withExtensionPage } from "../library/actions/extension/platform/cdp.mjs";
7
7
  import { bridgeCommand, evalSync, MOBILE_BRIDGE_ERROR_CODES, selectBridgeStatusEntry, simulatorScreenshot } from "../library/actions/mobile/platform/bridge.mjs";
8
+ import { observeNativeUi } from "../library/actions/mobile/platform/observe-ui.mjs";
8
9
  function sleep(ms) {
9
10
  return new Promise((resolve) => setTimeout(resolve, ms));
10
11
  }
@@ -276,7 +277,8 @@ const MOBILE_BRIDGE_HANDLERS = {
276
277
  setInput: handleMobileSetInput,
277
278
  scroll: handleMobileScroll,
278
279
  waitFor: handleMobileWaitFor,
279
- hud: handleMobileHud
280
+ hud: handleMobileHud,
281
+ observeUi: handleMobileObserveUi
280
282
  };
281
283
  function createMetaMaskMobileBridge() {
282
284
  return {
@@ -302,6 +304,9 @@ async function handleMobileScreenshot(payload, context) {
302
304
  async function handleMobileStatus(payload, context) {
303
305
  return bridgeCommand(mobileUiInput(context, "status", payload), ["status"]);
304
306
  }
307
+ async function handleMobileObserveUi(payload, context) {
308
+ return observeNativeUi(payload, context);
309
+ }
305
310
  async function handleMobileNavigate(payload, context) {
306
311
  const live = await runLiveAdapterScript({
307
312
  platform: "mobile",
@@ -496,7 +501,8 @@ function createMetaMaskUiTransport(platform, harness, preparedLiveAdapters) {
496
501
  );
497
502
  }
498
503
  });
499
- const transport = {
504
+ return {
505
+ ...base.observe ? { observe: base.observe.bind(base) } : {},
500
506
  async execute(action, node, context) {
501
507
  if (action === "ui.navigate") {
502
508
  const live = await runLiveFirst(
@@ -513,10 +519,6 @@ function createMetaMaskUiTransport(platform, harness, preparedLiveAdapters) {
513
519
  return base.execute(action, action === "ui.wait_for" ? normalizeUiWaitNode(node) : node, context);
514
520
  }
515
521
  };
516
- if (base.observe) {
517
- transport.observe = (refs, node, context) => base.observe(refs, node, context);
518
- }
519
- return transport;
520
522
  }
521
523
  function normalizeUiWaitNode(node) {
522
524
  if (node.expected !== void 0 || typeof node.visible !== "boolean") return node;
@@ -15,7 +15,7 @@ const SPEC = {
15
15
  { name: "logs", aliases: ["tail"], desc: "Compact build events or full log", flags: ["--full", "-f", "--window", "--events", "--source", "--json"] },
16
16
  { name: "debug", aliases: ["devtools", "inspect"], desc: "Open DevTools UI", flags: ["--json", "--no-open"] },
17
17
  { name: "fixtures", desc: "Manage the canonical wallet fixture (sync/set/generate)", args: ["sync", "set", "generate"], flags: ["--fixture", "--out", "--adapter", "--target", "--device", "--json"] },
18
- { name: "actions", desc: "List runnable recipe actions", flags: ["--json", "--categories", "--category", "--action", "--library"] },
18
+ { name: "actions", desc: "List runnable recipe actions", flags: ["--json", "--matrix", "--categories", "--category", "--action", "--library"] },
19
19
  { name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--print-ready", "--cdp-port", "--device"] },
20
20
  { name: "run", desc: "Execute a proof recipe (path or library name, e.g. run perps.smoke)", args: ["recipe.json|name"], flags: ["--list", "--device"] },
21
21
  { name: "execution-template", desc: "Discover and materialize shared agent checklists", args: ["list", "materialize", "lint", "new"], flags: ["--dir", "--domain-dir", "--project-worker", "--project-name", "--package-templates", "--package-id", "--flow", "--run-mode", "--platform", "--domain", "--id", "--provenance", "--include-shadowed", "--no-include-shadowed", "--title", "--force", "--json"] },
@@ -117,6 +117,7 @@ const PUBLIC_COMMAND_CONTRACTS = {
117
117
  actions: {
118
118
  options: options(HELP, JSON, TARGET, ADAPTER, ADAPTER_OR_MOBILE_PLATFORM, {
119
119
  "--action": value(),
120
+ "--matrix": bool(),
120
121
  "--categories": bool(),
121
122
  "--category": value(),
122
123
  "--action-manifest": value(),
@@ -6,7 +6,14 @@ import { importRecipeProtocol } from "../paths.js";
6
6
  import { conciseFailureForHuman, recipeRunning } from "../heal-bounds.js";
7
7
  import { color } from "../cli-color.js";
8
8
  import { checkoutBusyOut, EXIT, usageOut } from "./shared.js";
9
- import { actionExampleCommand, describeManifestActions, fuzzyResolveActions } from "./manifest.js";
9
+ import {
10
+ actionLibraryContextArgs,
11
+ actionExampleCommand,
12
+ describeManifestActions,
13
+ fuzzyResolveActions,
14
+ resolveActionCapabilityMatrix,
15
+ resolveActionCapabilityRefusal
16
+ } from "./manifest.js";
10
17
  import {
11
18
  parseArgs,
12
19
  isRecord,
@@ -112,6 +119,36 @@ async function handleCall(argv) {
112
119
  const names = getRecipeActionManifestActionNames(manifest);
113
120
  const resolution = resolveActionName(shortName, names);
114
121
  if (resolution.status === "unknown") {
122
+ const refusal = actionManifestOverride ? void 0 : resolveActionCapabilityRefusal(
123
+ shortName,
124
+ adapter,
125
+ await resolveActionCapabilityMatrix(librarySources)
126
+ );
127
+ if (refusal) {
128
+ const satisfying = refusal.satisfyingAdapters.join(", ");
129
+ const message2 = `missing action capability "${refusal.capability}" for the ${adapter} adapter.`;
130
+ const userAction2 = `Satisfying adapters for "${refusal.capability}": ${satisfying}. Inspect: mm-harness actions --matrix --action ${shellQuote(refusal.capability)}${actionLibraryContextArgs(librarySources)} --json`;
131
+ const error = {
132
+ code: "ACTION_CAPABILITY_UNAVAILABLE",
133
+ message: message2,
134
+ capability: refusal.capability,
135
+ satisfyingAdapters: refusal.satisfyingAdapters,
136
+ userAction: userAction2
137
+ };
138
+ if (json) {
139
+ console.log(JSON.stringify({
140
+ schemaVersion: 1,
141
+ command: "call",
142
+ adapter,
143
+ action: shortName,
144
+ error
145
+ }, null, 2));
146
+ } else {
147
+ console.error(`\u2717 call: ${message2}
148
+ Next: ${userAction2}`);
149
+ }
150
+ return EXIT.usage;
151
+ }
115
152
  const message = `unknown action "${shortName}" for the ${adapter} adapter.`;
116
153
  const userAction = `mm-harness actions --adapter ${adapter} --json`;
117
154
  if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, action: shortName, error: { code: "ACTION_UNKNOWN", message, userAction } }, null, 2));
@@ -1,12 +1,15 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { resolveMobileToolPath } from "../../library/actions/mobile/platform/tool-paths.mjs";
2
3
  import { listConnectedDevices } from "../devices.js";
3
4
  import { optionString, shellQuoteArg, targetPath } from "./parse-args.js";
4
5
  function normalizeDeviceName(value) {
5
6
  return value.replace(/_/gu, " ").trim();
6
7
  }
7
8
  function resolveAndroidModel(serial) {
9
+ const adbPath = resolveMobileToolPath("adb");
10
+ if (!adbPath) return void 0;
8
11
  try {
9
- const output = execFileSync("adb", ["-s", serial, "shell", "getprop", "ro.product.model"], {
12
+ const output = execFileSync(adbPath, ["-s", serial, "shell", "getprop", "ro.product.model"], {
10
13
  encoding: "utf8",
11
14
  stdio: ["ignore", "pipe", "ignore"],
12
15
  timeout: 5e3
@@ -131,7 +131,31 @@ async function handleDoctor({ options }) {
131
131
  }
132
132
  return status === "pass" ? 0 : 1;
133
133
  }
134
- const result = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
134
+ const mobilePlatform = adapter === "mobile" && (platformOption === "ios" || platformOption === "android") ? platformOption : void 0;
135
+ const result = createDoctorReport(
136
+ adapter,
137
+ target,
138
+ manifestValidation,
139
+ actionManifestPath,
140
+ mobilePlatform
141
+ );
142
+ const missingRequiredTool = result.checks.find(
143
+ (check) => check.required && check.status === "fail" && check.id.startsWith("device-tool-") && check.userAction
144
+ );
145
+ if (missingRequiredTool) {
146
+ const error = {
147
+ code: "DOCTOR_CHECKS_FAILED",
148
+ message: missingRequiredTool.message,
149
+ userAction: missingRequiredTool.userAction
150
+ };
151
+ if (json) {
152
+ console.log(JSON.stringify({ ...result, ready: false, error }, null, 2));
153
+ } else {
154
+ console.error(`\u2717 doctor: ${error.message}
155
+ Next: ${error.userAction}`);
156
+ }
157
+ return EXIT.runtime;
158
+ }
135
159
  let runtime;
136
160
  let runtimeProbeError;
137
161
  try {
@@ -178,7 +202,9 @@ async function handleDoctor({ options }) {
178
202
  printReady
179
203
  );
180
204
  }
181
- const doctorUserAction = `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
205
+ const doctorUserAction = doctorResult.checks.find(
206
+ (check) => check.required && check.status === "fail" && check.userAction
207
+ )?.userAction ?? `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
182
208
  const doctorError = doctorResult.status === "fail" ? { code: "DOCTOR_CHECKS_FAILED", message: "one or more required doctor checks failed", userAction: doctorUserAction } : void 0;
183
209
  const next = doctorError?.userAction ?? (runtime?.decision === "ready" ? void 0 : runtime?.nextAction);
184
210
  if (json) console.log(JSON.stringify({ ...doctorResult, ready: runtime?.decision === "ready", runtime, orphanMetros, capture, devices, additionalReachableDevices, ...next ? { next } : {}, ...doctorError ? { error: doctorError } : {} }, null, 2));