@deeeed/metamask-harness 0.30.0 → 0.32.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.
Files changed (31) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/adapters/extension/verify.sh +16 -4
  3. package/dist/commands/self-test.js +1 -1
  4. package/dist/recipe-security.js +1 -0
  5. package/docs/RECIPES.md +5 -2
  6. package/library/actions/extension/deeplink/open.mjs +26 -0
  7. package/library/actions/mobile/deeplink/open.mjs +16 -0
  8. package/library/actions/mobile/platform/bridge.mjs +80 -0
  9. package/library/actions/shared/deeplink/url.mjs +37 -0
  10. package/library/manifests/extension.action-manifest.json +33 -0
  11. package/library/manifests/mobile.action-manifest.json +33 -0
  12. package/package.json +2 -2
  13. /package/library/recipes/{perps/clean-market-testnet.core.recipe.json → core/perps/clean-market-testnet.recipe.json} +0 -0
  14. /package/library/recipes/{perps/order-lifecycle.core.recipe.json → core/perps/order-lifecycle.recipe.json} +0 -0
  15. /package/library/recipes/{perps/read-markets.core.recipe.json → core/perps/read-markets.recipe.json} +0 -0
  16. /package/library/recipes/{perps/smoke.core.recipe.json → core/perps/smoke.recipe.json} +0 -0
  17. /package/library/recipes/{perps/trading-lifecycle.core.recipe.json → core/perps/trading-lifecycle.recipe.json} +0 -0
  18. /package/library/recipes/{runner/smoke.core.recipe.json → core/runner/smoke.recipe.json} +0 -0
  19. /package/library/recipes/{perps/smoke.extension.recipe.json → extension/perps/smoke.recipe.json} +0 -0
  20. /package/library/recipes/{runner/action-validation.extension.recipe.json → extension/runner/action-validation.recipe.json} +0 -0
  21. /package/library/recipes/{runner/smoke.extension.recipe.json → extension/runner/smoke.recipe.json} +0 -0
  22. /package/library/recipes/{wallet/smoke.extension.recipe.json → extension/wallet/smoke.recipe.json} +0 -0
  23. /package/library/recipes/{app/lifecycle.android-smoke.mobile.recipe.json → mobile/app/lifecycle.android-smoke.recipe.json} +0 -0
  24. /package/library/recipes/{perps/performance.background-resume.mobile.recipe.json → mobile/perps/performance.background-resume.recipe.json} +0 -0
  25. /package/library/recipes/{perps/performance.cold-start.mobile.recipe.json → mobile/perps/performance.cold-start.recipe.json} +0 -0
  26. /package/library/recipes/{perps/performance.mobile.recipe.json → mobile/perps/performance.recipe.json} +0 -0
  27. /package/library/recipes/{perps/performance.warm-start.mobile.recipe.json → mobile/perps/performance.warm-start.recipe.json} +0 -0
  28. /package/library/recipes/{perps/smoke.mobile.recipe.json → mobile/perps/smoke.recipe.json} +0 -0
  29. /package/library/recipes/{runner/action-validation.mobile.recipe.json → mobile/runner/action-validation.recipe.json} +0 -0
  30. /package/library/recipes/{runner/smoke.mobile.recipe.json → mobile/runner/smoke.recipe.json} +0 -0
  31. /package/library/recipes/{wallet/smoke.mobile.recipe.json → mobile/wallet/smoke.recipe.json} +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.32.0 - 2026-08-02
6
+
7
+ ### Changed
8
+
9
+ - Organize bundled platform recipe variants under `library/recipes/<adapter>/` while retaining temporary compatibility with legacy filename suffixes.
10
+
11
+ ## 0.31.0 - 2026-08-02
12
+
13
+ ### Added
14
+
15
+ - Add one cross-platform `metamask.deeplink.open` capability that exercises real Chrome or device URL ingress for external deeplink proofs.
16
+
5
17
  ## 0.30.0 - 2026-08-02
6
18
 
7
19
  ### Changed
@@ -88,7 +88,7 @@ RUNNER_BIN="$HARNESS_DIR/runner/bin/mm-harness"
88
88
  # --out (optional): a task-local recipes dir. Resolve it safely within the target
