@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.
- package/CHANGELOG.md +42 -0
- package/README.md +5 -0
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +363 -55
- package/adapters/mobile/bridge-runtime/console-forwarder.cjs +23 -10
- package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +2 -0
- package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +296 -25
- package/adapters/mobile/bridge-runtime/lib/config.cjs +14 -4
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +4 -2
- package/adapters/mobile/reload-app.mjs +99 -1
- package/adapters/mobile/start-console-forwarder.sh +5 -1
- package/dist/adapters/extension/browser-cdp.js +174 -0
- package/dist/adapters/extension/network-observer.js +19 -110
- package/dist/adapters/extension/performance-observer.js +75 -0
- package/dist/adapters/mobile/frame-metrics.js +45 -0
- package/dist/adapters/mobile/performance-observer.js +43 -0
- package/dist/adapters/mobile/prepare.js +12 -0
- package/dist/adapters/performance/cdp-trace.js +342 -0
- package/dist/adapters/performance/js-task-metrics.js +35 -0
- package/dist/adapters.js +39 -5
- package/dist/artifact-files.js +92 -0
- package/dist/async.js +19 -0
- package/dist/commands/call.js +63 -4
- package/dist/commands/run-engine.js +265 -139
- package/dist/commands/run-report.js +68 -0
- package/dist/commands/run.js +67 -3
- package/dist/execution-provenance.js +342 -0
- package/dist/network-observation.js +59 -47
- package/dist/performance-observation.js +465 -0
- package/dist/run-diagnostics.js +36 -11
- package/dist/runner.js +44 -13
- package/docs/PERFORMANCE-CAPTURE.md +33 -0
- package/docs/RECIPES.md +7 -0
- package/library/actions/mobile/perps/performance-capture.mjs +570 -189
- package/library/actions/mobile/perps/perps.mjs +122 -0
- package/library/actions/mobile/platform/bridge.mjs +43 -8
- package/library/actions/mobile/platform/native-session.mjs +1 -1
- package/library/actions/mobile/wallet/lock.mjs +1 -4
- package/library/actions/mobile/wallet/select_account.mjs +129 -17
- package/library/manifests/extension.action-manifest.json +85 -0
- package/library/manifests/mobile.action-manifest.json +125 -3
- package/library/recipes/mobile/perps/performance.recipe.json +73 -47
- package/package.json +1 -1
- package/scripts/site-contrast.mjs +43 -27
package/dist/run-diagnostics.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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)
|
|
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;
|
|
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
|
|
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)
|
|
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) ||
|
|
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
|
-
|
|
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
|
|
347
|
-
const
|
|
348
|
-
const
|
|
349
|
-
const
|
|
350
|
-
const
|
|
351
|
-
const
|
|
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 (
|
|
366
|
-
const processAfter =
|
|
367
|
-
const
|
|
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 (
|
|
390
|
+
if (checksRequiredContinuity && !processPreserved) {
|
|
391
|
+
const platform = androidLifecycle ? "Android" : "iOS";
|
|
379
392
|
throw new Error(
|
|
380
|
-
|
|
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
|