@deeeed/metamask-harness 0.42.0 → 0.44.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 (43) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +5 -0
  3. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +363 -55
  4. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +23 -10
  5. package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +2 -0
  6. package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +296 -25
  7. package/adapters/mobile/bridge-runtime/lib/config.cjs +14 -4
  8. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +4 -2
  9. package/adapters/mobile/reload-app.mjs +99 -1
  10. package/adapters/mobile/start-console-forwarder.sh +5 -1
  11. package/dist/adapters/extension/browser-cdp.js +174 -0
  12. package/dist/adapters/extension/network-observer.js +19 -110
  13. package/dist/adapters/extension/performance-observer.js +75 -0
  14. package/dist/adapters/mobile/frame-metrics.js +45 -0
  15. package/dist/adapters/mobile/performance-observer.js +43 -0
  16. package/dist/adapters/mobile/prepare.js +12 -0
  17. package/dist/adapters/performance/cdp-trace.js +342 -0
  18. package/dist/adapters/performance/js-task-metrics.js +35 -0
  19. package/dist/adapters.js +39 -5
  20. package/dist/artifact-files.js +92 -0
  21. package/dist/async.js +19 -0
  22. package/dist/commands/call.js +63 -4
  23. package/dist/commands/run-engine.js +265 -139
  24. package/dist/commands/run-report.js +68 -0
  25. package/dist/commands/run.js +67 -3
  26. package/dist/execution-provenance.js +342 -0
  27. package/dist/network-observation.js +59 -47
  28. package/dist/performance-observation.js +465 -0
  29. package/dist/run-diagnostics.js +36 -11
  30. package/dist/runner.js +44 -13
  31. package/docs/PERFORMANCE-CAPTURE.md +33 -0
  32. package/docs/RECIPES.md +7 -0
  33. package/library/actions/mobile/perps/performance-capture.mjs +570 -189
  34. package/library/actions/mobile/perps/perps.mjs +122 -0
  35. package/library/actions/mobile/platform/bridge.mjs +43 -8
  36. package/library/actions/mobile/platform/native-session.mjs +1 -1
  37. package/library/actions/mobile/wallet/lock.mjs +1 -4
  38. package/library/actions/mobile/wallet/select_account.mjs +129 -17
  39. package/library/manifests/extension.action-manifest.json +85 -0
  40. package/library/manifests/mobile.action-manifest.json +125 -3
  41. package/library/recipes/mobile/perps/performance.recipe.json +73 -47
  42. package/package.json +1 -1
  43. package/scripts/site-contrast.mjs +43 -27