89
89
  # (resolve_harness_out rejects absolute/.. escapes) and prefer its smoke recipe so
90
90
  # `live --out <dir>` does not silently fall back to the installed default.
91
- SMOKE_RECIPE="$HARNESS_DIR/runner/recipes/runner/smoke.extension.recipe.json"
91
+ SMOKE_RECIPE="$HARNESS_DIR/runner/recipes/extension/runner/smoke.recipe.json"
92
92
  if [ -n "$OUT" ]; then
93
93
  # Fail fast on a task-local --out that does not contain the requested recipe.
94
94
  # Silently falling back to the installed default would validate a different
@@ -98,11 +98,23 @@ if [ -n "$OUT" ]; then
98
98
  echo "extension verify: --out '$OUT' did not resolve to a safe path under the target." >&2
99
99
  exit 2
100
100
  fi
101
- if [ ! -f "$OUT_ABS/runner/smoke.extension.recipe.json" ]; then
102
- echo "extension verify: --out '$OUT' (resolved: $OUT_ABS) has no runner/smoke.extension.recipe.json. Refusing to fall back to the installed default recipe; place the recipe under --out or omit --out." >&2
101
+ CANONICAL_SMOKE_RECIPE="$OUT_ABS/extension/runner/smoke.recipe.json"
102
+ LEGACY_SMOKE_RECIPE="$OUT_ABS/runner/smoke.extension.recipe.json"
103
+ if [ -f "$CANONICAL_SMOKE_RECIPE" ] && [ -f "$LEGACY_SMOKE_RECIPE" ]; then
104
+ echo "extension verify: --out '$OUT' declares runner.smoke for extension in both canonical and legacy paths." >&2
105
+ echo "Next: Remove $LEGACY_SMOKE_RECIPE and rerun verification." >&2
103
106
  exit 2
104
107
  fi
105
- SMOKE_RECIPE="$OUT_ABS/runner/smoke.extension.recipe.json"
108
+ if [ -f "$CANONICAL_SMOKE_RECIPE" ]; then
109
+ SMOKE_RECIPE="$CANONICAL_SMOKE_RECIPE"
110
+ elif [ -f "$LEGACY_SMOKE_RECIPE" ]; then
111
+ SMOKE_RECIPE="$LEGACY_SMOKE_RECIPE"
112
+ else
113
+ echo "extension verify: --out '$OUT' has no extension runner.smoke recipe." >&2
114
+ echo "Next: Add extension/runner/smoke.recipe.json under $OUT_ABS or omit --out." >&2
115
+ exit 2
116
+ fi
117
+ unset CANONICAL_SMOKE_RECIPE LEGACY_SMOKE_RECIPE
106
118
  fi
107
119
  ARTIFACTS="${ARTIFACTS:-$HARNESS_DIR/verify/$(date -u +%Y%m%dT%H%M%SZ)}"
108
120
  mkdir -p "$ARTIFACTS/logs"
