@deeeed/metamask-harness 0.18.0 → 0.19.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 (79) hide show
  1. package/CHANGELOG.md +187 -0
  2. package/README.md +4 -7
  3. package/adapters/core/inject.sh +1 -6
  4. package/adapters/extension/inject.mjs +1 -4
  5. package/adapters/extension/verify.sh +1 -1
  6. package/adapters/mobile/inject.sh +5 -4
  7. package/adapters/mobile/verify.sh +15 -19
  8. package/adapters/shared/harness-source-fingerprint.mjs +14 -12
  9. package/dist/adapters/harness-freshness.js +49 -0
  10. package/dist/cli.js +0 -4
  11. package/dist/command-contract.js +51 -40
  12. package/dist/command-journal.js +50 -12
  13. package/dist/commands/call.js +95 -20
  14. package/dist/commands/check.js +1 -1
  15. package/dist/commands/completion-candidates.js +7 -19
  16. package/dist/commands/fixtures.js +50 -1
  17. package/dist/commands/last.js +9 -1
  18. package/dist/commands/launch/extension.js +2 -2
  19. package/dist/commands/launch/mobile.js +2 -0
  20. package/dist/commands/list-executables.js +56 -20
  21. package/dist/commands/manifest.js +28 -12
  22. package/dist/commands/parse-args.js +55 -3
  23. package/dist/commands/provision.js +0 -1
  24. package/dist/commands/run-engine.js +514 -336
  25. package/dist/commands/run.js +25 -37
  26. package/dist/commands/shared.js +1 -1
  27. package/dist/heal-bounds.js +5 -0
  28. package/dist/mm-harness-cli.js +22 -30
  29. package/dist/run-diagnostics.js +1 -1
  30. package/dist/run-recording.js +1 -1
  31. package/dist/runner.js +24 -2
  32. package/docs/CONTRIBUTING.md +2 -3
  33. package/docs/QA.md +0 -1
  34. package/docs/RECIPES.md +52 -91
  35. package/library/README.md +5 -5
  36. package/library/actions/core/perps/_controller.mjs +33 -1
  37. package/library/actions/core/perps/assert_orders.mjs +6 -7
  38. package/library/actions/core/perps/assert_positions.mjs +6 -7
  39. package/library/actions/core/perps/close_orders.mjs +2 -0
  40. package/library/actions/core/perps/close_positions.mjs +2 -0
  41. package/library/actions/core/perps/ensure_orders.mjs +4 -2
  42. package/library/actions/core/perps/ensure_positions.mjs +4 -2
  43. package/library/actions/core/perps/place_order.mjs +7 -3
  44. package/library/actions/extension/perps/assert_orders.mjs +2 -1
  45. package/library/actions/extension/perps/assert_positions.mjs +2 -1
  46. package/library/actions/extension/perps/perps.mjs +43 -14
  47. package/library/actions/mobile/perps/assert_orders.mjs +2 -1
  48. package/library/actions/mobile/perps/assert_positions.mjs +2 -1
  49. package/library/actions/mobile/perps/perps.mjs +40 -12
  50. package/library/library.json +1 -1
  51. package/library/manifests/core.action-manifest.json +1170 -413
  52. package/library/manifests/extension.action-manifest.json +1495 -641
  53. package/library/manifests/mobile.action-manifest.json +1704 -769
  54. package/library/recipes/app/lifecycle.android-smoke.mobile.recipe.json +63 -81
  55. package/library/recipes/perps/clean-market-testnet.core.recipe.json +44 -0
  56. package/library/recipes/perps/clean-market-testnet.recipe.json +49 -0
  57. package/library/recipes/perps/lifecycle.recipe.json +136 -180
  58. package/library/recipes/perps/order-lifecycle.core.recipe.json +71 -67
  59. package/library/recipes/perps/performance.background-resume.mobile.recipe.json +51 -67
  60. package/library/recipes/perps/performance.cold-start.mobile.recipe.json +51 -67
  61. package/library/recipes/perps/performance.mobile.recipe.json +37 -51
  62. package/library/recipes/perps/performance.warm-start.mobile.recipe.json +44 -59
  63. package/library/recipes/perps/read-markets.core.recipe.json +29 -31
  64. package/library/recipes/perps/smoke.core.recipe.json +29 -32
  65. package/library/recipes/perps/smoke.extension.recipe.json +41 -44
  66. package/library/recipes/perps/smoke.mobile.recipe.json +42 -44
  67. package/library/recipes/perps/trading-lifecycle.core.recipe.json +69 -65
  68. package/library/recipes/runner/action-validation.extension.recipe.json +312 -405
  69. package/library/recipes/runner/action-validation.mobile.recipe.json +316 -409
  70. package/library/recipes/runner/smoke.core.recipe.json +18 -20
  71. package/library/recipes/runner/smoke.extension.recipe.json +23 -24
  72. package/library/recipes/runner/smoke.mobile.recipe.json +23 -24
  73. package/library/recipes/wallet/smoke.extension.recipe.json +33 -35
  74. package/library/recipes/wallet/smoke.mobile.recipe.json +33 -35
  75. package/package.json +3 -3
  76. package/scripts/completions.sh +1 -4
  77. package/dist/adapters/extension/harness-freshness.js +0 -39
  78. package/dist/commands/flows.js +0 -91
  79. package/library/flows/perps.flows.json +0 -64