@@ -33,6 +33,7 @@ import {
33
33
  emitHealViolation,
34
34
  executeWithHealBounds,
35
35
  prepareHeal,
36
+ persistRunEffects,
36
37
  recoverRunInfra,
37
38
  resolveMetaMaskLibrarySources,
38
39
  preflightRecipe,
@@ -46,9 +47,13 @@ import { formatRunDiagnosticsForHuman, readRunDiagnosticsDocument } from "../run
46
47
  import {
47
48
  startRunNetworkObservation
48
49
  } from "../network-observation.js";
50
+ import {
51
+ startRunPerformanceObservation
52
+ } from "../performance-observation.js";
49
53
  import { recipeTrustFailure } from "../recipe-security.js";
50
54
  import { isSensitiveKey, recordCommandEvidence, redactStructuredValue } from "../command-journal.js";
51
55
  import { closest } from "../command-contract.js";
56
+ import { ProvenanceDriftError } from "../execution-provenance.js";
52
57
  async function handleCall(argv) {
53
58
  if (argv.includes("--list")) {
54
59
  const { options: options2 } = parseArgs(argv, "call");
@@ -213,13 +218,17 @@ async function handleCall(argv) {
213
218
  const requestedRuntimeOptions = runtimeOptionsFromCli(options);
214
219
  const inheritedSource = process.env.FARMSLOT_RECIPE_SOURCE_TRUST || process.env.FARMSLOT_RECIPE_SOURCE_KIND || process.env.FARMSLOT_RECIPE_SOURCE_NAME || process.env.FARMSLOT_RECIPE_SOURCE_DIGEST;
215
220
  let networkObservation;
221
+ let performanceObservation;
216
222
  const callRuntimeOptions = {
217
223
  ...requestedRuntimeOptions,
218
224
  ...librarySources ? { librarySources } : {},
219
225
  autoHud: false,
220
226
  suppressLibraryResolutionLogs: true,
221
227
  stdoutIsMachineContract: json,
222
- onActionEvent: ({ nodeId, action, status }) => networkObservation?.onActionEvent({ nodeId, action, status }),
228
+ onActionEvent: ({ nodeId, action, status }) => {
229
+ networkObservation?.onActionEvent({ nodeId, action, status });
230
+ performanceObservation?.onActionEvent({ nodeId, action, status });
231
+ },
223
232
  ...requestedRuntimeOptions.source ? { source: requestedRuntimeOptions.source } : inheritedSource ? {} : {
224
233
  source: {
225
234
  kind: "operator",
@@ -275,11 +284,20 @@ async function handleCall(argv) {
275
284
  return checkoutBusyOut(json, "call", lock.message, lock.path);
276
285
  }
277
286
  try {
287
+ const authoredMobileRestart = adapter === "mobile" && resolvedAction === "app.lifecycle" && args.command === "restart";
278
288
  const prepared = await prepareHeal(adapter, target, options, json, {
279
- skipMobileSourceFreshness: adapter === "mobile" && (resolvedAction === "app.lifecycle" || readMobileReleaseArtifactState(target) !== null)
289
+ skipMobileSourceFreshness: authoredMobileRestart || adapter === "mobile" && readMobileReleaseArtifactState(target) !== null
280
290
  });
281
291
  if (typeof prepared === "number") return prepared;
282
292
  const { state, heal } = prepared;
293
+ preflightedExecution = await preflightRecipe(
294
+ adapter,
295
+ recipe,
296
+ artifactsDir,
297
+ target,
298
+ actionManifestOverride,
299
+ callRuntimeOptions
300
+ );
283
301
  networkObservation = await startRunNetworkObservation(
284
302
  adapter,
285
303
  target,
@@ -290,6 +308,16 @@ async function handleCall(argv) {
290
308
  watcherPort: callRuntimeOptions.watcherPort
291
309
  }
292
310
  );
311
+ performanceObservation = startRunPerformanceObservation(
312
+ adapter,
313
+ target,
314
+ artifactsDir,
315
+ process.env,
316
+ {
317
+ cdpPort: callRuntimeOptions.cdpPort,
318
+ watcherPort: callRuntimeOptions.watcherPort
319
+ }
320
+ );
293
321
  let executionResult;
294
322
  try {
295
323
  executionResult = await executeWithHealBounds(
@@ -303,7 +331,8 @@ async function handleCall(argv) {
303
331
  target,
304
332
  actionManifestOverride,
305
333
  callRuntimeOptions,
306
- execution
334
+ execution,
335
+ state
307
336
  );
308
337
  },
309
338
  adapter,
@@ -316,13 +345,43 @@ async function handleCall(argv) {
316
345
  })
317
346
  );
318
347
  } catch (error) {
319
- await networkObservation?.finalize();
348
+ await networkObservation?.finalize().catch(() => void 0);
320
349
  networkObservation = void 0;
350
+ await performanceObservation?.finalize().catch(() => void 0);
351
+ performanceObservation = void 0;
352
+ if (error instanceof ProvenanceDriftError) {
353
+ const failure = {
354
+ code: error.code,
355
+ message: error.message,
356
+ userAction: error.userAction,
357
+ provenancePath: error.provenancePath,
358
+ drift: error.drift
359
+ };
360
+ if (json) {
361
+ console.log(JSON.stringify({
362
+ schemaVersion: 1,
363
+ command: "call",
364
+ adapter,
365
+ action: shortName,
366
+ resolvedAction,
367
+ status: "fail",
368
+ error: failure,
369
+ exitCode: error.exitCode
370
+ }, null, 2));
371
+ } else {
372
+ console.error(`\u2717 mm-harness call: ${error.message}`);
373
+ console.error(` Next: ${error.userAction}`);
374
+ }
375
+ return error.exitCode;
376
+ }
321
377
  throw error;
322
378
  }
323
379
  const { result, violation } = executionResult;
324
380
  await networkObservation?.finalize(result.artifactManifestPath);
325
381
  networkObservation = void 0;
382
+ await performanceObservation?.finalize(result.artifactManifestPath);
383
+ performanceObservation = void 0;
384
+ persistRunEffects(result.summaryPath, result.artifactManifestPath, state);
326
385
  if (violation !== null) {
327
386
  const conciseFailure = violation.originalError ? conciseFailureForHuman(violation.originalError) : "";
328
387
  const example = describedAction ? actionExampleCommand(
@@ -28,6 +28,12 @@ import {
28
28
  validateMetaMaskActionInputs
29
29
  } from "../metamask-action-validation.js";
30
30
  import { gitLibraryProvenance } from "../library-provenance.js";
31
+ import {
32
+ captureExecutionProvenance,
33
+ executionProvenanceDrift,
34
+ ProvenanceDriftError,
35
+ writeExecutionProvenance
36
+ } from "../execution-provenance.js";
31
37
  import {
32
38
  authorizeTrustedMutationPlan,
33
39
  loadTrustedMutationBase
@@ -36,7 +42,7 @@ import {
36
42
  beginRunDiagnostics,
37
43
  finishRunDiagnostics
38
44
  } from "../run-diagnostics.js";
39
- import { EXIT, spawnScriptStreaming } from "./shared.js";
45
+ import { EXIT } from "./shared.js";
40
46
  import {
41
47
  actionManifestPathOption,
42
48
  optionString,
@@ -46,13 +52,14 @@ import {
46
52
  targetPath,
47
53
  usageError
48
54
  } from "./parse-args.js";
49
- async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManifestPath, runtimeOptions = {}, preflightedExecution) {
55
+ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManifestPath, runtimeOptions = {}, preflightedExecution, effectsState = newHealState()) {
50
56
  const restoreRuntimeEnvironment = activateRecipeRuntimeEnvironment(
51
57
  adapter,
52
58
  projectRoot,
53
59
  runtimeOptions
54
60
  );
55
61
  try {
62
+ const wasPreflighted = preflightedExecution !== void 0;
56
63
  const execution = preflightedExecution ?? await resolveRecipeExecution(
57
64
  adapter,
58
65
  recipe,
@@ -65,27 +72,48 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
65
72
  runner,
66
73
  runRequest,
67
74
  absoluteArtifactsDir,
68
- useFramedExtensionRecording
75
+ useFramedExtensionRecording,
76
+ provenanceInput,
77
+ provenanceSnapshots
69
78
  } = execution;
70
- await runner.preflight(runRequest);
79
+ if (!wasPreflighted) await runner.preflight(runRequest);
71
80
  await prepareRuntimeIfNeeded(adapter, projectRoot, runtimeOptions);
81
+ await runner.preflight(runRequest);
82
+ const preExecute = await captureExecutionProvenance(
83
+ provenanceInput,
84
+ "pre-execute"
85
+ );
86
+ provenanceSnapshots.push(preExecute);
87
+ const preExecuteDrift = executionProvenanceDrift(
88
+ provenanceSnapshots[0],
89
+ preExecute
90
+ );
91
+ if (preExecuteDrift.length > 0) {
92
+ const provenancePath2 = writeExecutionProvenance(
93
+ absoluteArtifactsDir,
94
+ provenanceSnapshots,
95
+ preExecuteDrift
96
+ );
97
+ throw new ProvenanceDriftError(provenancePath2, preExecuteDrift);
98
+ }
72
99
  const diagnosticBaseline = await beginRunDiagnostics(adapter, projectRoot);
73
100
  const recording = useFramedExtensionRecording ? await startRecipeRecording(adapter, projectRoot, absoluteArtifactsDir, {
74
101
  record: true,
75
102
  cdpPort: runtimeOptions.cdpPort
76
103
  }) : void 0;
77
104
  let result;
105
+ let executionError;
78
106
  try {
79
107
  result = await runner.run(runRequest);
80
108
  } catch (error) {
109
+ executionError = error;
81
110
  try {
82
111
  await stopRecipeRecording(recording);
83
- } catch (recordingError) {
112
+ } catch (recordingError2) {
84
113
  console.error(
85
- `WARN: recipe and video recording both failed; preserving recipe failure: ${recordingError instanceof Error ? recordingError.message : String(recordingError)}`
114
+ `WARN: recipe and video recording both failed; preserving recipe failure: ${recordingError2 instanceof Error ? recordingError2.message : String(recordingError2)}`
86
115
  );
87
116
  }
88
- throw error;
89
117
  } finally {
90
118
  if (adapter === "mobile") {
91
119
  const { hideMobileHudOnTeardown } = await import("../adapters.js");
@@ -95,8 +123,36 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
95
123
  );
96
124
  }
97
125
  }
98
- await stopRecipeRecording(recording, result);
99
- return finishRunDiagnostics(diagnosticBaseline, result);
126
+ let recordingError;
127
+ if (executionError === void 0 && result) {
128
+ try {
129
+ await stopRecipeRecording(recording, result);
130
+ } catch (error) {
131
+ recordingError = error;
132
+ }
133
+ }
134
+ const end = await captureExecutionProvenance(provenanceInput, "end");
135
+ provenanceSnapshots.push(end);
136
+ const endDrift = executionProvenanceDrift(provenanceSnapshots[0], end);
137
+ const provenancePath = writeExecutionProvenance(
138
+ absoluteArtifactsDir,
139
+ provenanceSnapshots,
140
+ endDrift,
141
+ result?.artifactManifestPath
142
+ );
143
+ if (endDrift.length > 0) {
144
+ throw new ProvenanceDriftError(provenancePath, endDrift, executionError);
145
+ }
146
+ if (executionError !== void 0) throw executionError;
147
+ if (recordingError !== void 0) throw recordingError;
148
+ if (!result) throw new Error("Recipe execution returned no result.");
149
+ const finalized = finishRunDiagnostics(diagnosticBaseline, result);
150
+ persistRunEffects(
151
+ finalized.summaryPath,
152
+ finalized.artifactManifestPath,
153
+ effectsState
154
+ );
155
+ return finalized;
100
156
  } finally {
101
157
  restoreRuntimeEnvironment();
102
158
  }
@@ -122,6 +178,23 @@ async function preflightRecipe(adapter, recipe, artifactsDir, projectRoot, actio
122
178
  restoreRuntimeEnvironment();
123
179
  }
124
180
  }
181
+ function persistRunEffects(summaryPath, artifactManifestPath, state) {
182
+ for (const filePath of [summaryPath, artifactManifestPath]) {
183
+ let document;
184
+ try {
185
+ document = JSON.parse(fs.readFileSync(filePath, "utf8"));
186
+ } catch {
187
+ document = null;
188
+ }
189
+ if (!isRecord(document)) {
190
+ throw new Error(`Recipe evidence file is not valid JSON: ${filePath}`);
191
+ }
192
+ document.recovered = [...state.recovered];
193
+ document.mutations = state.mutations.map((mutation) => ({ ...mutation }));
194
+ fs.writeFileSync(filePath, `${JSON.stringify(document, null, 2)}
195
+ `);
196
+ }
197
+ }
125
198
  async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot, actionManifestPath, runtimeOptions) {
126
199
  const absoluteArtifactsDir = path.resolve(artifactsDir);
127
200
  const absoluteRecipePath = typeof recipe === "string" ? path.resolve(recipe) : void 0;
@@ -173,7 +246,7 @@ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot
173
246
  });
174
247
  const useFramedExtensionRecording = adapter === "extension" && recordVideo === "full-run" && trust.source?.trust !== "untrusted" && trust.source?.trust !== "unknown" && captureHelperSupportsRecordSessionSnapshots(projectRoot);
175
248
  const runRequest = {
176
- ...absoluteRecipePath ? { recipePath: absoluteRecipePath } : { recipeDocument: recipe },
249
+ ...recipeDocument !== void 0 ? { recipeDocument } : absoluteRecipePath ? { recipePath: absoluteRecipePath } : { recipeDocument: recipe },
177
250
  artifactsDir: absoluteArtifactsDir,
178
251
  projectRoot,
179
252
  inheritProcessEnv: false,
@@ -196,13 +269,139 @@ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot
196
269
  trustedMutation: authorizedMutation
197
270
  });
198
271
  }
272
+ const provenanceInput = {
273
+ adapter,
274
+ projectRoot,
275
+ recipeDocument: recipeDocument ?? recipe,
276
+ ...absoluteRecipePath ? { recipePath: absoluteRecipePath } : {},
277
+ ...librarySources ? { librarySources } : {},
278
+ excludedProductRoots: [absoluteArtifactsDir],
279
+ helperPaths: commandHelperPaths(recipeDocument ?? recipe, projectRoot)
280
+ };
281
+ const startProvenance = await captureExecutionProvenance(
282
+ provenanceInput,
283
+ "start"
284
+ );
199
285
  return {
200
286
  runner,
201
287
  absoluteArtifactsDir,
202
288
  useFramedExtensionRecording,
203
- runRequest
289
+ runRequest,
290
+ provenanceInput,
291
+ provenanceSnapshots: [startProvenance]
204
292
  };
205
293
  }
294
+ function commandHelperPaths(recipe, projectRoot) {
295
+ const helpers = /* @__PURE__ */ new Set();
296
+ visitRecipeValues(recipe, (value) => {
297
+ if (!isRecord(value) || value.action !== "command" || typeof value.cmd !== "string") return;
298
+ const candidates = commandHelperTokens(value.cmd);
299
+ for (const token of candidates) {
300
+ if (/[$`{}]/u.test(token)) {
301
+ throw new Error(`Command helper path cannot be bound before execution: ${token}`);
302
+ }
303
+ if (!isHelperToken(token)) continue;
304
+ const absolute = path.isAbsolute(token) ? path.resolve(token) : path.resolve(projectRoot, token);
305
+ let stat;
306
+ try {
307
+ stat = fs.lstatSync(absolute);
308
+ } catch {
309
+ throw new Error(`Command helper source does not exist: ${token}`);
310
+ }
311
+ if (stat.isSymbolicLink() || !stat.isFile()) {
312
+ throw new Error(`Command helper source must be a regular file: ${token}`);
313
+ }
314
+ helpers.add(absolute);
315
+ }
316
+ });
317
+ return [...helpers].sort();
318
+ }
319
+ function commandHelperTokens(command, depth = 0) {
320
+ if (depth > 4) {
321
+ throw new Error("Command helper sources exceed the supported shell nesting depth.");
322
+ }
323
+ const tokens = shellTokens(command);
324
+ const candidates = new Set(tokens.filter(isHelperToken));
325
+ for (const token of tokens) {
326
+ const assignment = /^(?:--)?(?:config|configuration|manifest|project|recipe|schema|tsconfig)=([^=]+)$/iu.exec(
327
+ token
328
+ );
329
+ const value = assignment?.[1];
330
+ if (value && (isHelperToken(value) || isConfigInputToken(value))) {
331
+ candidates.add(value);
332
+ }
333
+ }
334
+ for (let index = 0; index < tokens.length - 1; index += 1) {
335
+ if (!isScriptInterpreter(tokens[index])) continue;
336
+ const option = tokens[index + 1];
337
+ if (isShellInterpreter(tokens[index]) && /^-[a-z]*c[a-z]*$/iu.test(option) && tokens[index + 2]) {
338
+ for (const nested of commandHelperTokens(tokens[index + 2], depth + 1)) {
339
+ candidates.add(nested);
340
+ }
341
+ continue;
342
+ }
343
+ if (!option.startsWith("-")) candidates.add(option);
344
+ }
345
+ return candidates;
346
+ }
347
+ function visitRecipeValues(value, visit) {
348
+ visit(value);
349
+ if (Array.isArray(value)) {
350
+ for (const child of value) visitRecipeValues(child, visit);
351
+ return;
352
+ }
353
+ if (!isRecord(value)) return;
354
+ for (const child of Object.values(value)) visitRecipeValues(child, visit);
355
+ }
356
+ function isHelperToken(token) {
357
+ return !token.startsWith("-") && !token.includes("=") && !/^[a-z][a-z0-9+.-]*:\/\//iu.test(token) && /^\S+\.(?:cjs|mjs|js|jsx|ts|tsx|sh|py)$/u.test(token);
358
+ }
359
+ function isConfigInputToken(token) {
360
+ return !token.startsWith("-") && !token.includes("=") && /^\S+\.(?:json|json5|ya?ml|toml)$/u.test(token);
361
+ }
362
+ function isScriptInterpreter(token) {
363
+ return /^(?:node|bash|sh|python|python3)$/u.test(path.basename(token));
364
+ }
365
+ function isShellInterpreter(token) {
366
+ return /^(?:bash|sh)$/u.test(path.basename(token));
367
+ }
368
+ function shellTokens(command) {
369
+ const tokens = [];
370
+ let token = "";
371
+ let quote = null;
372
+ let escaped = false;
373
+ const push = () => {
374
+ if (token) tokens.push(token);
375
+ token = "";
376
+ };
377
+ for (const character of command) {
378
+ if (escaped) {
379
+ token += character;
380
+ escaped = false;
381
+ continue;
382
+ }
383
+ if (character === "\\" && quote !== "'") {
384
+ escaped = true;
385
+ continue;
386
+ }
387
+ if (quote) {
388
+ if (character === quote) quote = null;
389
+ else token += character;
390
+ continue;
391
+ }
392
+ if (character === "'" || character === '"') {
393
+ quote = character;
394
+ continue;
395
+ }
396
+ if (/\s|[;&|<>]/u.test(character)) push();
397
+ else token += character;
398
+ }
399
+ if (quote || escaped) {
400
+ throw new Error("Command helper sources cannot be bound from an unterminated shell token.");
401
+ }
402
+ push();
403
+ return tokens;
404
+ }
206
405
  function runtimeRecipeDocument(recipe, absoluteRecipePath) {
207
406
  if (typeof recipe !== "string") return recipe;
208
407
  if (!absoluteRecipePath || !fs.existsSync(absoluteRecipePath)) return void 0;
@@ -241,7 +440,24 @@ function activateRecipeRuntimeEnvironment(adapter, projectRoot, runtimeOptions)
241
440
  const previousAndroidPackageId = process.env.ANDROID_PACKAGE_ID;
242
441
  const previousAdbSerial = process.env.ADB_SERIAL;
243
442
  const previousAndroidSerial = process.env.ANDROID_SERIAL;
443
+ const explicitMobileDeviceEnv = adapter === "mobile" && process.env.MM_HARNESS_EXPLICIT_PLATFORM ? Object.fromEntries(
444
+ [
445
+ "PLATFORM",
446
+ "MM_HARNESS_EXPLICIT_PLATFORM",
447
+ "IOS_SIMULATOR",
448
+ "SIM_UDID",
449
+ "ADB_SERIAL",
450
+ "ANDROID_SERIAL",
451
+ "ANDROID_DEVICE",
452
+ "ANDROID_TARGET_DEVICE_NAME"
453
+ ].map((key) => [key, process.env[key]])
454
+ ) : void 0;
244
455
  getAdapterSurface(adapter).resolveSlotPorts(projectRoot);
456
+ if (explicitMobileDeviceEnv) {
457
+ for (const [key, value] of Object.entries(explicitMobileDeviceEnv)) {
458
+ restoreEnv(key, value);
459
+ }
460
+ }
245
461
  if (runtimeOptions.cdpPort) {
246
462
  process.env.CDP_PORT = runtimeOptions.cdpPort;
247
463
  process.env.RECIPE_CDP_PORT = runtimeOptions.cdpPort;
@@ -921,19 +1137,15 @@ async function prepareHeal(adapter, target, options, json, prepareOptions = {})
921
1137
  target,
922
1138
  heal,
923
1139
  state,
924
- json,
925
- {
926
- onRecovery: (code) => prepareOptions.onPhase?.("recover", { code })
927
- }
1140
+ json
928
1141
  );
929
1142
  if (current !== null) return current;
930
1143
  }
931
1144
  return { state, heal };
932
1145
  }
933
- async function ensureMobileProofRuntime(target, heal, state, json, deps = {}) {
1146
+ async function ensureMobileProofRuntime(target, _heal, state, json, deps = {}) {
934
1147
  const source = await import("../adapters/mobile/source-freshness.js");
935
1148
  const check = deps.check ?? (() => source.mobileSourceCheck(target));
936
- const record = deps.record ?? ((fingerprint) => source.recordMobileSourceBaseline(target, fingerprint));
937
1149
  const initial = check();
938
1150
  if (initial.status === "unavailable") {
939
1151
  if (json) {
@@ -958,99 +1170,26 @@ async function ensureMobileProofRuntime(target, heal, state, json, deps = {}) {
958
1170
  if (initial.status === "current") return null;
959
1171
  const targetArg = JSON.stringify(path.resolve(target));
960
1172
  const userAction = `mm-harness call app.lifecycle --arg command=restart --adapter mobile --target ${targetArg}`;
961
- if (heal === "off") {
962
- const message = `Mobile loaded source is not current (${initial.status}).`;
963
- if (json) {
964
- console.log(
965
- JSON.stringify(
966
- {
967
- schemaVersion: 1,
968
- status: "fail",
969
- recoverable: true,
970
- mutations: state.mutations,
971
- error: { code: "MOBILE_SOURCE_NOT_LOADED", message, userAction }
972
- },
973
- null,
974
- 2
975
- )
976
- );
977
- } else {
978
- console.error(`\u2717 ${message}`);
979
- console.error(` Next: ${userAction}`);
980
- }
981
- return EXIT.infra;
982
- }
983
- const recoveryCode = "mobile.source_reloaded";
984
- state.attemptedRecoveries.push(recoveryCode);
985
- deps.onRecovery?.(recoveryCode);
986
- if (!json)
987
- console.error(
988
- `\u2192 Mobile proof preflight: source ${initial.status}; restarting the app before execution`
989
- );
990
- const restart = deps.restart ?? (async () => {
991
- const result2 = await runOneNode(
992
- "mobile",
993
- "app.lifecycle",
994
- { command: "restart" },
995
- target,
996
- void 0
1173
+ const message = `Mobile loaded source is not current (${initial.status}).`;
1174
+ if (json) {
1175
+ console.log(
1176
+ JSON.stringify(
1177
+ {
1178
+ schemaVersion: 1,
1179
+ status: "fail",
1180
+ recoverable: true,
1181
+ mutations: state.mutations,
1182
+ error: { code: "MOBILE_SOURCE_NOT_LOADED", message, userAction }
1183
+ },
1184
+ null,
1185
+ 2
1186
+ )
997
1187
  );
998
- return {
999
- status: result2.status === "pass" ? 0 : EXIT.runtime,
1000
- output: result2.status
1001
- };
1002
- });
1003
- const result = await restart();
1004
- const platform = resolveRunMobilePlatform();
1005
- const bridge = result.status === 0 ? await (deps.waitForBridge ? deps.waitForBridge() : spawnScriptStreaming(
1006
- path.join(runnerDir, "adapters/mobile/wait-for-bridge.sh"),
1007
- ["--target", target, "--platform", platform],
1008
- target
1009
- )) : { status: result.status, output: result.output };
1010
- const recorded = result.status === 0 && bridge.status === 0 && record(initial.fingerprint);
1011
- const verified = recorded ? check() : initial;
1012
- if (result.status !== 0 || bridge.status !== 0 || !recorded || verified.status !== "current") {
1013
- const message = bridge.status !== 0 ? "Mobile source restart completed but the platform-matched bridge did not become ready." : "Mobile source restart did not produce a current loaded-source baseline.";
1014
- if (json) {
1015
- console.log(
1016
- JSON.stringify(
1017
- {
1018
- schemaVersion: 1,
1019
- status: "fail",
1020
- recoverable: false,
1021
- attemptedRecoveries: state.attemptedRecoveries,
1022
- error: {
1023
- code: bridge.status !== 0 ? "MOBILE_BRIDGE_NOT_READY" : "MOBILE_SOURCE_RELOAD_FAILED",
1024
- message,
1025
- userAction,
1026
- originalError: bridge.output || result.output
1027
- }
1028
- },
1029
- null,
1030
- 2
1031
- )
1032
- );
1033
- } else {
1034
- console.error(`\u2717 ${message}`);
1035
- console.error(
1036
- ` Next: ${bridge.status !== 0 ? "inspect the bridge-status log and rerun mm-harness launch <platform>" : userAction}`
1037
- );
1038
- }
1039
- return EXIT.infra;
1040
- }
1041
- state.recovered.push(recoveryCode);
1042
- state.mutations.push({
1043
- type: "runtime",
1044
- action: "restarted",
1045
- reasonCode: `source-${initial.status}`
1046
- });
1047
- return null;
1048
- }
1049
- function resolveRunMobilePlatform() {
1050
- if (process.env.PLATFORM === "android" || process.env.PLATFORM === "ios") {
1051
- return process.env.PLATFORM;
1188
+ } else {
1189
+ console.error(`\u2717 ${message}`);
1190
+ console.error(` Next: ${userAction}`);
1052
1191
  }
1053
- return process.env.ADB_SERIAL || process.env.ANDROID_SERIAL ? "android" : "ios";
1192
+ return EXIT.infra;
1054
1193
  }
1055
1194
  async function ensureExtensionProofRuntime(target, heal, state, json, deps = {}) {
1056
1195
  const decide = deps.decide ?? (async () => {
@@ -1246,26 +1385,10 @@ async function recoverRunInfra(adapter, target, json, runtimeOptions = {}) {
1246
1385
  return launchExtension(target, "quick", false);
1247
1386
  }
1248
1387
  if (adapter === "mobile") {
1249
- try {
1250
- const artifact = requireCurrentMobileReleaseArtifactState(target);
1251
- if (artifact) {
1252
- const { relaunchAndroidReleaseArtifactRuntime } = await import("../adapters/mobile/release-artifact.js");
1253
- await relaunchAndroidReleaseArtifactRuntime(artifact);
1254
- return {
1255
- status: 0,
1256
- output: `Relaunched ${artifact.packageId} ${artifact.versionName} (${artifact.versionCode}) on ${artifact.deviceSerial}.`
1257
- };
1258
- }
1259
- } catch (error) {
1260
- return {
1261
- status: 1,
1262
- output: `Release artifact recovery could not revalidate and relaunch the pinned Android package: ${error instanceof Error ? error.message : String(error)}
1263
- Next: rerun runtime-launch with the same artifact source.`
1264
- };
1265
- }
1266
- const { launchMobile } = await import("./launch/mobile.js");
1267
- const platform = resolveMobileRecoveryPlatform();
1268
- return launchMobile(target, platform, "quick", json);
1388
+ return {
1389
+ status: 1,
1390
+ output: "Automatic Mobile runtime recovery is disabled.\nNext: use an explicit app.lifecycle action when the recipe intends to relaunch the app."
1391
+ };
1269
1392
  }
1270
1393
  return { status: 0, output: "headless run retry" };
1271
1394
  } finally {
@@ -1273,15 +1396,6 @@ Next: rerun runtime-launch with the same artifact source.`
1273
1396
  restoreEnv("RECIPE_CDP_PORT", previousRecipeCdpPort);
1274
1397
  }
1275
1398
  }
1276
- function resolveMobileRecoveryPlatform(env = process.env) {
1277
- if (env.PLATFORM === "android" || env.PLATFORM === "ios") return env.PLATFORM;
1278
- const androidPinned = Boolean(
1279
- env.ADB_SERIAL || env.ANDROID_SERIAL || env.ANDROID_TARGET_DEVICE_NAME || env.ANDROID_DEVICE
1280
- );
1281
- const iosPinned = Boolean(env.IOS_SIMULATOR || env.SIM_UDID);
1282
- if (androidPinned && !iosPinned) return "android";
1283
- return "ios";
1284
- }
1285
1399
  function readRunFailureText(result) {
1286
1400
  try {
1287
1401
  const trace = JSON.parse(fs.readFileSync(result.tracePath, "utf8"));
@@ -1303,11 +1417,22 @@ async function executeWithHealBounds(exec, adapter, target, heal, state, recover
1303
1417
  state
1304
1418
  );
1305
1419
  if (violation !== null) return { result, violation };
1420
+ if (adapter === "mobile") return { result, violation: null };
1306
1421
  if (heal === "off") return { result, violation: null };
1307
1422
  const recoveryCode = RUN_RECOVERY_CODE[adapter];
1308
1423
  state.attemptedRecoveries.push(recoveryCode);
1309
1424
  onRecovery(recoveryCode);
1310
- await recover();
1425
+ const recovery = await recover();
1426
+ if (recovery.status !== 0) {
1427
+ return { result, violation: null };
1428
+ }
1429
+ if (adapter === "extension") {
1430
+ state.mutations.push({
1431
+ type: "runtime",
1432
+ action: "reloaded",
1433
+ reasonCode: recoveryCode
1434
+ });
1435
+ }
1311
1436
  result = await exec();
1312
1437
  if (result.status === "pass") {
1313
1438
  state.recovered.push(recoveryCode);
@@ -1362,6 +1487,7 @@ function countRecipeNodes(recipe) {
1362
1487
  return nodes ? Object.keys(nodes).length : void 0;
1363
1488
  }
1364
1489
  export {
1490
+ activateRecipeRuntimeEnvironment,
1365
1491
  countRecipeNodes,
1366
1492
  describeRunnableRecipe,
1367
1493
  emitHealViolation,
@@ -1369,12 +1495,12 @@ export {
1369
1495
  ensureMobileProofRuntime,
1370
1496
  executeWithHealBounds,
1371
1497
  listRunnableRecipes,
1498
+ persistRunEffects,
1372
1499
  preflightRecipe,
1373
1500
  prepareHeal,
1374
1501
  prepareRuntimeIfNeeded,
1375
1502
  recoverRunInfra,
1376
1503
  resolveMetaMaskLibrarySources,
1377
- resolveMobileRecoveryPlatform,
1378
1504
  resolveRunRecipeArg,
1379
1505
  runOneNode,
1380
1506
  runRecipe,