@@ -26,7 +26,7 @@ async function runSelfTest(options) {
26
26
  const manifest = loadActionManifest(adapter);
27
27
  const manifestValidation = await validateManifest(manifest);
28
28
  const smokeRecipe = recipePath(
29
- adapter === "mobile" ? "runner/smoke.mobile.recipe.json" : "runner/smoke.extension.recipe.json"
29
+ adapter === "mobile" ? "mobile/runner/smoke.recipe.json" : "extension/runner/smoke.recipe.json"
30
30
  );
31
31
  const artifactsDir = path.join(root, adapter);
32
32
  const result = await runRecipe(adapter, smokeRecipe, artifactsDir, runnerDir, void 0, {
@@ -20,6 +20,7 @@ const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
20
20
  "metamask.wallet.setup",
21
21
  "metamask.wallet.ensure_unlocked",
22
22
  "metamask.wallet.select_account",
23
+ "metamask.deeplink.open",
23
24
  "metamask.perps.close_positions",
24
25
  "metamask.perps.close_orders",
25
26
  "metamask.perps.place_order",
package/docs/RECIPES.md CHANGED
@@ -116,7 +116,7 @@ The protocol is authoritative:
116
116
  team-recipes/
117
117
  manifests/extension.action-manifest.json
118
118
  actions/extension/wallet/ensure_ready.mjs
119
- recipes/onboarding/smoke.extension.recipe.json
119
+ recipes/extension/onboarding/smoke.recipe.json
120
120
  ```
121
121
 
122
122
  ```bash
@@ -129,7 +129,10 @@ mm-harness run onboarding.smoke --plan
129
129
  Use `--library wallet=/path/to/library` for one command. The alias is the
130
130
  source name; without one, the directory name is used. Resolution follows the
131
131
  explicit library order, then bundled MetaMask. Adapter-specific variants are
132
- selected deterministically. Every run records the root recipe, exact resolved
132
+ selected deterministically from `recipes/<adapter>/<domain>/`. Legacy
133
+ `*.<adapter>.recipe.json` paths remain readable during migration, but new and
134
+ migrated libraries use the adapter-first layout. A file cannot declare its
135
+ adapter through both forms. Every run records the root recipe, exact resolved
133
136
  dependency documents, their digests, call edges, selected sources, and shadows.
134
137
 
135
138
  ## Direct product access
@@ -0,0 +1,26 @@
1
+ import { pathToFileURL } from 'node:url';
2
+
3
+ import {
4
+ externalDeeplinkTimeout,
5
+ externalDeeplinkUrl,
6
+ } from '../../shared/deeplink/url.mjs';
7
+ import { runAdapter, withExtensionPage } from '../platform/cdp.mjs';
8
+
9
+ export async function openExtensionDeeplink(input) {
10
+ return withExtensionPage(input, (page) => navigateExtensionDeeplink(page, input));
11
+ }
12
+
13
+ export async function navigateExtensionDeeplink(page, input) {
14
+ const url = externalDeeplinkUrl(input.node);
15
+ const timeoutMs = externalDeeplinkTimeout(input.node);
16
+ return {
17
+ action: input.action,
18
+ url,
19
+ navigation: await page.navigate(url, timeoutMs),
20
+ proofPath: 'external-deeplink',
21
+ };
22
+ }
23
+
24
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
25
+ runAdapter(openExtensionDeeplink);
26
+ }
@@ -0,0 +1,16 @@
1
+ import {
2
+ externalDeeplinkTimeout,
3
+ externalDeeplinkUrl,
4
+ } from '../../shared/deeplink/url.mjs';
5
+ import { openExternalDeeplink, runAdapter } from '../platform/bridge.mjs';
6
+
7
+ runAdapter(async (input) => {
8
+ const url = externalDeeplinkUrl(input.node);
9
+ const timeoutMs = externalDeeplinkTimeout(input.node);
10
+ return {
11
+ action: input.action,
12
+ url,
13
+ ...(await openExternalDeeplink(input, url, timeoutMs)),
14
+ proofPath: 'external-deeplink',
15
+ };
16
+ });
@@ -160,6 +160,86 @@ function resolveMobileTarget(input) {
160
160
  return { watcherPort, iosSimulator, androidDevice, androidTargetDeviceName, adbSerial };
161
161
  }
162
162
 
163
+ export async function openExternalDeeplink(input, url, timeout = 15000) {
164
+ const target = resolveMobileTarget(input);
165
+ const requestedPlatform = String(
166
+ input.context?.env?.PLATFORM ?? process.env.PLATFORM ?? '',
167
+ ).toLowerCase();
168
+ const serial = String(
169
+ input.context?.env?.ADB_SERIAL ??
170
+ input.context?.env?.ANDROID_SERIAL ??
171
+ process.env.ADB_SERIAL ??
172
+ process.env.ANDROID_SERIAL ??
173
+ '',
174
+ ).trim();
175
+ const android =
176
+ requestedPlatform === 'android' || (requestedPlatform !== 'ios' && Boolean(serial));
177
+ if (android) {
178
+ if (!serial) {
179
+ throw new Error(
180
+ 'Android deeplink ingress requires an adb serial.\n' +
181
+ ' Next: mm-harness devices --adapter mobile --json',
182
+ );
183
+ }
184
+ const adb = resolveMobileToolPath('adb', { required: true });
185
+ const { stdout } = await runDeeplinkCommand(
186
+ adb,
187
+ [
188
+ '-s',
189
+ serial,
190
+ 'shell',
191
+ 'am',
192
+ 'start',
193
+ '-W',
194
+ '-a',
195
+ 'android.intent.action.VIEW',
196
+ '-d',
197
+ quoteRemoteShellArgument(url),
198
+ ],
199
+ timeout,
200
+ `Android device ${serial}`,
201
+ );
202
+ if (!/^Status:\s*ok\s*$/imu.test(stdout)) {
203
+ throw new Error(
204
+ `Android device ${serial} did not report a successful deeplink launch.\n` +
205
+ ' Next: mm-harness doctor --adapter mobile --json',
206
+ );
207
+ }
208
+ return { platform: 'android', selectedDevice: serial, method: 'adb-am-start-view' };
209
+ }
210
+
211
+ const simulator = String(target.iosSimulator ?? '').trim();
212
+ if (!simulator) {
213
+ throw new Error(
214
+ 'iOS deeplink ingress requires an iOS simulator.\n' +
215
+ ' Next: mm-harness devices --adapter mobile --json',
216
+ );
217
+ }
218
+ await runDeeplinkCommand(
219
+ 'xcrun',
220
+ ['simctl', 'openurl', simulator, url],
221
+ timeout,
222
+ `iOS simulator ${simulator}`,
223
+ );
224
+ return { platform: 'ios', selectedDevice: simulator, method: 'simctl-openurl' };
225
+ }
226
+
227
+ function quoteRemoteShellArgument(value) {
228
+ return `'${value.replaceAll("'", `'\\''`)}'`;
229
+ }
230
+
231
+ async function runDeeplinkCommand(command, args, timeout, target) {
232
+ try {
233
+ return await execFileAsync(command, args, { timeout, encoding: 'utf8' });
234
+ } catch (error) {
235
+ const detail = String(error?.stderr ?? error?.message ?? error).trim();
236
+ throw new Error(
237
+ `Could not open the deeplink on ${target}${detail ? `: ${detail}` : '.'}\n` +
238
+ ' Next: mm-harness doctor --adapter mobile --json',
239
+ );
240
+ }
241
+ }
242
+
163
243
  function androidDeviceNameMatches(deviceName, targetName) {
164
244
  if (!targetName) return false;
165
245
  return deviceName === targetName || deviceName.startsWith(`${targetName} -`);
@@ -0,0 +1,37 @@
1
+ export function externalDeeplinkUrl(node) {
2
+ const raw = node?.url;
3
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
4
+ throw new Error(
5
+ 'metamask.deeplink.open requires url.\n' +
6
+ ' Next: mm-harness call metamask.deeplink.open url=https://link.metamask.io/home',
7
+ );
8
+ }
9
+
10
+ let url;
11
+ try {
12
+ url = new URL(raw);
13
+ } catch {
14
+ throw new Error(
15
+ 'metamask.deeplink.open requires an absolute HTTPS url.\n' +
16
+ ' Next: mm-harness call metamask.deeplink.open url=https://link.metamask.io/home',
17
+ );
18
+ }
19
+ if (url.protocol !== 'https:') {
20
+ throw new Error(
21
+ 'metamask.deeplink.open accepts only HTTPS urls.\n' +
22
+ ' Next: mm-harness call metamask.deeplink.open url=https://link.metamask.io/home',
23
+ );
24
+ }
25
+ return url.toString();
26
+ }
27
+
28
+ export function externalDeeplinkTimeout(node) {
29
+ const timeout = Number(node?.timeout_ms ?? 15000);
30
+ if (!Number.isFinite(timeout) || timeout <= 0 || timeout > 120000) {
31
+ throw new Error(
32
+ 'metamask.deeplink.open timeout_ms must be between 1 and 120000.\n' +
33
+ ' Next: mm-harness call metamask.deeplink.open url=https://link.metamask.io/home timeout_ms=15000',
34
+ );
35
+ }
36
+ return timeout;
37
+ }
@@ -2779,6 +2779,37 @@
2779
2779
  "external-mutation"
2780
2780
  ]
2781
2781
  },
2782
+ "metamask.deeplink.open": {
2783
+ "description": "Open an HTTPS deeplink through real browser ingress so the Extension owns parsing, verification, and destination navigation.",
2784
+ "execution_capabilities": [
2785
+ "app-mutation"
2786
+ ],
2787
+ "examples": [
2788
+ {
2789
+ "action": "metamask.deeplink.open",
2790
+ "url": "https://link.metamask.io/home",
2791
+ "intent": "Open the deeplink through external browser navigation",
2792
+ "next": "done"
2793
+ }
2794
+ ],
2795
+ "schema": {
2796
+ "type": "object",
2797
+ "properties": {
2798
+ "url": {
2799
+ "type": "string",
2800
+ "description": "Absolute HTTPS deeplink URL."
2801
+ },
2802
+ "timeout_ms": {
2803
+ "type": "number",
2804
+ "description": "Maximum time for the external navigation. Default 15000."
2805
+ }
2806
+ },
2807
+ "required": [
2808
+ "url"
2809
+ ],
2810
+ "additionalProperties": false
2811
+ }
2812
+ },
2782
2813
  "metamask.analytics.start_capture": {
2783
2814
  "description": "extension Start capturing MetaMetrics events at the Segment transport and return a cursor to bracket later reads.",
2784
2815
  "execution_capabilities": [
@@ -2945,6 +2976,7 @@
2945
2976
  {
2946
2977
  "ref": "ui.screen",
2947
2978
  "default_for": [
2979
+ "metamask.deeplink.open",
2948
2980
  "ui.navigate",
2949
2981
  "ui.press",
2950
2982
  "ui.key_press",
@@ -2957,6 +2989,7 @@
2957
2989
  {
2958
2990
  "ref": "ui.visible",
2959
2991
  "default_for": [
2992
+ "metamask.deeplink.open",
2960
2993
  "ui.navigate",
2961
2994
  "ui.press",
2962
2995
  "ui.key_press",
@@ -2901,6 +2901,37 @@
2901
2901
  "external-mutation"
2902
2902
  ]
2903
2903
  },
2904
+ "metamask.deeplink.open": {
2905
+ "description": "Open an HTTPS deeplink through the selected Android device or iOS simulator so Mobile owns parsing and destination navigation.",
2906
+ "execution_capabilities": [
2907
+ "app-mutation"
2908
+ ],
2909
+ "examples": [
2910
+ {
2911
+ "action": "metamask.deeplink.open",
2912
+ "url": "https://link.metamask.io/home",
2913
+ "intent": "Open the deeplink through external device navigation",
2914
+ "next": "done"
2915
+ }
2916
+ ],
2917
+ "schema": {
2918
+ "type": "object",
2919
+ "properties": {
2920
+ "url": {
2921
+ "type": "string",
2922
+ "description": "Absolute HTTPS deeplink URL."
2923
+ },
2924
+ "timeout_ms": {
2925
+ "type": "number",
2926
+ "description": "Maximum time for the external navigation. Default 15000."
2927
+ }
2928
+ },
2929
+ "required": [
2930
+ "url"
2931
+ ],
2932
+ "additionalProperties": false
2933
+ }
2934
+ },
2904
2935
  "metamask.analytics.start_capture": {
2905
2936
  "description": "mobile Start capturing MetaMetrics events at the Segment transport and return a cursor to bracket later reads.",
2906
2937
  "execution_capabilities": [
@@ -3067,6 +3098,7 @@
3067
3098
  {
3068
3099
  "ref": "ui.screen",
3069
3100
  "default_for": [
3101
+ "metamask.deeplink.open",
3070
3102
  "ui.navigate",
3071
3103
  "ui.press",
3072
3104
  "ui.screenshot",
@@ -3078,6 +3110,7 @@
3078
3110
  {
3079
3111
  "ref": "ui.visible",
3080
3112
  "default_for": [
3113
+ "metamask.deeplink.open",
3081
3114
  "ui.navigate",
3082
3115
  "ui.press",
3083
3116
  "ui.screenshot",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -23,7 +23,7 @@
23
23
  "@farmslot/agent-runtime": "0.5.1",
24
24
  "@farmslot/handoff": "^0.3.1",
25
25
  "@farmslot/protocol": "0.16.0",
26
- "@farmslot/recipe-harness": "0.12.0",
26
+ "@farmslot/recipe-harness": "0.13.0",
27
27
  "commander": "^12.0.0",
28
28
  "es-module-lexer": "2.3.1",
29
29
  "esbuild": "0.28.1",