@@ -1,9 +1,10 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import { resolveActionManifest } from "../manifest.js";
3
4
  import { importRecipeProtocol } from "../paths.js";
4
5
  import { color } from "../cli-color.js";
5
6
  import { EXIT } from "./shared.js";
6
- import { optionFlag, optionString, resolveAdapter, shellQuoteArg } from "./parse-args.js";
7
+ import { optionFlag, optionString, optionStrings, resolveAdapter, shellQuoteArg } from "./parse-args.js";
7
8
  import { describeManifestActions, renderHumanActionCatalog } from "./manifest.js";
8
9
  import {
9
10
  describeRunnableRecipe,
@@ -19,9 +20,9 @@ function catalogDescription(description) {
19
20
  async function handleListExecutables(command, options) {
20
21
  const json = optionFlag(options, "json");
21
22
  const { adapter } = resolveAdapter(options);
22
- const librarySources = await resolveMetaMaskLibrarySources(optionString(options, "library"));
23
+ const librarySources = await resolveMetaMaskLibrarySources(optionStrings(options, "library"));
23
24
  if (command === "run") {
24
- const recipes = listRunnableRecipes(adapter, librarySources);
25
+ const recipes = await listRunnableRecipes(adapter, librarySources);
25
26
  if (json) {
26
27
  console.log(JSON.stringify({ schemaVersion: 1, command, action: "list", adapter, recipes }, null, 2));
27
28
  return EXIT.ok;
@@ -31,9 +32,11 @@ async function handleListExecutables(command, options) {
31
32
  console.log(out("comment", "Inspect: mm-harness run <recipe> --describe"));
32
33
  for (const recipe of recipes) {
33
34
  const shadowed = recipe.shadows.length > 0 ? ` shadows=${recipe.shadows.join(",")}` : "";
35
+ const variant = recipe.variant ? ` variant=${recipe.variant}` : " variant=all";
36
+ const parameters = recipe.parameters.length > 0 ? ` params=${recipe.parameters.map((parameter) => parameter.name).join(",")}` : "";
34
37
  const summary = catalogDescription(recipe.description);
35
38
  const description = summary ? ` \u2014 ${summary}` : "";
36
- console.log(` ${out("cmd", recipe.name)} ${out("dim", `[${recipe.source}]${shadowed}`)}${description}`);
39
+ console.log(` ${out("cmd", recipe.name)} ${out("dim", `[${recipe.source}]${variant}${shadowed}${parameters}`)}${description}`);
37
40
  }
38
41
  return EXIT.ok;
39
42
  }
@@ -83,14 +86,18 @@ async function handleListExecutables(command, options) {
83
86
  async function handleDescribeRecipe(recipeArg, options) {
84
87
  const json = optionFlag(options, "json");
85
88
  const { adapter } = resolveAdapter(options);
86
- const libraryEntry = optionString(options, "library");
87
- const librarySources = await resolveMetaMaskLibrarySources(libraryEntry);
88
- const result = describeRunnableRecipe(recipeArg, adapter, librarySources);
89
- const targetEntry = optionString(options, "target") ?? optionString(options, "projectRoot");
89
+ const libraryEntries = optionStrings(options, "library");
90
+ const directRecipePath = path.resolve(recipeArg);
91
+ const librarySources = await resolveMetaMaskLibrarySources(
92
+ libraryEntries,
93
+ fs.existsSync(directRecipePath) ? directRecipePath : void 0
94
+ );
95
+ const result = await describeRunnableRecipe(recipeArg, adapter, librarySources);
96
+ const targetEntry = optionString(options, "target");
90
97
  const replayTarget = targetEntry ? path.resolve(targetEntry) : void 0;
91
98
  const contextFlags = [
92
99
  `--adapter ${adapter}`,
93
- ...libraryEntry ? [`--library ${shellQuoteArg(libraryEntry)}`] : [],
100
+ ...libraryEntries?.map((entry) => `--library ${shellQuoteArg(entry)}`) ?? [],
94
101
  ...replayTarget ? [`--target ${shellQuoteArg(replayTarget)}`] : []
95
102
  ].join(" ");
96
103
  const discoveryCommand = `mm-harness run --list ${contextFlags}`;
@@ -114,7 +121,14 @@ async function handleDescribeRecipe(recipeArg, options) {
114
121
  return EXIT.usage;
115
122
  }
116
123
  const recipe = result.recipe;
117
- const nextCommand = `mm-harness run ${shellQuoteArg(recipeArg)} --plan ${contextFlags}`;
124
+ const parameterArgs = recipe.parameters.filter((parameter) => parameter.required && !Object.hasOwn(parameter, "default")).map((parameter) => `${parameter.name}=${shellQuoteArg(exampleParameterValue(parameter))}`);
125
+ const runCommand = [
126
+ "mm-harness run",
127
+ shellQuoteArg(recipeArg),
128
+ ...parameterArgs,
129
+ contextFlags
130
+ ].filter(Boolean).join(" ");
131
+ const nextCommand = `${runCommand} --plan`;
118
132
  if (json) {
119
133
  console.log(JSON.stringify({
120
134
  schemaVersion: 1,
@@ -122,6 +136,7 @@ async function handleDescribeRecipe(recipeArg, options) {
122
136
  action: "describe",
123
137
  status: "pass",
124
138
  recipe,
139
+ runCommand,
125
140
  nextCommand
126
141
  }, null, 2));
127
142
  return EXIT.ok;
@@ -133,24 +148,45 @@ async function handleDescribeRecipe(recipeArg, options) {
133
148
  console.log(` ${out("label", "adapter:")} ${recipe.adapter}`);
134
149
  console.log(` ${out("label", "source:")} ${recipe.source} \xB7 ${recipe.file}`);
135
150
  console.log(` ${out("label", "path:")} ${out("path", recipe.path)}`);
136
- const inputEntries = Object.entries(recipe.inputs);
137
- if (inputEntries.length === 0) {
138
- console.log(` ${out("label", "declared inputs:")} none`);
151
+ if (recipe.$schema) console.log(` ${out("label", "schema:")} ${recipe.$schema}`);
152
+ console.log(` ${out("label", "variant:")} ${recipe.variant ?? "all adapters"}`);
153
+ if (recipe.shadows.length > 0) {
154
+ console.log(` ${out("label", "shadows:")} ${recipe.shadows.join(", ")}`);
155
+ }
156
+ if (recipe.parameters.length === 0) {
157
+ console.log(` ${out("label", "parameters:")} none`);
139
158
  } else {
140
- console.log(` ${out("label", "input overrides:")} unavailable \u2014 declared inputs are recipe-owned`);
141
- console.log(` ${out("label", `declared inputs (${inputEntries.length})`)}`);
142
- for (const [name, declaration] of inputEntries) {
143
- console.log(` ${out("cmd", name)} = ${compactValue(declaration)}`);
159
+ console.log(` ${out("label", `parameters (${recipe.parameters.length})`)}`);
160
+ for (const parameter of recipe.parameters) {
161
+ const required = parameter.required ? " required" : "";
162
+ const defaultValue = Object.hasOwn(parameter, "default") ? ` default=${compactValue(parameter.default)}` : "";
163
+ const choices = parameter.enum ? ` choices=${compactValue(parameter.enum)}` : "";
164
+ const description = parameter.description ? ` \u2014 ${parameter.description}` : "";
165
+ console.log(
166
+ ` ${out("cmd", parameter.name)}${parameter.type ? ` (${parameter.type})` : ""}${required}${defaultValue}${choices}${description}`
167
+ );
144
168
  }
145
169
  }
146
170
  renderComposition("actions", recipe.actions, out);
147
- if (!recipe.actionsComplete) {
148
- console.log(` ${out("comment", "Note: actions inside external library flows are not expanded.")}`);
171
+ renderComposition("called recipes", recipe.nestedRecipes, out);
172
+ if (recipe.unresolvedRecipes.length > 0) {
173
+ renderComposition("unresolved recipes", recipe.unresolvedRecipes, out);
149
174
  }
150
- renderComposition("flows", recipe.flows, out);
175
+ console.log(` ${out("comment", `Run: ${runCommand}`)}`);
151
176
  console.log(` ${out("comment", `Next: ${nextCommand}`)}`);
152
177
  return EXIT.ok;
153
178
  }
179
+ function exampleParameterValue(parameter) {
180
+ if (parameter.enum && parameter.enum.length > 0) return stringifyAssignmentValue(parameter.enum[0]);
181
+ if (parameter.type === "boolean") return "false";
182
+ if (parameter.type === "number" || parameter.type === "integer") return "0";
183
+ if (parameter.type === "array") return "[]";
184
+ if (parameter.type === "object") return "{}";
185
+ return "value";
186
+ }
187
+ function stringifyAssignmentValue(value) {
188
+ return typeof value === "string" ? value : JSON.stringify(value) ?? String(value);
189
+ }
154
190
  function compactValue(value) {
155
191
  const rendered = JSON.stringify(value);
156
192
  if (rendered === void 0) return String(value);
@@ -5,6 +5,7 @@ import {
5
5
  isRecord,
6
6
  optionFlag,
7
7
  optionString,
8
+ optionStrings,
8
9
  resolveAdapter,
9
10
  shellQuoteArg
10
11
  } from "./parse-args.js";
@@ -15,7 +16,7 @@ import {
15
16
  import { metaMaskActionExecutionCapabilities } from "../recipe-security.js";
16
17
  async function handleActions({ options, positional }) {
17
18
  const { adapter, target } = resolveAdapter(options);
18
- const librarySources = await resolveMetaMaskLibrarySources(optionString(options, "library"));
19
+ const librarySources = await resolveMetaMaskLibrarySources(optionStrings(options, "library"));
19
20
  const { manifest, actionSources } = await resolveActionManifest(
20
21
  adapter,
21
22
  optionString(options, "actionManifest"),
@@ -92,13 +93,15 @@ async function handleActions({ options, positional }) {
92
93
  }
93
94
  const relatedActions = action && actions.length === 1 ? findRelatedActions(all, actions[0]) : void 0;
94
95
  if (json) {
96
+ const detailed = Boolean(action && actions.length === 1);
95
97
  console.log(JSON.stringify({
96
98
  schemaVersion: 1,
97
99
  command: "actions",
98
100
  adapter,
99
101
  ...query ? { query } : {},
100
102
  category,
101
- actions,
103
+ detail: detailed ? "full" : "summary",
104
+ actions: detailed ? actions : actions.map(summarizeDescribedAction),
102
105
  ...relatedActions?.length ? { relatedActions } : {}
103
106
  }, null, 2));
104
107
  } else {
@@ -145,6 +148,10 @@ function findRelatedActions(entries, selected, limit = 5) {
145
148
  }).filter(({ score }) => score > 0).sort((left, right) => right.score - left.score || left.name.localeCompare(right.name)).slice(0, limit).map(({ name }) => name);
146
149
  }
147
150
  const GENERIC_OPERATION_TERMS = /* @__PURE__ */ new Set(["assert", "call", "close", "ensure", "place", "read", "start", "teardown"]);
151
+ function summarizeDescribedAction(entry) {
152
+ const { schema: _schema, examples: _examples, ...summary } = entry;
153
+ return summary;
154
+ }
148
155
  function renderHumanActionCatalog(actions, options) {
149
156
  const out = (style, text) => color(style, text, { stream: process.stdout });
150
157
  const groups = /* @__PURE__ */ new Map();
@@ -170,11 +177,25 @@ function renderHumanActionExample(entry, adapter, target, executable) {
170
177
  const node = authoredExampleNode(entry.examples);
171
178
  if (!node) return void 0;
172
179
  const out = (style, text) => color(style, text, { stream: process.stdout });
180
+ const command = actionExampleCommand(entry, adapter, target, executable);
181
+ if (!command) return void 0;
182
+ const lines = [
183
+ out("label", "Example call:"),
184
+ ` ${out("cmd", command)}`,
185
+ out("label", "Recipe node:"),
186
+ ...JSON.stringify(node, null, 2).split("\n").map((line) => ` ${line}`)
187
+ ];
188
+ return lines.join("\n");
189
+ }
190
+ function actionExampleCommand(entry, adapter, target, executable) {
191
+ const node = authoredExampleNode(entry.examples);
192
+ if (!node) return void 0;
193
+ const commandName = executable.endsWith("/mm-harness") ? "mm-harness" : executable;
173
194
  const args = entry.fields.flatMap(
174
195
  (field) => Object.hasOwn(node, field) ? [shellQuoteArg(`${field}=${actionCallValue(node[field])}`)] : []
175
196
  );
176
- const command = [
177
- shellQuoteArg(executable),
197
+ return [
198
+ shellQuoteArg(commandName),
178
199
  "call",
179
200
  shellQuoteArg(entry.name),
180
201
  ...args,
@@ -183,13 +204,6 @@ function renderHumanActionExample(entry, adapter, target, executable) {
183
204
  "--target",
184
205
  shellQuoteArg(target)
185
206
  ].join(" ");
186
- const lines = [
187
- out("label", "Example call:"),
188
- ` ${out("cmd", command)}`,
189
- out("label", "Recipe node:"),
190
- ...JSON.stringify(node, null, 2).split("\n").map((line) => ` ${line}`)
191
- ];
192
- return lines.join("\n");
193
207
  }
194
208
  function authoredExampleNode(examples) {
195
209
  if (!Array.isArray(examples)) return void 0;
@@ -318,6 +332,7 @@ function levenshtein(left, right) {
318
332
  return prior[right.length] ?? right.length;
319
333
  }
320
334
  export {
335
+ actionExampleCommand,
321
336
  describeManifestActions,
322
337
  findRelatedActions,
323
338
  fuzzyResolveActions,
@@ -325,5 +340,6 @@ export {
325
340
  renderHumanActionCatalog,
326
341
  renderHumanActionExample,
327
342
  searchActions,
328
- summarizeActionCategories
343
+ summarizeActionCategories,
344
+ summarizeDescribedAction
329
345
  };
@@ -38,6 +38,7 @@ function parseArgs(argv, command) {
38
38
  "allDevices",
39
39
  "help"
40
40
  ]);
41
+ const repeatableOptions = /* @__PURE__ */ new Set(["library"]);
41
42
  for (let i = 0; i < argv.length; i += 1) {
42
43
  const arg = argv[i];
43
44
  if (!arg.startsWith("--")) {
@@ -70,15 +71,56 @@ function parseArgs(argv, command) {
70
71
  continue;
71
72
  }
72
73
  if (inlineValue !== void 0) {
73
- options[key] = inlineValue;
74
+ assignOption(options, key, inlineValue, repeatableOptions);
74
75
  continue;
75
76
  }
76
77
  if (i + 1 >= argv.length) throw usageError(`Missing value for ${arg}`);
77
- options[key] = argv[i + 1];
78
+ assignOption(options, key, argv[i + 1], repeatableOptions);
78
79
  i += 1;
79
80
  }
80
81
  return { positional, options, rawArgv: [...argv] };
81
82
  }
83
+ function assignOption(options, key, value, repeatableOptions) {
84
+ if (!repeatableOptions.has(key)) {
85
+ options[key] = value;
86
+ return;
87
+ }
88
+ const existing = options[key];
89
+ if (existing === void 0) {
90
+ options[key] = [value];
91
+ return;
92
+ }
93
+ if (!Array.isArray(existing)) throw usageError(`--${key} has conflicting values.`);
94
+ options[key] = [...existing, value];
95
+ }
96
+ function parseRecipeParamAssignments(values) {
97
+ const params = {};
98
+ for (const assignment of values) {
99
+ const separator = assignment.indexOf("=");
100
+ if (separator <= 0) {
101
+ throw usageError(`recipe parameter ${JSON.stringify(assignment)} must use key=value.`);
102
+ }
103
+ const key = assignment.slice(0, separator).trim();
104
+ if (!key) throw usageError(`recipe parameter ${JSON.stringify(assignment)} has an empty key.`);
105
+ if (Object.hasOwn(params, key)) {
106
+ throw usageError(`recipe parameter ${JSON.stringify(key)} was provided more than once.`);
107
+ }
108
+ const raw = assignment.slice(separator + 1);
109
+ let value;
110
+ try {
111
+ value = JSON.parse(raw);
112
+ } catch {
113
+ value = raw;
114
+ }
115
+ Object.defineProperty(params, key, {
116
+ value,
117
+ enumerable: true,
118
+ configurable: true,
119
+ writable: true
120
+ });
121
+ }
122
+ return params;
123
+ }
82
124
  function parseRecordVideoMode(value) {
83
125
  if (value === void 0 || value === "" || value === "true") return "full-run";
84
126
  if (value === "off" || value === "false") return false;
@@ -101,6 +143,14 @@ function optionString(options, key) {
101
143
  if (typeof value !== "string") throw usageError(`--${key} requires a value.`);
102
144
  return value;
103
145
  }
146
+ function optionStrings(options, key) {
147
+ const value = options[key];
148
+ if (value === void 0) return void 0;
149
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
150
+ throw usageError(`--${key} requires a value.`);
151
+ }
152
+ return value;
153
+ }
104
154
  function optionFlag(options, key) {
105
155
  const value = options[key];
106
156
  return value === true;
@@ -131,7 +181,7 @@ function adapterOption(options) {
131
181
  return adapter;
132
182
  }
133
183
  function targetPath(options) {
134
- return path.resolve(optionString(options, "target") ?? optionString(options, "projectRoot") ?? process.cwd());
184
+ return path.resolve(optionString(options, "target") ?? process.cwd());
135
185
  }
136
186
  function actionManifestPathOption(options, adapter) {
137
187
  const configured = optionString(options, "actionManifest");
@@ -196,8 +246,10 @@ export {
196
246
  isRecord,
197
247
  optionFlag,
198
248
  optionString,
249
+ optionStrings,
199
250
  parseArgs,
200
251
  parsePort,
252
+ parseRecipeParamAssignments,
201
253
  requiredOption,
202
254
  resolveAdapter,
203
255
  runtimeOptionsFromCli,
@@ -99,7 +99,6 @@ function provisionPositionals(rawArgv) {
99
99
  const valueFlags = /* @__PURE__ */ new Set([
100
100
  "--adapter",
101
101
  "--target",
102
- "--project-root",
103
102
  "--platform",
104
103
  "--device-platform",
105
104
  "--branch",