@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
@@ -8,6 +8,7 @@ import { ensureExtensionConsoleCapture } from "./adapters/extension/console-capt
8
8
  const MAX_CAPTURE_BYTES = 512 * 1024;
9
9
  const MAX_FINDINGS = 20;
10
10
  const MAX_PREVIEW_CHARS = 320;
11
+ const RAW_APP_LOG_ARTIFACT = "diagnostics-app-log.txt";
11
12
  function readRunDiagnosticsDocument(diagnosticsPath) {
12
13
  if (typeof diagnosticsPath !== "string" || diagnosticsPath.length === 0) return null;
13
14
  try {
@@ -53,11 +54,15 @@ function finishRunDiagnostics(baseline, result) {
53
54
  if (!baseline) return result;
54
55
  try {
55
56
  const bufferedIssues = baseline.mobileIssueBuffer ? collectMobileIssueBuffer(baseline.mobileIssueBuffer.projectRoot) : void 0;
56
- const diagnostics = collectRunDiagnostics(baseline, bufferedIssues);
57
+ const { diagnostics, rawAppLog } = collectRunDiagnosticsCapture(baseline, bufferedIssues);
57
58
  const artifactsDir = path.dirname(result.summaryPath);
58
59
  const diagnosticsPath = path.join(artifactsDir, "diagnostics.json");
59
60
  fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}
60
61
  `);
62
+ fs.writeFileSync(
63
+ path.join(artifactsDir, RAW_APP_LOG_ARTIFACT),
64
+ redactDiagnosticText(rawAppLog.toString("utf8"))
65
+ );
61
66
  indexDiagnosticArtifact(result.artifactManifestPath);
62
67
  indexDiagnosticSummary(result.summaryPath, diagnostics);
63
68
  return {
@@ -83,6 +88,9 @@ function finishRunDiagnostics(baseline, result) {
83
88
  }
84
89
  }
85
90
  function collectRunDiagnostics(baseline, bufferedIssues) {
91
+ return collectRunDiagnosticsCapture(baseline, bufferedIssues).diagnostics;
92
+ }
93
+ function collectRunDiagnosticsCapture(baseline, bufferedIssues) {
86
94
  const stat = safeStat(baseline.source.path);
87
95
  const source = {
88
96
  label: baseline.source.label,
@@ -91,9 +99,11 @@ function collectRunDiagnostics(baseline, bufferedIssues) {
91
99
  endOffset: stat?.size ?? baseline.offset,
92
100
  bytesRead: 0,
93
101
  truncated: false,
94
- inAppBuffer: bufferedIssues === void 0 ? "n/a" : bufferedIssues === null ? "unavailable" : "collected"
102
+ inAppBuffer: bufferedIssues === void 0 ? "n/a" : bufferedIssues === null ? "unavailable" : "collected",
103
+ rawArtifact: RAW_APP_LOG_ARTIFACT,
104
+ redacted: true
95
105
  };
96
- let text = "";
106
+ let rawAppLog = Buffer.alloc(0);
97
107
  if (stat) {
98
108
  const sameFile = baseline.inode === void 0 || baseline.inode === stat.ino;
99
109
  const startOffset = sameFile && stat.size >= baseline.offset ? baseline.offset : 0;
@@ -103,8 +113,9 @@ function collectRunDiagnostics(baseline, bufferedIssues) {
103
113
  source.endOffset = stat.size;
104
114
  source.bytesRead = bytesToRead;
105
115
  source.truncated = available > MAX_CAPTURE_BYTES;
106
- if (bytesToRead > 0) text = readSlice(baseline.source.path, startOffset, bytesToRead);
116
+ if (bytesToRead > 0) rawAppLog = readSlice(baseline.source.path, startOffset, bytesToRead);
107
117
  }
118
+ const text = rawAppLog.toString("utf8");
108
119
  const allFindings = dedupeFindings(
109
120
  [
110
121
  ...text.split(/\r?\n/u).map(classifyLine).filter((finding) => finding !== null),
@@ -113,9 +124,10 @@ function collectRunDiagnostics(baseline, bufferedIssues) {
113
124
  );
114
125
  const findings = allFindings.slice(0, MAX_FINDINGS);
115
126
  const counts = countFindings(allFindings);
127
+ const omittedFindingCount = Math.max(0, allFindings.length - findings.length);
116
128
  const status = counts.total > 0 ? "review" : stat || bufferedIssues !== void 0 && bufferedIssues !== null ? "clean" : "unavailable";
117
- const note = counts.total > 0 ? `Observed ${counts.total} distinct application warning/error event(s) during the recipe run; relation to the task is not determined.` : status === "clean" ? "No application warnings or errors were emitted during the recipe run." : "Application diagnostics were unavailable for this run.";
118
- return {
129
+ const note = counts.total > 0 ? `Observed ${counts.total} distinct application warning/error event(s) during the recipe run; showing ${findings.length} preview(s) and omitting ${omittedFindingCount}. Relation to the task is not determined.` : status === "clean" ? "No application warnings or errors were emitted during the recipe run." : "Application diagnostics were unavailable for this run.";
130
+ return { diagnostics: {
119
131
  schemaVersion: 1,
120
132
  scope: "recipe-run-application",
121
133
  status,
@@ -123,8 +135,10 @@ function collectRunDiagnostics(baseline, bufferedIssues) {
123
135
  note,
124
136
  source,
125
137
  counts,
138
+ displayedFindingCount: findings.length,
139
+ omittedFindingCount,
126
140
  findings
127
- };
141
+ }, rawAppLog };
128
142
  }
129
143
  function classifyLine(line) {
130
144
  const trimmed = line.trim();
@@ -180,7 +194,10 @@ function runMobileIssueCommand(projectRoot, command) {
180
194
  }
181
195
  }
182
196
  function redactPreview(value) {
183
- return value.replace(/\b(Bearer)\s+\S+/giu, "$1 [REDACTED]").replace(/\b(api[-_]?key|password|passphrase|mnemonic|seed(?:Phrase)?|privateKey|secret|token|authorization|vault)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/giu, "$1=[REDACTED]").replace(/\b(?:0x)?[a-f0-9]{64,}\b/giu, "[REDACTED_HEX]").replace(/(https?:\/\/[^\s?]+)\?\S+/giu, "$1?[REDACTED_QUERY]").slice(0, MAX_PREVIEW_CHARS);
197
+ return redactDiagnosticText(value).slice(0, MAX_PREVIEW_CHARS);
198
+ }
199
+ function redactDiagnosticText(value) {
200
+ return value.replace(/\b(Bearer)\s+\S+/giu, "$1 [REDACTED]").replace(/(["'])(api[-_]?key|password|passphrase|mnemonic|seed(?:Phrase)?|privateKey|secret|token|authorization|vault)\1\s*:\s*(?:"[^"]*"|'[^']*'|[^,\s}\]]+)/giu, '$1$2$1:"[REDACTED]"').replace(/\b(api[-_]?key|password|passphrase|mnemonic|seed(?:Phrase)?|privateKey|secret|token|authorization|vault)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/giu, "$1=[REDACTED]").replace(/\b(?:0x)?[a-f0-9]{64,}\b/giu, "[REDACTED_HEX]").replace(/(https?:\/\/[^\s?]+)\?\S+/giu, "$1?[REDACTED_QUERY]");
184
201
  }
185
202
  function dedupeFindings(findings) {
186
203
  const byFingerprint = /* @__PURE__ */ new Map();
@@ -201,7 +218,7 @@ function readSlice(filePath, offset, length) {
201
218
  try {
202
219
  const buffer = Buffer.alloc(length);
203
220
  const bytesRead = fs.readSync(fd, buffer, 0, length, offset);
204
- return buffer.subarray(0, bytesRead).toString("utf8");
221
+ return buffer.subarray(0, bytesRead);
205
222
  } finally {
206
223
  fs.closeSync(fd);
207
224
  }
@@ -218,12 +235,18 @@ function indexDiagnosticArtifact(manifestPath) {
218
235
  if (!manifest) return;
219
236
  const artifacts = Array.isArray(manifest.artifacts) ? manifest.artifacts : [];
220
237
  manifest.artifacts = [
221
- ...artifacts.filter((artifact) => !isRecord(artifact) || artifact.path !== "diagnostics.json"),
238
+ ...artifacts.filter((artifact) => !isRecord(artifact) || !["diagnostics.json", RAW_APP_LOG_ARTIFACT].includes(String(artifact.path))),
222
239
  {
223
240
  path: "diagnostics.json",
224
241
  type: "json",
225
242
  label: "Run-scoped application diagnostics",
226
243
  category: "diagnostic"
244
+ },
245
+ {
246
+ path: RAW_APP_LOG_ARTIFACT,
247
+ type: "log",
248
+ label: "Redacted run-scoped application log slice",
249
+ category: "diagnostic"
227
250
  }
228
251
  ];
229
252
  fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}
@@ -236,7 +259,9 @@ function indexDiagnosticSummary(summaryPath, diagnostics) {
236
259
  status: diagnostics.status,
237
260
  nonBlocking: true,
238
261
  counts: diagnostics.counts,
239
- diagnosticsPath: "diagnostics.json"
262
+ diagnosticsPath: "diagnostics.json",
263
+ appLogPath: RAW_APP_LOG_ARTIFACT,
264
+ appLogRedacted: true
240
265
  };
241
266
  fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}
242
267
  `);
package/dist/runner.js CHANGED
@@ -2,6 +2,7 @@ import { execFileSync, execSync } from "node:child_process";
2
2
  import { OFFICIAL_RECIPE_ACTIONS } from "@farmslot/protocol";
3
3
  import { createMetaMaskAdapters, createMetaMaskUiTransport } from "./adapters.js";
4
4
  import { bridgeCommand } from "../library/actions/mobile/platform/bridge.mjs";
5
+ import { resolveMobileToolPath } from "../library/actions/mobile/platform/tool-paths.mjs";
5
6
  import {
6
7
  mobileSourceFingerprint,
7
8
  recordMobileSourceBaseline
@@ -334,21 +335,29 @@ function mobileSourceAwareLifecycleAdapters(adapter, lifecycle, readinessProbe =
334
335
  fingerprint: mobileSourceFingerprint,
335
336
  record: recordMobileSourceBaseline
336
337
  }, processIdentity = {
337
- readIosPid: readIosAppPid
338
+ readIosPid: readIosAppPid,
339
+ readAndroidPid: readAndroidAppPid
338
340
  }) {
339
341
  if (adapter !== "mobile") return lifecycle;
340
342
  return lifecycle.map((entry) => ({
341
343
  ...entry,
342
344
  async execute(node, context) {
343
- if (context.env?.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1") {
345
+ const command = node.command ?? node.event ?? node.state;
346
+ const opaqueRuntime = context.env?.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1";
347
+ const requiresProcessContinuity = command !== "restart" && node.require_process_continuity === true;
348
+ if (opaqueRuntime && !requiresProcessContinuity) {
344
349
  return entry.execute(node, context);
345
350
  }
346
- const command = node.command ?? node.event ?? node.state;
347
- const reloadsSource = command === "launch" || command === "foreground" || command === "restart";
348
- const checksIosContinuity = command === "foreground" && !isAndroidLifecycle(node, context.env ?? {});
349
- const processBefore = checksIosContinuity ? processIdentity.readIosPid(node, context) : void 0;
350
- const fingerprint = reloadsSource ? sourceFreshness.fingerprint(context.projectRoot) : void 0;
351
- const androidRestart = command === "restart" && isAndroidLifecycle(node, context.env ?? {});
351
+ const reloadsSource = !opaqueRuntime && (command === "launch" || command === "foreground" || command === "restart");
352
+ const recordsSource = !opaqueRuntime && command === "restart";
353
+ const androidLifecycle = isAndroidLifecycle(node, context.env ?? {});
354
+ const checksIosContinuity = command === "foreground" && !androidLifecycle;
355
+ const checksRequiredContinuity = requiresProcessContinuity;
356
+ const recordsProcessIdentity = checksIosContinuity || checksRequiredContinuity;
357
+ const readProcessId = androidLifecycle ? processIdentity.readAndroidPid ?? readAndroidAppPid : processIdentity.readIosPid;
358
+ const processBefore = recordsProcessIdentity ? readProcessId(node, context) : void 0;
359
+ const fingerprint = recordsSource ? sourceFreshness.fingerprint(context.projectRoot) : void 0;
360
+ const androidRestart = command === "restart" && androidLifecycle;
352
361
  const initialNode = androidRestart ? { ...node, settle_ms: 0 } : node;
353
362
  const initialResult = await entry.execute(initialNode, context);
354
363
  let result = initialResult;
@@ -362,22 +371,26 @@ function mobileSourceAwareLifecycleAdapters(adapter, lifecycle, readinessProbe =
362
371
  if (reloadsSource) {
363
372
  await readinessProbe(node, context);
364
373
  }
365
- if (checksIosContinuity) {
366
- const processAfter = processIdentity.readIosPid(node, context);
367
- const actualTransition = processBefore !== null && processBefore === processAfter ? "foreground_resume" : processAfter !== null ? "cold_relaunch" : "unknown";
374
+ if (recordsProcessIdentity) {
375
+ const processAfter = readProcessId(node, context);
376
+ const processPreserved = processBefore !== null && processBefore === processAfter;
377
+ const actualTransition = processPreserved ? command === "background" ? "background_same_process" : "foreground_resume" : processAfter !== null ? "cold_relaunch" : "unknown";
378
+ const processContinuity = processPreserved ? "preserved" : processAfter === null ? "unknown" : "changed";
368
379
  const resultRecord = asRecord(result);
369
380
  result = {
370
381
  ...resultRecord,
371
382
  output: {
372
383
  ...asRecord(resultRecord.output),
373
384
  actualTransition,
385
+ processContinuity,
374
386
  processBefore,
375
387
  processAfter
376
388
  }
377
389
  };
378
- if (node.require_process_continuity === true && actualTransition !== "foreground_resume") {
390
+ if (checksRequiredContinuity && !processPreserved) {
391
+ const platform = androidLifecycle ? "Android" : "iOS";
379
392
  throw new Error(
380
- `iOS foreground did not preserve the app process (actualTransition=${actualTransition}, before=${String(processBefore)}, after=${String(processAfter)}); this lifecycle sample is a cold relaunch, not a reconnect.`
393
+ `${platform} ${String(command)} did not prove app-process continuity (actualTransition=${actualTransition}, before=${String(processBefore)}, after=${String(processAfter)}); this lifecycle sample cannot be classified as a same-process reconnect.`
381
394
  );
382
395
  }
383
396
  }
@@ -390,6 +403,24 @@ function mobileSourceAwareLifecycleAdapters(adapter, lifecycle, readinessProbe =
390
403
  }
391
404
  }));
392
405
  }
406
+ function readAndroidAppPid(node, context) {
407
+ const env = context.env ?? {};
408
+ const device = node.adb_serial ?? node.android_device ?? node.device ?? env.ADB_SERIAL ?? env.ANDROID_SERIAL ?? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL;
409
+ const packageId = node.package_id ?? node.packageName ?? node.app_id ?? env.ANDROID_PACKAGE_ID ?? process.env.ANDROID_PACKAGE_ID ?? "io.metamask";
410
+ if (typeof device !== "string" || typeof packageId !== "string") return null;
411
+ try {
412
+ const adb = resolveMobileToolPath("adb", { required: true });
413
+ const output = execFileSync(
414
+ adb,
415
+ ["-s", device, "shell", "pidof", packageId],
416
+ { encoding: "utf8", timeout: 5e3, stdio: ["ignore", "pipe", "ignore"] }
417
+ );
418
+ const pid = Number.parseInt(output.trim().split(/\s+/u)[0] ?? "", 10);
419
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
420
+ } catch {
421
+ return null;
422
+ }
423
+ }
393
424
  function readIosAppPid(node, context) {
394
425
  const env = context.env ?? {};
395
426
  const device = node.simulator ?? node.ios_simulator ?? env.SIM_UDID ?? env.IOS_SIMULATOR ?? process.env.SIM_UDID ?? process.env.IOS_SIMULATOR;
@@ -0,0 +1,33 @@
1
+ # UI smoothness capture
2
+
3
+ `app.performance_capture` attributes CDP trace samples to the exact Recipe Protocol v1 nodes that run inside an explicit window. Mobile and Extension use the same action contract.
4
+
5
+ ```json
6
+ {
7
+ "action": "app.performance_capture",
8
+ "phase": "start",
9
+ "id": "market-scroll",
10
+ "max_duration_ms": 60000,
11
+ "intent": "Start UI smoothness capture",
12
+ "next": "scroll"
13
+ }
14
+ ```
15
+
16
+ End the same ID with `phase: "end"`. The action writes bounded JSON and a self-contained HTML report. `app.performance_assert` can require a status, minimum frame count, and specific node intervals.
17
+
18
+ ## Sources and status
19
+
20
+ - Mobile uses React Native's `Tracing.start`, `Tracing.dataCollected`, and `Tracing.end` implementation. iOS frame timings come from its built-in `CADisplayLink` observer. Android frame timings come from `Window.FrameMetrics`. The app build must report `unstable_frameRecordingEnabled: true`; otherwise native UI coverage is partial or unavailable.
21
+ - Extension uses Chromium's implementation of the same CDP Tracing domain. Frame and JavaScript data count only after the harness proves they belong to the MetaMask renderer. Unresolved browser-wide events produce partial or unavailable coverage.
22
+ - The harness records a CDP clock marker and maps trace timestamps onto recipe-node timestamps. It waits for `Tracing.tracingComplete` before writing evidence.
23
+ - The JSON report includes bounded trace evidence counts for `BeginFrame`, `DrawFrame`, `RunTask`, and `ProfileChunk`, plus data-loss and retention-overflow flags.
24
+ - UI frames and JavaScript work remain separate sources. `RunTask` events produce JavaScript task summaries. Sampling `ProfileChunk` events prove profiler data exists but are not converted to task timing or FPS. JavaScript work is never labelled as UI or JS FPS.
25
+ - `complete`, `partial`, and `unavailable` describe source coverage. Missing data is never converted to zero.
26
+
27
+ `app.performance_assert.minimum_frame_count` applies only to native UI or Chromium renderer frames. JavaScript task count cannot satisfy it.
28
+
29
+ Capture is explicit only. Use the action for comparisons on the same runtime and build. Automatic capture remains disabled until its overhead passes a matched benchmark.
30
+
31
+ ## Sentry promotion boundary
32
+
33
+ This action does not send data to Sentry. A future product integration may promote only bounded aggregates after release-build overhead is independently accepted: source status, stable node/action name, frame count, p50/p95/p99, jank percentage, and longest frame. Do not send raw frame samples, recipe parameters, wallet/account identity, URLs, or artifact contents.
package/docs/RECIPES.md CHANGED
@@ -269,6 +269,13 @@ events. The result distinguishes complete, partial, and unavailable evidence
269
269
  so a target rotation cannot be misreported as zero requests. See
270
270
  [Recipe-scoped network capture](NETWORK-CAPTURE.md).
271
271
 
272
+ ## Capture UI smoothness
273
+
274
+ Use explicit `app.performance_capture` start/end nodes to correlate Mobile or
275
+ Extension CDP trace samples with recipe-node boundaries.
276
+ `app.performance_assert` checks the resulting evidence contract. Capture is
277
+ not automatic; see [UI smoothness capture](PERFORMANCE-CAPTURE.md).
278
+
272
279
  ## Share a library
273
280
 
274
281
  ```text