@deeeed/metamask-harness 0.41.0 → 0.42.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 +24 -0
- package/README.md +7 -0
- package/adapters/manifest.json +25 -1
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +70 -10
- package/adapters/mobile/bridge-runtime/console-forwarder.cjs +115 -15
- package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +752 -0
- package/adapters/mobile/bridge-runtime/lib/devtools-proxy.cjs +177 -0
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +13 -3
- package/adapters/mobile/coalesce-metro-log.cjs +24 -0
- package/adapters/mobile/launch-metro.cjs +9 -8
- package/adapters/mobile/metro-log-generation.cjs +106 -0
- package/adapters/mobile/reload-app.mjs +67 -0
- package/adapters/mobile/start-console-forwarder.sh +17 -2
- package/adapters/mobile/start-metro.sh +23 -18
- package/adapters/mobile/stop-metro.sh +15 -7
- package/adapters/shared/open-debug.mjs +172 -2
- package/adapters/shared/reap-checkout-metros.sh +17 -0
- package/dist/adapters/extension/network-observer.js +300 -0
- package/dist/adapters/mobile/metro-env.js +0 -5
- package/dist/adapters/mobile/prepare.js +1 -3
- package/dist/adapters/mobile/runtime-decision.js +6 -30
- package/dist/adapters.js +14 -1
- package/dist/cli-commands.js +6 -3
- package/dist/cli.js +4 -0
- package/dist/command-contract.js +3 -0
- package/dist/commands/call.js +45 -20
- package/dist/commands/launch/index.js +25 -5
- package/dist/commands/reload.js +80 -0
- package/dist/commands/run.js +49 -22
- package/dist/mm-harness-cli.js +17 -1
- package/dist/network-observation.js +271 -0
- package/docs/NETWORK-CAPTURE.md +98 -0
- package/docs/QA.md +2 -0
- package/docs/RECIPES.md +10 -0
- package/library/actions/mobile/app/network_assert.mjs +14 -0
- package/library/actions/mobile/app/network_capture.mjs +72 -0
- package/library/actions/mobile/platform/bridge.mjs +7 -2
- package/library/actions/shared/app/network-artifact.mjs +10 -0
- package/library/actions/shared/app/network-assert.mjs +154 -0
- package/library/manifests/extension.action-manifest.json +88 -0
- package/library/manifests/mobile.action-manifest.json +107 -0
- package/library/recipes/mobile/perps/performance.recipe.json +11 -11
- package/package.json +1 -1
- package/scripts/completions.sh +2 -1
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import brokerModule from "../adapters/mobile/bridge-runtime/lib/cdp-broker.cjs";
|
|
4
|
+
import {
|
|
5
|
+
createExtensionNetworkObserver
|
|
6
|
+
} from "./adapters/extension/network-observer.js";
|
|
7
|
+
const AUTO_CAPTURE_ID = "run-network";
|
|
8
|
+
const AUTO_ARTIFACT_PATH = "network/run-summary.json";
|
|
9
|
+
const { brokerSocketPath, createBrokerClient } = brokerModule;
|
|
10
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
11
|
+
async function startRunNetworkObservation(adapter, target, artifactsDir, env, ports = {}) {
|
|
12
|
+
if (adapter === "core") return void 0;
|
|
13
|
+
const runtimeEnv = {
|
|
14
|
+
...env,
|
|
15
|
+
...ports.cdpPort ? { CDP_PORT: ports.cdpPort, RECIPE_CDP_PORT: ports.cdpPort } : {},
|
|
16
|
+
...ports.watcherPort ? { WATCHER_PORT: ports.watcherPort } : {}
|
|
17
|
+
};
|
|
18
|
+
const key = path.resolve(artifactsDir);
|
|
19
|
+
const session = {
|
|
20
|
+
artifactsDir: key,
|
|
21
|
+
autoStarted: false,
|
|
22
|
+
autoStartedAt: Date.now(),
|
|
23
|
+
nodeEvents: []
|
|
24
|
+
};
|
|
25
|
+
sessions.set(key, session);
|
|
26
|
+
try {
|
|
27
|
+
session.backend = adapter === "mobile" ? await createMobileNetworkBackend(target, runtimeEnv) : await createExtensionNetworkObserver(extensionCdpPort(runtimeEnv), key);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
session.setupError = boundedError(error);
|
|
30
|
+
}
|
|
31
|
+
if (runtimeEnv.MM_HARNESS_AUTO_NETWORK_CAPTURE !== "0") {
|
|
32
|
+
session.autoStartedAt = Date.now();
|
|
33
|
+
if (session.backend) {
|
|
34
|
+
try {
|
|
35
|
+
await session.backend.start({
|
|
36
|
+
id: AUTO_CAPTURE_ID,
|
|
37
|
+
bodyJsonFields: ["type"],
|
|
38
|
+
maxDurationMs: 60 * 60 * 1e3,
|
|
39
|
+
maxRequests: 1e4,
|
|
40
|
+
methods: [],
|
|
41
|
+
urlIncludes: []
|
|
42
|
+
});
|
|
43
|
+
session.autoStarted = true;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
session.setupError = boundedError(error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
onActionEvent(event) {
|
|
51
|
+
session.nodeEvents.push({ ...event, epochMs: Date.now() });
|
|
52
|
+
},
|
|
53
|
+
async finalize(artifactManifestPath) {
|
|
54
|
+
try {
|
|
55
|
+
if (runtimeEnv.MM_HARNESS_AUTO_NETWORK_CAPTURE !== "0") {
|
|
56
|
+
const summary = await automaticSummary(session);
|
|
57
|
+
writeSummary(key, AUTO_ARTIFACT_PATH, summary);
|
|
58
|
+
if (artifactManifestPath) {
|
|
59
|
+
indexArtifact(
|
|
60
|
+
artifactManifestPath,
|
|
61
|
+
AUTO_ARTIFACT_PATH,
|
|
62
|
+
"Automatic network observation"
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
sessions.delete(key);
|
|
68
|
+
await session.backend?.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
async function handleRunNetworkAction(platform, action, node, context) {
|
|
74
|
+
if (platform !== "extension") return null;
|
|
75
|
+
if (action === "app.network_assert") {
|
|
76
|
+
const assertion = await import("../library/actions/shared/app/network-assert.mjs");
|
|
77
|
+
return {
|
|
78
|
+
output: await assertion.assertNetwork({ action, node, context })
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (action !== "app.network_capture") return null;
|
|
82
|
+
const phase = String(node.phase ?? "").toLowerCase();
|
|
83
|
+
const id = String(node.id ?? "").trim();
|
|
84
|
+
if (!["start", "end"].includes(phase) || !id) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"app.network_capture requires phase=start|end and a non-empty id."
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const session = sessions.get(path.resolve(context.artifactsDir));
|
|
90
|
+
if (!session?.backend) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Extension network observation is unavailable: ${session?.setupError ?? "run observer was not started"}.`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (phase === "start") {
|
|
96
|
+
const result = await session.backend.start(captureParams(id, node));
|
|
97
|
+
return {
|
|
98
|
+
output: {
|
|
99
|
+
action,
|
|
100
|
+
phase,
|
|
101
|
+
...asRecord(result)
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const artifactPath = String(
|
|
106
|
+
node.artifact_path ?? `network/${id}-summary.json`
|
|
107
|
+
);
|
|
108
|
+
const summary = await session.backend.end(id);
|
|
109
|
+
writeSummary(context.artifactsDir, artifactPath, summary);
|
|
110
|
+
return {
|
|
111
|
+
output: {
|
|
112
|
+
action,
|
|
113
|
+
phase,
|
|
114
|
+
...summary
|
|
115
|
+
},
|
|
116
|
+
artifacts: [
|
|
117
|
+
{
|
|
118
|
+
path: artifactPath,
|
|
119
|
+
type: "report",
|
|
120
|
+
nodeId: context.nodeId
|
|
121
|
+
}
|
|
122
|
+
]
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
async function createMobileNetworkBackend(target, env) {
|
|
126
|
+
const runtimeDir = env.RECIPE_RUNTIME_DIR ? path.resolve(target, env.RECIPE_RUNTIME_DIR) : path.join(target, "temp", "recipe", "runtime");
|
|
127
|
+
const socketPath = brokerSocketPath(runtimeDir);
|
|
128
|
+
const discoveryClient = await createBrokerClient(socketPath, "", 1e4);
|
|
129
|
+
const targets = await discoveryClient.control("list-targets", {}, 1e4);
|
|
130
|
+
discoveryClient.close();
|
|
131
|
+
const deviceId = selectMobileBrokerTarget(targets, env);
|
|
132
|
+
const client = await createBrokerClient(socketPath, deviceId, 1e4);
|
|
133
|
+
return {
|
|
134
|
+
start(params) {
|
|
135
|
+
return client.control("capture-start", params, 1e4);
|
|
136
|
+
},
|
|
137
|
+
async end(id) {
|
|
138
|
+
return asRecord(await client.control("capture-end", { id }, 1e4));
|
|
139
|
+
},
|
|
140
|
+
async close() {
|
|
141
|
+
client.close();
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function selectMobileBrokerTarget(value, env) {
|
|
146
|
+
const targets = Array.isArray(value) ? value.map(asRecord).filter((target) => target.deviceId) : [];
|
|
147
|
+
const platform = String(
|
|
148
|
+
env.MM_HARNESS_EXPLICIT_PLATFORM ?? env.RECIPE_HARNESS_PLATFORM ?? ""
|
|
149
|
+
).toLowerCase();
|
|
150
|
+
const pin = String(
|
|
151
|
+
platform === "android" ? env.ANDROID_TARGET_DEVICE_NAME ?? env.ANDROID_DEVICE ?? "" : platform === "ios" ? env.IOS_SIMULATOR ?? "" : env.IOS_SIMULATOR ?? env.ANDROID_TARGET_DEVICE_NAME ?? env.ANDROID_DEVICE ?? ""
|
|
152
|
+
).trim();
|
|
153
|
+
const candidates = pin ? targets.filter(
|
|
154
|
+
(target) => String(target.name ?? "").toLowerCase().includes(pin.toLowerCase())
|
|
155
|
+
) : targets;
|
|
156
|
+
if (candidates.length !== 1) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Mobile network observation requires one broker target; found ${candidates.length}.`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return String(candidates[0].deviceId);
|
|
162
|
+
}
|
|
163
|
+
async function automaticSummary(session) {
|
|
164
|
+
let summary;
|
|
165
|
+
if (session.autoStarted && session.backend) {
|
|
166
|
+
try {
|
|
167
|
+
summary = await session.backend.end(AUTO_CAPTURE_ID);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
summary = unavailableSummary(session.autoStartedAt, boundedError(error));
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
summary = unavailableSummary(
|
|
173
|
+
session.autoStartedAt,
|
|
174
|
+
session.setupError ?? "Network observer did not start."
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const startedAt = Number(summary.startedAtEpochMs ?? session.autoStartedAt);
|
|
178
|
+
return {
|
|
179
|
+
...summary,
|
|
180
|
+
nodeEvents: session.nodeEvents.map((event) => ({
|
|
181
|
+
action: event.action,
|
|
182
|
+
elapsedMs: Math.max(0, event.epochMs - startedAt),
|
|
183
|
+
nodeId: event.nodeId,
|
|
184
|
+
status: event.status
|
|
185
|
+
}))
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function unavailableSummary(startedAtEpochMs, reason) {
|
|
189
|
+
return {
|
|
190
|
+
schemaVersion: 1,
|
|
191
|
+
id: AUTO_CAPTURE_ID,
|
|
192
|
+
status: "unavailable",
|
|
193
|
+
startedAtEpochMs,
|
|
194
|
+
endedAtEpochMs: Date.now(),
|
|
195
|
+
maxDurationMs: 60 * 60 * 1e3,
|
|
196
|
+
reconnects: 0,
|
|
197
|
+
droppedRequests: 0,
|
|
198
|
+
unavailableReasons: [reason],
|
|
199
|
+
coverageGapReasons: [],
|
|
200
|
+
uninspectableBodyRequests: 0,
|
|
201
|
+
projectedBodyFields: ["type"],
|
|
202
|
+
totalRequests: 0,
|
|
203
|
+
requestsByMethod: {},
|
|
204
|
+
requestsByHost: {},
|
|
205
|
+
requestsByType: {},
|
|
206
|
+
requests: []
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function captureParams(id, node) {
|
|
210
|
+
return {
|
|
211
|
+
id,
|
|
212
|
+
urlIncludes: node.url_includes ?? [],
|
|
213
|
+
methods: node.methods ?? [],
|
|
214
|
+
bodyJsonFields: node.body_json_fields ?? [],
|
|
215
|
+
maxRequests: node.max_requests,
|
|
216
|
+
maxDurationMs: node.max_duration_ms
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function writeSummary(artifactsDir, relativePath, summary) {
|
|
220
|
+
const destination = resolveArtifact(artifactsDir, relativePath);
|
|
221
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
222
|
+
fs.writeFileSync(destination, `${JSON.stringify(summary, null, 2)}
|
|
223
|
+
`);
|
|
224
|
+
}
|
|
225
|
+
function resolveArtifact(artifactsDir, relativePath) {
|
|
226
|
+
const root = path.resolve(artifactsDir);
|
|
227
|
+
const resolved = path.resolve(root, relativePath);
|
|
228
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
|
|
229
|
+
throw new Error("Network artifact_path escapes artifactsDir.");
|
|
230
|
+
}
|
|
231
|
+
return resolved;
|
|
232
|
+
}
|
|
233
|
+
function indexArtifact(artifactManifestPath, relativePath, label) {
|
|
234
|
+
const manifest = asRecord(
|
|
235
|
+
JSON.parse(fs.readFileSync(artifactManifestPath, "utf8"))
|
|
236
|
+
);
|
|
237
|
+
const artifacts = Array.isArray(manifest.artifacts) ? manifest.artifacts.filter(
|
|
238
|
+
(artifact) => asRecord(artifact).path !== relativePath
|
|
239
|
+
) : [];
|
|
240
|
+
artifacts.push({
|
|
241
|
+
path: relativePath,
|
|
242
|
+
type: "report",
|
|
243
|
+
label,
|
|
244
|
+
category: "diagnostic"
|
|
245
|
+
});
|
|
246
|
+
manifest.artifacts = artifacts;
|
|
247
|
+
fs.writeFileSync(
|
|
248
|
+
artifactManifestPath,
|
|
249
|
+
`${JSON.stringify(manifest, null, 2)}
|
|
250
|
+
`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
function extensionCdpPort(env) {
|
|
254
|
+
const port = Number(
|
|
255
|
+
env.CDP_PORT ?? env.RECIPE_CDP_PORT ?? process.env.CDP_PORT
|
|
256
|
+
);
|
|
257
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
258
|
+
throw new Error("Extension network observation requires CDP_PORT.");
|
|
259
|
+
}
|
|
260
|
+
return port;
|
|
261
|
+
}
|
|
262
|
+
function boundedError(error) {
|
|
263
|
+
return String(error instanceof Error ? error.message : error).slice(0, 256);
|
|
264
|
+
}
|
|
265
|
+
function asRecord(value) {
|
|
266
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
267
|
+
}
|
|
268
|
+
export {
|
|
269
|
+
handleRunNetworkAction,
|
|
270
|
+
startRunNetworkObservation
|
|
271
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Recipe-scoped network capture
|
|
2
|
+
|
|
3
|
+
`app.network_capture` records HTTP requests between two Recipe Protocol v1 nodes without product instrumentation. Mobile uses its shared Hermes CDP broker; Extension uses one browser-level CDP observer across the extension page, service worker, and offscreen targets.
|
|
4
|
+
|
|
5
|
+
```json
|
|
6
|
+
{
|
|
7
|
+
"action": "app.network_capture",
|
|
8
|
+
"phase": "start",
|
|
9
|
+
"id": "perps-home",
|
|
10
|
+
"url_includes": ["api.hyperliquid.xyz/info"],
|
|
11
|
+
"methods": ["POST"],
|
|
12
|
+
"body_json_fields": ["type", "req.coin", "dex"],
|
|
13
|
+
"next": "exercise-flow"
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"action": "app.network_capture",
|
|
20
|
+
"phase": "end",
|
|
21
|
+
"id": "perps-home",
|
|
22
|
+
"artifact_path": "network/perps-home.json",
|
|
23
|
+
"next": "assert-network"
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"action": "app.network_assert",
|
|
30
|
+
"id": "perps-home",
|
|
31
|
+
"artifact_path": "network/perps-home.json",
|
|
32
|
+
"required_status": "complete",
|
|
33
|
+
"next": "done"
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The end node writes and indexes a JSON summary containing request totals, counts by method/host/retained body type, relative timestamps, reconnects, and dropped events.
|
|
38
|
+
|
|
39
|
+
## Automatic run evidence
|
|
40
|
+
|
|
41
|
+
Every live Mobile or Extension recipe run also writes `network/run-summary.json`. The automatic artifact is metadata-only and records:
|
|
42
|
+
|
|
43
|
+
- method, host, sanitized path, elapsed time, and the safe top-level request `type` when present;
|
|
44
|
+
- `running`, `passed`, and `failed` node-boundary events on the same elapsed-time axis;
|
|
45
|
+
- completeness, reconnect, drop, and coverage-gap status.
|
|
46
|
+
|
|
47
|
+
This makes requests between node boundaries inspectable without adding capture nodes to every recipe. Set `MM_HARNESS_AUTO_NETWORK_CAPTURE=0` only for observer-overhead comparisons or a runtime that intentionally forbids network inspection. Explicit `app.network_capture` windows remain the source for focused filters, assertions, and additional allowlisted body fields.
|
|
48
|
+
|
|
49
|
+
## Status semantics
|
|
50
|
+
|
|
51
|
+
- `complete`: the debugger target stayed attached and every retained event fit inside the configured cap.
|
|
52
|
+
- `partial`: the target rotated/disconnected, the window exceeded `max_duration_ms`, events exceeded a retention bound, or a requested body field could not be inspected safely. Never interpret zero requests from a partial capture as proof of absence.
|
|
53
|
+
- `unavailable`: the selected runtime rejected Network capture or no observer-owned target became available.
|
|
54
|
+
|
|
55
|
+
Use `required_status`, `required_min_requests`, `required_max_requests`,
|
|
56
|
+
`required_types`, and `forbidden_types` on a separate `app.network_assert`
|
|
57
|
+
node. The end node is therefore recorded and its JSON artifact indexed before
|
|
58
|
+
an assertion can fail. Absence assertions (`required_max_requests` and
|
|
59
|
+
`forbidden_types`) require `required_status: "complete"`; partial or unavailable
|
|
60
|
+
coverage cannot prove absence. Type assertions also require `type` in the start
|
|
61
|
+
node's `body_json_fields`, so an unobserved discriminator cannot prove presence
|
|
62
|
+
or absence.
|
|
63
|
+
|
|
64
|
+
## Redaction
|
|
65
|
+
|
|
66
|
+
- Query strings, request headers, response bodies, cookies, and authorization are never stored.
|
|
67
|
+
- Request bodies are omitted unless `body_json_fields` explicitly allowlists bounded, non-sensitive primitive fields.
|
|
68
|
+
- Field names containing address, account, user, token, secret, password, key, cookie, or authorization are rejected.
|
|
69
|
+
- Each capture is capped at 4 MiB in addition to `max_requests`, which defaults
|
|
70
|
+
to 1,000 and is capped at 10,000.
|
|
71
|
+
- `max_duration_ms` defaults to five minutes and is capped at one hour; at
|
|
72
|
+
most 16 windows may be active for one device.
|
|
73
|
+
- The local broker socket is mode `0600`.
|
|
74
|
+
|
|
75
|
+
## Runtime design
|
|
76
|
+
|
|
77
|
+
Mobile uses one per-slot broker for console events, bridge commands, HUD actions, and Network events. The broker reconnects when Hermes rotates and marks active captures partial because events emitted while no target exists cannot be proven complete.
|
|
78
|
+
|
|
79
|
+
Extension uses the browser CDP target to attach to every target owned by the loaded MetaMask extension. Target creation or removal re-enables Network collection and marks active captures partial when continuity cannot be proven.
|
|
80
|
+
|
|
81
|
+
| Adapter | Automatic run artifact | Explicit window/assert | Raw interactive view |
|
|
82
|
+
| --- | --- | --- | --- |
|
|
83
|
+
| Mobile | Supported | Supported | React Native DevTools |
|
|
84
|
+
| Extension | Supported | Supported | Chrome DevTools |
|
|
85
|
+
| Core | Not applicable | Not declared | Not applicable |
|
|
86
|
+
|
|
87
|
+
## Recipe v1 observation-window convention
|
|
88
|
+
|
|
89
|
+
Observation capabilities use one lifecycle:
|
|
90
|
+
|
|
91
|
+
1. start a bounded observer;
|
|
92
|
+
2. execute ordinary recipe nodes;
|
|
93
|
+
3. end the observer and index its artifact;
|
|
94
|
+
4. assert the indexed artifact separately.
|
|
95
|
+
|
|
96
|
+
Every observer reports `complete`, `partial`, or `unavailable`; a partial or unavailable window cannot prove absence. A future Mobile FPS/jank observer should reuse this lifecycle and node-boundary timeline, but no FPS action is advertised until its device overhead and metrics are validated.
|
|
97
|
+
|
|
98
|
+
Local validation of the shared request processor measured about 1.6 microseconds of median CPU per retained request. Five real observer lifecycle samples measured 19.9 ms median on Mobile and 9.4 ms median on Extension; observed maxima were 117 ms and 192 ms. These are harness-overhead measurements, not product-network or UI latency claims.
|
package/docs/QA.md
CHANGED
|
@@ -124,6 +124,8 @@ mm-harness debug
|
|
|
124
124
|
- [ ] Status matches verified product routes: `Login`/`LockScreen` are locked;
|
|
125
125
|
`WalletView` is unlocked.
|
|
126
126
|
- [ ] App and Metro logs are separate.
|
|
127
|
+
- [ ] A restarted Metro rotates the previous `metro.log`, records its generation and retention actions in launch JSON, and classifies bundle failures only from the current log.
|
|
128
|
+
- [ ] `mm-harness logs --full` preserves errors, completion, and meaningful timestamped bundle progress without repeated same-percentage module counts.
|
|
127
129
|
- [ ] A JS edit rebuilds through Metro and appears after reload without a native
|
|
128
130
|
rebuild; revert restores a clean tree.
|
|
129
131
|
|
package/docs/RECIPES.md
CHANGED
|
@@ -259,6 +259,16 @@ when repeated direct access has a stable cross-task contract.
|
|
|
259
259
|
The protocol is authoritative:
|
|
260
260
|
<https://farmslot.io/docs/reference/recipe-protocol-v1>.
|
|
261
261
|
|
|
262
|
+
## Capture network requests
|
|
263
|
+
|
|
264
|
+
Use `app.network_capture` start/end nodes to record redacted HTTP requests made
|
|
265
|
+
inside one Mobile or Extension recipe window, then a separate
|
|
266
|
+
`app.network_assert` node when the recipe needs self-checking evidence. Every
|
|
267
|
+
live recipe also indexes a bounded metadata-only run summary with node-boundary
|
|
268
|
+
events. The result distinguishes complete, partial, and unavailable evidence
|
|
269
|
+
so a target rotation cannot be misreported as zero requests. See
|
|
270
|
+
[Recipe-scoped network capture](NETWORK-CAPTURE.md).
|
|
271
|
+
|
|
262
272
|
## Share a library
|
|
263
273
|
|
|
264
274
|
```text
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import { assertNetwork } from '../../shared/app/network-assert.mjs';
|
|
5
|
+
import { runAdapter } from '../platform/bridge.mjs';
|
|
6
|
+
|
|
7
|
+
export { assertNetwork };
|
|
8
|
+
|
|
9
|
+
if (
|
|
10
|
+
process.argv[1] &&
|
|
11
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
12
|
+
) {
|
|
13
|
+
runAdapter(assertNetwork);
|
|
14
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
bridgeCommand,
|
|
8
|
+
runAdapter,
|
|
9
|
+
} from '../platform/bridge.mjs';
|
|
10
|
+
import { resolveNetworkArtifact } from '../../shared/app/network-artifact.mjs';
|
|
11
|
+
|
|
12
|
+
export async function captureNetwork(input) {
|
|
13
|
+
const node = input.node ?? {};
|
|
14
|
+
const phase = String(node.phase ?? '').toLowerCase();
|
|
15
|
+
const id = String(node.id ?? '').trim();
|
|
16
|
+
if (!['start', 'end'].includes(phase) || !id) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
'app.network_capture requires phase=start|end and a non-empty id.',
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (phase === 'start') {
|
|
23
|
+
const result = await bridgeCommand(input, [
|
|
24
|
+
'network-capture-start',
|
|
25
|
+
JSON.stringify({
|
|
26
|
+
id,
|
|
27
|
+
urlIncludes: node.url_includes ?? [],
|
|
28
|
+
methods: node.methods ?? [],
|
|
29
|
+
bodyJsonFields: node.body_json_fields ?? [],
|
|
30
|
+
maxRequests: node.max_requests,
|
|
31
|
+
maxDurationMs: node.max_duration_ms,
|
|
32
|
+
}),
|
|
33
|
+
]);
|
|
34
|
+
return { action: input.action, phase, ...result };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const artifactsDir = input.context?.artifactsDir;
|
|
38
|
+
if (!artifactsDir) {
|
|
39
|
+
throw new Error('app.network_capture requires artifactsDir.');
|
|
40
|
+
}
|
|
41
|
+
const artifactPath = String(
|
|
42
|
+
node.artifact_path ?? `network/${id}-summary.json`,
|
|
43
|
+
);
|
|
44
|
+
const artifactFile = resolveNetworkArtifact(artifactsDir, artifactPath);
|
|
45
|
+
|
|
46
|
+
const summary = await bridgeCommand(input, [
|
|
47
|
+
'network-capture-end',
|
|
48
|
+
JSON.stringify({ id }),
|
|
49
|
+
]);
|
|
50
|
+
await mkdir(path.dirname(artifactFile), { recursive: true });
|
|
51
|
+
await writeFile(artifactFile, `${JSON.stringify(summary, null, 2)}\n`);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
action: input.action,
|
|
55
|
+
phase,
|
|
56
|
+
...summary,
|
|
57
|
+
artifacts: [
|
|
58
|
+
{
|
|
59
|
+
path: artifactPath,
|
|
60
|
+
type: 'report',
|
|
61
|
+
nodeId: String(input.context?.nodeId ?? 'network-capture'),
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
process.argv[1] &&
|
|
69
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
70
|
+
) {
|
|
71
|
+
runAdapter(captureNetwork);
|
|
72
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { constants as fsConstants } from 'node:fs';
|
|
2
|
+
import { constants as fsConstants, existsSync } from 'node:fs';
|
|
3
3
|
import { mkdir, mkdtemp, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { execFile, spawn } from 'node:child_process';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
@@ -7,6 +7,7 @@ import os from 'node:os';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import bridgeErrors from '../../../../adapters/mobile/bridge-runtime/lib/bridge-errors.cjs';
|
|
10
|
+
import brokerModule from '../../../../adapters/mobile/bridge-runtime/lib/cdp-broker.cjs';
|
|
10
11
|
import { resolveMobileToolPath } from './tool-paths.mjs';
|
|
11
12
|
|
|
12
13
|
const {
|
|
@@ -16,6 +17,7 @@ const {
|
|
|
16
17
|
coded,
|
|
17
18
|
parseErrorMarker,
|
|
18
19
|
} = bridgeErrors;
|
|
20
|
+
const { brokerSocketPath } = brokerModule;
|
|
19
21
|
|
|
20
22
|
// Re-exported so the TS adapter classifies on the same code constants without a
|
|
21
23
|
// second import path into the cjs bridge-runtime.
|
|
@@ -430,7 +432,10 @@ export async function bridgeCommand(input, args) {
|
|
|
430
432
|
const script = bridgeScript(input);
|
|
431
433
|
// bridgeEnv is async: it may call `adb getprop` to resolve the Metro device name.
|
|
432
434
|
const env = await bridgeEnv(input);
|
|
433
|
-
|
|
435
|
+
const brokerSocket = brokerSocketPath(
|
|
436
|
+
path.dirname(resolveBridgeLockPath(input, env)),
|
|
437
|
+
);
|
|
438
|
+
if (adapterActionActive && !existsSync(brokerSocket)) {
|
|
434
439
|
env.CDP_BRIDGE_LOCK_OWNER_PID = await acquireActionBridgeLock(input, env);
|
|
435
440
|
}
|
|
436
441
|
const result = await new Promise((resolve, reject) => {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
export function resolveNetworkArtifact(artifactsDir, relativePath) {
|
|
4
|
+
const root = path.resolve(artifactsDir);
|
|
5
|
+
const resolved = path.resolve(root, relativePath);
|
|
6
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
|
|
7
|
+
throw new Error('Network artifact_path escapes artifactsDir.');
|
|
8
|
+
}
|
|
9
|
+
return resolved;
|
|
10
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
import { resolveNetworkArtifact } from './network-artifact.mjs';
|
|
5
|
+
|
|
6
|
+
export async function assertNetwork(input) {
|
|
7
|
+
const node = input.node ?? {};
|
|
8
|
+
const id = String(node.id ?? '').trim();
|
|
9
|
+
const artifactsDir = input.context?.artifactsDir;
|
|
10
|
+
if (!id || !artifactsDir) {
|
|
11
|
+
throw new Error('app.network_assert requires id and artifactsDir.');
|
|
12
|
+
}
|
|
13
|
+
const artifactPath = String(
|
|
14
|
+
node.artifact_path ?? `network/${id}-summary.json`,
|
|
15
|
+
);
|
|
16
|
+
const artifactFile = resolveNetworkArtifact(artifactsDir, artifactPath);
|
|
17
|
+
const handle = await open(
|
|
18
|
+
artifactFile,
|
|
19
|
+
constants.O_RDONLY | constants.O_NOFOLLOW,
|
|
20
|
+
);
|
|
21
|
+
let summary;
|
|
22
|
+
try {
|
|
23
|
+
const artifactStat = await handle.stat();
|
|
24
|
+
if (!artifactStat.isFile() || artifactStat.size > 5 * 1024 * 1024) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'app.network_assert summary is not a bounded regular file.',
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
summary = JSON.parse(await handle.readFile('utf8'));
|
|
30
|
+
} finally {
|
|
31
|
+
await handle.close();
|
|
32
|
+
}
|
|
33
|
+
validateSummary(summary, id);
|
|
34
|
+
|
|
35
|
+
const requiredStatus = node.required_status;
|
|
36
|
+
if (requiredStatus && summary.status !== requiredStatus) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`app.network_assert expected ${requiredStatus}, got ${summary.status}.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const requiredMinRequests = node.required_min_requests;
|
|
42
|
+
const requiredMaxRequests = node.required_max_requests;
|
|
43
|
+
const requiredTypes = node.required_types ?? [];
|
|
44
|
+
const forbiddenTypes = node.forbidden_types ?? [];
|
|
45
|
+
if (
|
|
46
|
+
(requiredTypes.length > 0 || forbiddenTypes.length > 0) &&
|
|
47
|
+
!summary.projectedBodyFields.includes('type')
|
|
48
|
+
) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
'app.network_assert type assertions require body_json_fields to include type.',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (
|
|
54
|
+
(requiredMaxRequests !== undefined || forbiddenTypes.length > 0) &&
|
|
55
|
+
requiredStatus !== 'complete'
|
|
56
|
+
) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
'app.network_assert negative assertions require required_status=complete.',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
for (const [name, value] of [
|
|
62
|
+
['required_min_requests', requiredMinRequests],
|
|
63
|
+
['required_max_requests', requiredMaxRequests],
|
|
64
|
+
]) {
|
|
65
|
+
if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
|
|
66
|
+
throw new Error(`app.network_assert ${name} is invalid.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (
|
|
70
|
+
Number.isInteger(requiredMinRequests) &&
|
|
71
|
+
Number.isInteger(requiredMaxRequests) &&
|
|
72
|
+
requiredMinRequests > requiredMaxRequests
|
|
73
|
+
) {
|
|
74
|
+
throw new Error('app.network_assert request assertion range is invalid.');
|
|
75
|
+
}
|
|
76
|
+
if (
|
|
77
|
+
Number.isInteger(requiredMinRequests) &&
|
|
78
|
+
summary.totalRequests < requiredMinRequests
|
|
79
|
+
) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`app.network_assert expected at least ${requiredMinRequests} request(s), got ${summary.totalRequests}.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (
|
|
85
|
+
Number.isInteger(requiredMaxRequests) &&
|
|
86
|
+
summary.totalRequests > requiredMaxRequests
|
|
87
|
+
) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`app.network_assert expected at most ${requiredMaxRequests} request(s), got ${summary.totalRequests}.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
for (const type of requiredTypes) {
|
|
93
|
+
if (!hasPositiveOwnCount(summary.requestsByType, type)) {
|
|
94
|
+
throw new Error(`app.network_assert did not observe required type ${type}.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const type of forbiddenTypes) {
|
|
98
|
+
if (hasPositiveOwnCount(summary.requestsByType, type)) {
|
|
99
|
+
throw new Error(`app.network_assert observed forbidden type ${type}.`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
action: input.action,
|
|
105
|
+
id,
|
|
106
|
+
artifactPath,
|
|
107
|
+
status: summary.status,
|
|
108
|
+
totalRequests: summary.totalRequests,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function hasPositiveOwnCount(counts, key) {
|
|
113
|
+
return (
|
|
114
|
+
Object.hasOwn(counts, key) &&
|
|
115
|
+
Number.isInteger(counts[key]) &&
|
|
116
|
+
counts[key] > 0
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function validateSummary(summary, expectedId) {
|
|
121
|
+
if (
|
|
122
|
+
!summary ||
|
|
123
|
+
typeof summary !== 'object' ||
|
|
124
|
+
Array.isArray(summary) ||
|
|
125
|
+
summary.schemaVersion !== 1 ||
|
|
126
|
+
summary.id !== expectedId ||
|
|
127
|
+
!['complete', 'partial', 'unavailable'].includes(summary.status) ||
|
|
128
|
+
!Number.isInteger(summary.totalRequests) ||
|
|
129
|
+
summary.totalRequests < 0 ||
|
|
130
|
+
!Array.isArray(summary.requests) ||
|
|
131
|
+
summary.requests.length !== summary.totalRequests ||
|
|
132
|
+
!Number.isInteger(summary.uninspectableBodyRequests) ||
|
|
133
|
+
summary.uninspectableBodyRequests < 0 ||
|
|
134
|
+
!Array.isArray(summary.projectedBodyFields) ||
|
|
135
|
+
summary.projectedBodyFields.some((field) => typeof field !== 'string') ||
|
|
136
|
+
!summary.requestsByType ||
|
|
137
|
+
typeof summary.requestsByType !== 'object' ||
|
|
138
|
+
Array.isArray(summary.requestsByType)
|
|
139
|
+
) {
|
|
140
|
+
throw new Error('app.network_assert summary contract is invalid.');
|
|
141
|
+
}
|
|
142
|
+
const typeCounts = Object.values(summary.requestsByType);
|
|
143
|
+
for (const count of typeCounts) {
|
|
144
|
+
if (!Number.isInteger(count) || count < 1) {
|
|
145
|
+
throw new Error('app.network_assert summary type counts are invalid.');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (
|
|
149
|
+
typeCounts.reduce((total, count) => total + count, 0) !==
|
|
150
|
+
summary.totalRequests
|
|
151
|
+
) {
|
|
152
|
+
throw new Error('app.network_assert summary type counts are inconsistent.');
|
|
153
|
+
}
|
|
154
|
+
}
|