@deeeed/metamask-harness 0.41.1 → 0.43.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 +35 -0
- package/README.md +12 -0
- package/adapters/manifest.json +8 -0
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +108 -10
- package/adapters/mobile/bridge-runtime/console-forwarder.cjs +117 -16
- package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +2 -0
- package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +921 -0
- package/adapters/mobile/bridge-runtime/lib/config.cjs +14 -4
- 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/reload-app.mjs +67 -0
- package/adapters/mobile/start-console-forwarder.sh +22 -3
- package/adapters/mobile/start-metro.sh +6 -11
- package/adapters/mobile/stop-metro.sh +10 -1
- package/adapters/shared/open-debug.mjs +172 -2
- package/dist/adapters/extension/browser-cdp.js +174 -0
- package/dist/adapters/extension/network-observer.js +209 -0
- package/dist/adapters/extension/performance-observer.js +75 -0
- package/dist/adapters/mobile/frame-metrics.js +45 -0
- package/dist/adapters/mobile/metro-env.js +0 -5
- package/dist/adapters/mobile/performance-observer.js +43 -0
- package/dist/adapters/mobile/prepare.js +1 -3
- package/dist/adapters/mobile/runtime-decision.js +6 -9
- package/dist/adapters/performance/cdp-trace.js +342 -0
- package/dist/adapters/performance/js-task-metrics.js +35 -0
- package/dist/adapters.js +29 -1
- package/dist/artifact-files.js +92 -0
- package/dist/async.js +19 -0
- 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 +66 -20
- package/dist/commands/reload.js +80 -0
- package/dist/commands/run-engine.js +18 -0
- package/dist/commands/run.js +68 -22
- package/dist/mm-harness-cli.js +17 -1
- package/dist/network-observation.js +283 -0
- package/dist/performance-observation.js +465 -0
- package/docs/NETWORK-CAPTURE.md +98 -0
- package/docs/PERFORMANCE-CAPTURE.md +33 -0
- package/docs/RECIPES.md +17 -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 +10 -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 +173 -0
- package/library/manifests/mobile.action-manifest.json +204 -0
- package/library/recipes/mobile/perps/performance.recipe.json +11 -11
- package/package.json +1 -1
- package/scripts/completions.sh +2 -1
- package/scripts/site-contrast.mjs +43 -27
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { WebSocket } from "ws";
|
|
2
|
+
import { withTimeout } from "../../async.js";
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 1e4;
|
|
4
|
+
async function createBrowserCdpClient(cdpPort) {
|
|
5
|
+
if (!Number.isInteger(cdpPort) || cdpPort <= 0) {
|
|
6
|
+
throw new Error("Extension CDP requires a valid port.");
|
|
7
|
+
}
|
|
8
|
+
const version = asRecord(
|
|
9
|
+
await fetchJson(`http://127.0.0.1:${cdpPort}/json/version`)
|
|
10
|
+
);
|
|
11
|
+
const webSocketUrl = String(version.webSocketDebuggerUrl ?? "");
|
|
12
|
+
if (!webSocketUrl) {
|
|
13
|
+
throw new Error("Extension browser CDP WebSocket is unavailable.");
|
|
14
|
+
}
|
|
15
|
+
const socket = new WebSocket(webSocketUrl);
|
|
16
|
+
await waitForOpen(socket);
|
|
17
|
+
let nextId = 0;
|
|
18
|
+
const pending = /* @__PURE__ */ new Map();
|
|
19
|
+
const handlers = /* @__PURE__ */ new Set();
|
|
20
|
+
const closeHandlers = /* @__PURE__ */ new Set();
|
|
21
|
+
socket.on("message", (data) => {
|
|
22
|
+
let message;
|
|
23
|
+
try {
|
|
24
|
+
message = JSON.parse(String(data));
|
|
25
|
+
} catch {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (message.id && pending.has(message.id)) {
|
|
29
|
+
const command = pending.get(message.id);
|
|
30
|
+
pending.delete(message.id);
|
|
31
|
+
if (!command) return;
|
|
32
|
+
clearTimeout(command.timer);
|
|
33
|
+
if (message.error) {
|
|
34
|
+
command.reject(
|
|
35
|
+
new Error(
|
|
36
|
+
String(message.error.message ?? "Extension CDP command failed")
|
|
37
|
+
)
|
|
38
|
+
);
|
|
39
|
+
} else {
|
|
40
|
+
command.resolve(message.result ?? {});
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
for (const handler of handlers) handler(message);
|
|
45
|
+
});
|
|
46
|
+
socket.on("close", () => {
|
|
47
|
+
for (const command of pending.values()) {
|
|
48
|
+
clearTimeout(command.timer);
|
|
49
|
+
command.reject(new Error("Extension browser CDP connection closed"));
|
|
50
|
+
}
|
|
51
|
+
pending.clear();
|
|
52
|
+
handlers.clear();
|
|
53
|
+
for (const handler of closeHandlers) handler();
|
|
54
|
+
closeHandlers.clear();
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
send(method, params = {}, sessionId, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const id = ++nextId;
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
pending.delete(id);
|
|
62
|
+
reject(new Error(`Extension CDP command timed out: ${method}`));
|
|
63
|
+
}, timeoutMs);
|
|
64
|
+
pending.set(id, { resolve, reject, timer });
|
|
65
|
+
socket.send(
|
|
66
|
+
JSON.stringify({
|
|
67
|
+
id,
|
|
68
|
+
method,
|
|
69
|
+
params,
|
|
70
|
+
...sessionId ? { sessionId } : {}
|
|
71
|
+
})
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
onMessage(handler) {
|
|
76
|
+
handlers.add(handler);
|
|
77
|
+
return () => handlers.delete(handler);
|
|
78
|
+
},
|
|
79
|
+
onClose(handler) {
|
|
80
|
+
closeHandlers.add(handler);
|
|
81
|
+
return () => closeHandlers.delete(handler);
|
|
82
|
+
},
|
|
83
|
+
close() {
|
|
84
|
+
socket.close();
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
async function fetchCdpJson(cdpPort, path = "/json") {
|
|
89
|
+
return fetchJson(`http://127.0.0.1:${cdpPort}${path}`);
|
|
90
|
+
}
|
|
91
|
+
function asBrowserCdpTarget(value) {
|
|
92
|
+
const target = asRecord(value);
|
|
93
|
+
const targetId = String(target.targetId ?? target.id ?? "");
|
|
94
|
+
const type = String(target.type ?? "");
|
|
95
|
+
const url = String(target.url ?? "");
|
|
96
|
+
return targetId && type && url ? { targetId, type, url } : null;
|
|
97
|
+
}
|
|
98
|
+
function extensionIdFromCdpTargets(value) {
|
|
99
|
+
const targets = Array.isArray(value) ? value : [];
|
|
100
|
+
for (const candidate of targets) {
|
|
101
|
+
const target = asBrowserCdpTarget(candidate);
|
|
102
|
+
if (!target) continue;
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(target.url);
|
|
105
|
+
if (url.protocol === "chrome-extension:" && url.hostname) {
|
|
106
|
+
return url.hostname;
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
function selectExtensionUiTarget(value, extensionId) {
|
|
115
|
+
const targets = Array.isArray(value) ? value : [];
|
|
116
|
+
const candidates = targets.map(asBrowserCdpTarget).filter((candidate) => {
|
|
117
|
+
if (!candidate || !["page", "other"].includes(candidate.type)) return false;
|
|
118
|
+
try {
|
|
119
|
+
const url = new URL(candidate.url);
|
|
120
|
+
return url.protocol === "chrome-extension:" && url.hostname === extensionId;
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
const matchingPath = (target, pathname) => {
|
|
126
|
+
try {
|
|
127
|
+
return new URL(target.url).pathname === pathname;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const home = candidates.filter((target) => matchingPath(target, "/home.html"));
|
|
133
|
+
if (home.length > 0) return home.length === 1 ? home[0] : null;
|
|
134
|
+
const sidepanel = candidates.filter(
|
|
135
|
+
(target) => matchingPath(target, "/sidepanel.html")
|
|
136
|
+
);
|
|
137
|
+
return sidepanel.length === 1 ? sidepanel[0] : null;
|
|
138
|
+
}
|
|
139
|
+
async function fetchJson(url) {
|
|
140
|
+
const response = await fetch(url);
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
throw new Error(`Extension CDP returned HTTP ${response.status}.`);
|
|
143
|
+
}
|
|
144
|
+
return response.json();
|
|
145
|
+
}
|
|
146
|
+
function waitForOpen(socket) {
|
|
147
|
+
const onOpen = (resolve) => () => resolve();
|
|
148
|
+
let openHandler;
|
|
149
|
+
let errorHandler;
|
|
150
|
+
const opened = new Promise((resolve, reject) => {
|
|
151
|
+
openHandler = onOpen(resolve);
|
|
152
|
+
errorHandler = reject;
|
|
153
|
+
socket.once("open", openHandler);
|
|
154
|
+
socket.once("error", errorHandler);
|
|
155
|
+
});
|
|
156
|
+
return withTimeout(opened, {
|
|
157
|
+
message: "Extension browser CDP connection timed out.",
|
|
158
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
159
|
+
onTimeout: () => socket.close()
|
|
160
|
+
}).finally(() => {
|
|
161
|
+
if (openHandler) socket.off("open", openHandler);
|
|
162
|
+
if (errorHandler) socket.off("error", errorHandler);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
function asRecord(value) {
|
|
166
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
167
|
+
}
|
|
168
|
+
export {
|
|
169
|
+
asBrowserCdpTarget,
|
|
170
|
+
createBrowserCdpClient,
|
|
171
|
+
extensionIdFromCdpTargets,
|
|
172
|
+
fetchCdpJson,
|
|
173
|
+
selectExtensionUiTarget
|
|
174
|
+
};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import brokerModule from "../../../adapters/mobile/bridge-runtime/lib/cdp-broker.cjs";
|
|
3
|
+
import {
|
|
4
|
+
asBrowserCdpTarget,
|
|
5
|
+
createBrowserCdpClient,
|
|
6
|
+
extensionIdFromCdpTargets,
|
|
7
|
+
fetchCdpJson
|
|
8
|
+
} from "./browser-cdp.js";
|
|
9
|
+
const DEVICE_ID = "extension";
|
|
10
|
+
const COMMAND_TIMEOUT_MS = 1e4;
|
|
11
|
+
const EXTENSION_TARGET_TYPES = /* @__PURE__ */ new Set([
|
|
12
|
+
"background_page",
|
|
13
|
+
"other",
|
|
14
|
+
"page",
|
|
15
|
+
"service_worker"
|
|
16
|
+
]);
|
|
17
|
+
const { brokerSocketPath, createBrokerClient, createCdpBroker } = brokerModule;
|
|
18
|
+
async function createExtensionNetworkObserver(cdpPort, runtimeDir) {
|
|
19
|
+
if (!Number.isInteger(cdpPort) || cdpPort <= 0) {
|
|
20
|
+
throw new Error("Extension network observation requires a valid CDP port.");
|
|
21
|
+
}
|
|
22
|
+
const targets = await fetchCdpJson(cdpPort);
|
|
23
|
+
const extensionId = extensionIdFromCdpTargets(targets);
|
|
24
|
+
if (!extensionId) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
"Extension CDP browser or loaded extension target is unavailable."
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
const connection = await createBrowserCdpClient(cdpPort);
|
|
30
|
+
let closed = false;
|
|
31
|
+
const brokerRef = {};
|
|
32
|
+
const targetSessions = /* @__PURE__ */ new Map();
|
|
33
|
+
const attaching = /* @__PURE__ */ new Set();
|
|
34
|
+
const send = (method, params = {}, sessionId, timeoutMs = COMMAND_TIMEOUT_MS) => connection.send(method, params, sessionId, timeoutMs);
|
|
35
|
+
const relevantTarget = (target) => {
|
|
36
|
+
if (!EXTENSION_TARGET_TYPES.has(String(target.type))) return false;
|
|
37
|
+
try {
|
|
38
|
+
return new URL(String(target.url)).hostname === extensionId;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const refreshBrokerSession = () => {
|
|
44
|
+
if (!brokerRef.current) return;
|
|
45
|
+
brokerRef.current.onSessionClose(DEVICE_ID);
|
|
46
|
+
brokerRef.current.onSessionOpen(DEVICE_ID);
|
|
47
|
+
};
|
|
48
|
+
const attachTarget = async (target) => {
|
|
49
|
+
if (closed || !relevantTarget(target) || targetSessions.has(target.targetId) || attaching.has(target.targetId)) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
attaching.add(target.targetId);
|
|
53
|
+
try {
|
|
54
|
+
const result = asRecord(
|
|
55
|
+
await send("Target.attachToTarget", {
|
|
56
|
+
targetId: target.targetId,
|
|
57
|
+
flatten: true
|
|
58
|
+
})
|
|
59
|
+
);
|
|
60
|
+
const sessionId = String(result.sessionId ?? "");
|
|
61
|
+
if (sessionId) {
|
|
62
|
+
targetSessions.set(target.targetId, sessionId);
|
|
63
|
+
refreshBrokerSession();
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (!/already attached/iu.test(String(error))) {
|
|
67
|
+
brokerRef.current?.onSessionClose(DEVICE_ID);
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
attaching.delete(target.targetId);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
connection.onMessage((message) => {
|
|
74
|
+
if (message.method === "Target.targetCreated") {
|
|
75
|
+
const target = asBrowserCdpTarget(message.params?.targetInfo);
|
|
76
|
+
if (target) void attachTarget(target);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (message.method === "Target.attachedToTarget") {
|
|
80
|
+
const target = asBrowserCdpTarget(message.params?.targetInfo);
|
|
81
|
+
const sessionId = String(message.params?.sessionId ?? "");
|
|
82
|
+
if (target && sessionId && relevantTarget(target)) {
|
|
83
|
+
targetSessions.set(target.targetId, sessionId);
|
|
84
|
+
refreshBrokerSession();
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (message.method === "Target.detachedFromTarget") {
|
|
89
|
+
const sessionId = String(message.params?.sessionId ?? "");
|
|
90
|
+
for (const [targetId, activeSessionId] of targetSessions) {
|
|
91
|
+
if (activeSessionId !== sessionId) continue;
|
|
92
|
+
targetSessions.delete(targetId);
|
|
93
|
+
refreshBrokerSession();
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (message.sessionId && message.method?.startsWith("Network.")) {
|
|
98
|
+
brokerRef.current?.onCdpEvent(
|
|
99
|
+
DEVICE_ID,
|
|
100
|
+
message.method,
|
|
101
|
+
message.params ?? {}
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
connection.onClose(() => {
|
|
106
|
+
if (!closed) brokerRef.current?.onSessionClose(DEVICE_ID);
|
|
107
|
+
});
|
|
108
|
+
await send("Target.setDiscoverTargets", { discover: true });
|
|
109
|
+
const targetResult = asRecord(await send("Target.getTargets"));
|
|
110
|
+
const targetInfos = Array.isArray(targetResult.targetInfos) ? targetResult.targetInfos : [];
|
|
111
|
+
await Promise.all(
|
|
112
|
+
targetInfos.map(asBrowserCdpTarget).filter((target) => Boolean(target)).map(attachTarget)
|
|
113
|
+
);
|
|
114
|
+
if (targetSessions.size === 0) {
|
|
115
|
+
connection.close();
|
|
116
|
+
throw new Error("Extension CDP exposes no attachable extension targets.");
|
|
117
|
+
}
|
|
118
|
+
const sessions = /* @__PURE__ */ new Map([[DEVICE_ID, { brokerReady: true }]]);
|
|
119
|
+
const socketPath = brokerSocketPath(
|
|
120
|
+
path.join(runtimeDir, "extension-network")
|
|
121
|
+
);
|
|
122
|
+
brokerRef.current = createCdpBroker({
|
|
123
|
+
socketPath,
|
|
124
|
+
sessions,
|
|
125
|
+
async sendCommand(_session, method, params, timeoutMs) {
|
|
126
|
+
const sessionIds = [...new Set(targetSessions.values())];
|
|
127
|
+
if (sessionIds.length === 0) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
"Extension CDP has no active extension target sessions."
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const results = await Promise.allSettled(
|
|
133
|
+
sessionIds.map(
|
|
134
|
+
(sessionId) => send(method, params, sessionId, timeoutMs)
|
|
135
|
+
)
|
|
136
|
+
);
|
|
137
|
+
const successes = results.filter(
|
|
138
|
+
(result) => result.status === "fulfilled"
|
|
139
|
+
);
|
|
140
|
+
if (successes.length === 0) {
|
|
141
|
+
const firstFailure = results.find(
|
|
142
|
+
(result) => result.status === "rejected"
|
|
143
|
+
);
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Extension CDP command failed on every target: ${String(firstFailure?.reason ?? method)}`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (successes.length !== results.length) {
|
|
149
|
+
results.forEach((result, index) => {
|
|
150
|
+
if (result.status === "fulfilled") return;
|
|
151
|
+
const failedSession = sessionIds[index];
|
|
152
|
+
for (const [targetId, sessionId] of targetSessions) {
|
|
153
|
+
if (sessionId === failedSession) targetSessions.delete(targetId);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
brokerRef.current?.onSessionClose(DEVICE_ID);
|
|
157
|
+
}
|
|
158
|
+
return successes[0].value;
|
|
159
|
+
},
|
|
160
|
+
requestDiscovery() {
|
|
161
|
+
void send("Target.getTargets").then((result) => {
|
|
162
|
+
const infos = asRecord(result).targetInfos;
|
|
163
|
+
if (!Array.isArray(infos)) return;
|
|
164
|
+
for (const value of infos) {
|
|
165
|
+
const target = asBrowserCdpTarget(value);
|
|
166
|
+
if (target) void attachTarget(target);
|
|
167
|
+
}
|
|
168
|
+
}).catch(() => brokerRef.current?.onSessionClose(DEVICE_ID));
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
brokerRef.current.onSessionOpen(DEVICE_ID);
|
|
172
|
+
await waitForSocket(socketPath);
|
|
173
|
+
const client = await createBrokerClient(
|
|
174
|
+
socketPath,
|
|
175
|
+
DEVICE_ID,
|
|
176
|
+
COMMAND_TIMEOUT_MS
|
|
177
|
+
);
|
|
178
|
+
return {
|
|
179
|
+
start(params) {
|
|
180
|
+
return client.control("capture-start", params, COMMAND_TIMEOUT_MS);
|
|
181
|
+
},
|
|
182
|
+
async end(id) {
|
|
183
|
+
return asRecord(
|
|
184
|
+
await client.control("capture-end", { id }, COMMAND_TIMEOUT_MS)
|
|
185
|
+
);
|
|
186
|
+
},
|
|
187
|
+
async close() {
|
|
188
|
+
closed = true;
|
|
189
|
+
client.close();
|
|
190
|
+
brokerRef.current?.close();
|
|
191
|
+
connection.close();
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function asRecord(value) {
|
|
196
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
197
|
+
}
|
|
198
|
+
async function waitForSocket(socketPath) {
|
|
199
|
+
const { existsSync } = await import("node:fs");
|
|
200
|
+
const deadline = Date.now() + COMMAND_TIMEOUT_MS;
|
|
201
|
+
while (Date.now() < deadline) {
|
|
202
|
+
if (existsSync(socketPath)) return;
|
|
203
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
204
|
+
}
|
|
205
|
+
throw new Error("Extension network observer broker did not start.");
|
|
206
|
+
}
|
|
207
|
+
export {
|
|
208
|
+
createExtensionNetworkObserver
|
|
209
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCdpTraceCollector
|
|
3
|
+
} from "../performance/cdp-trace.js";
|
|
4
|
+
import {
|
|
5
|
+
createBrowserCdpClient,
|
|
6
|
+
fetchCdpJson,
|
|
7
|
+
selectExtensionUiTarget
|
|
8
|
+
} from "./browser-cdp.js";
|
|
9
|
+
import { resolveExtensionId } from "./extension-id.js";
|
|
10
|
+
async function createExtensionPerformanceBackend(cdpPort, target) {
|
|
11
|
+
const extensionId = (await resolveExtensionId(target, { cdpPort })).extensionId;
|
|
12
|
+
if (!extensionId) {
|
|
13
|
+
throw new Error("Extension performance observation could not resolve the MetaMask extension id.");
|
|
14
|
+
}
|
|
15
|
+
const uiTarget = selectExtensionUiTarget(
|
|
16
|
+
await fetchCdpJson(cdpPort),
|
|
17
|
+
extensionId
|
|
18
|
+
);
|
|
19
|
+
if (!uiTarget) {
|
|
20
|
+
throw new Error("Extension performance observation requires an open MetaMask UI target.");
|
|
21
|
+
}
|
|
22
|
+
const browser = await createBrowserCdpClient(cdpPort);
|
|
23
|
+
const attached = asRecord(
|
|
24
|
+
await browser.send(
|
|
25
|
+
"Target.attachToTarget",
|
|
26
|
+
{ targetId: uiTarget.targetId, flatten: true },
|
|
27
|
+
void 0,
|
|
28
|
+
1e4
|
|
29
|
+
)
|
|
30
|
+
);
|
|
31
|
+
const sessionId = String(attached.sessionId ?? "");
|
|
32
|
+
if (!sessionId) {
|
|
33
|
+
browser.close();
|
|
34
|
+
throw new Error("Extension performance observation could not attach to the MetaMask UI target.");
|
|
35
|
+
}
|
|
36
|
+
const client = browserTraceClient(browser);
|
|
37
|
+
return createCdpTraceCollector(client, "extension", async (name) => {
|
|
38
|
+
const before = Date.now();
|
|
39
|
+
await browser.send(
|
|
40
|
+
"Runtime.evaluate",
|
|
41
|
+
{
|
|
42
|
+
expression: `performance.mark(${JSON.stringify(name)})`,
|
|
43
|
+
returnByValue: true
|
|
44
|
+
},
|
|
45
|
+
sessionId,
|
|
46
|
+
1e4
|
|
47
|
+
);
|
|
48
|
+
const after = Date.now();
|
|
49
|
+
return {
|
|
50
|
+
hostEpochMs: (before + after) / 2,
|
|
51
|
+
uncertaintyMs: (after - before) / 2
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function browserTraceClient(browser) {
|
|
56
|
+
return {
|
|
57
|
+
send(method, params = {}, timeoutMs) {
|
|
58
|
+
return browser.send(method, params, void 0, timeoutMs);
|
|
59
|
+
},
|
|
60
|
+
on(method, handler) {
|
|
61
|
+
return browser.onMessage((message) => {
|
|
62
|
+
if (message.method === method) handler(message.params ?? {});
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
close() {
|
|
66
|
+
browser.close();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function asRecord(value) {
|
|
71
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
72
|
+
}
|
|
73
|
+
export {
|
|
74
|
+
createExtensionPerformanceBackend
|
|
75
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
function summarizeFrames(samples) {
|
|
2
|
+
const validSamples = samples.filter(
|
|
3
|
+
(sample) => Number.isFinite(sample.durationMs) && sample.durationMs > 0
|
|
4
|
+
);
|
|
5
|
+
const durations = validSamples.map((sample) => sample.durationMs).sort((first2, second) => first2 - second);
|
|
6
|
+
if (durations.length === 0) return { frameCount: 0 };
|
|
7
|
+
const jankValues = validSamples.map((sample) => sample.janky).filter((value) => value !== void 0);
|
|
8
|
+
const jankyFrameCount = jankValues.filter(Boolean).length;
|
|
9
|
+
const first = validSamples[0]?.completedAtEpochMs;
|
|
10
|
+
const last = validSamples.at(-1)?.completedAtEpochMs;
|
|
11
|
+
const elapsedMs = first !== void 0 && last !== void 0 && last > first ? last - first : void 0;
|
|
12
|
+
return {
|
|
13
|
+
...elapsedMs ? {
|
|
14
|
+
averageFps: round(
|
|
15
|
+
(validSamples.length - 1) * 1e3 / elapsedMs
|
|
16
|
+
)
|
|
17
|
+
} : {},
|
|
18
|
+
frameCount: durations.length,
|
|
19
|
+
...jankValues.length === validSamples.length ? {
|
|
20
|
+
jankyFrameCount,
|
|
21
|
+
jankyFramePercent: round(
|
|
22
|
+
jankyFrameCount / validSamples.length * 100
|
|
23
|
+
)
|
|
24
|
+
} : {},
|
|
25
|
+
longestFrameMs: round(durations.at(-1) ?? 0),
|
|
26
|
+
p50FrameMs: round(percentile(durations, 0.5)),
|
|
27
|
+
p95FrameMs: round(percentile(durations, 0.95)),
|
|
28
|
+
p99FrameMs: round(percentile(durations, 0.99))
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function percentile(sorted, quantile) {
|
|
32
|
+
if (sorted.length === 1) return sorted[0] ?? 0;
|
|
33
|
+
const position = (sorted.length - 1) * quantile;
|
|
34
|
+
const lower = Math.floor(position);
|
|
35
|
+
const upper = Math.ceil(position);
|
|
36
|
+
const lowerValue = sorted[lower] ?? 0;
|
|
37
|
+
const upperValue = sorted[upper] ?? lowerValue;
|
|
38
|
+
return lowerValue + (upperValue - lowerValue) * (position - lower);
|
|
39
|
+
}
|
|
40
|
+
function round(value) {
|
|
41
|
+
return Math.round(value * 1e3) / 1e3;
|
|
42
|
+
}
|
|
43
|
+
export {
|
|
44
|
+
summarizeFrames
|
|
45
|
+
};
|
|
@@ -32,10 +32,6 @@ function mobileMetroEnvCheck(target) {
|
|
|
32
32
|
status: marker === null ? "missing" : marker.fingerprint === fingerprint ? "current" : "changed"
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
-
function applyMobileMetroEnvPolicy(target, actions) {
|
|
36
|
-
if (mobileMetroEnvCheck(target).status === "current") return actions;
|
|
37
|
-
return actions.map((action) => action.id === "start-metro" && !action.argv?.includes("--clear") ? { ...action, argv: [...action.argv ?? [], "--clear"] } : action);
|
|
38
|
-
}
|
|
39
35
|
function recordMobileMetroEnvBaseline(target, expectedFingerprint) {
|
|
40
36
|
if (mobileMetroEnvFingerprint(target) !== expectedFingerprint) return false;
|
|
41
37
|
const marker = recipeRuntimePath(target, BASELINE_FILE);
|
|
@@ -69,7 +65,6 @@ function readBaseline(target) {
|
|
|
69
65
|
}
|
|
70
66
|
}
|
|
71
67
|
export {
|
|
72
|
-
applyMobileMetroEnvPolicy,
|
|
73
68
|
mobileMetroEnvCheck,
|
|
74
69
|
mobileMetroEnvFingerprint,
|
|
75
70
|
recordMobileMetroEnvBaseline
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCdpTraceCollector
|
|
3
|
+
} from "../performance/cdp-trace.js";
|
|
4
|
+
import {
|
|
5
|
+
connectMobileBroker
|
|
6
|
+
} from "../../network-observation.js";
|
|
7
|
+
async function createMobilePerformanceBackend(target, env) {
|
|
8
|
+
const { client } = await connectMobileBroker(target, env);
|
|
9
|
+
return createCdpTraceCollector(
|
|
10
|
+
client,
|
|
11
|
+
resolvePlatform(env),
|
|
12
|
+
async (name) => {
|
|
13
|
+
const before = Date.now();
|
|
14
|
+
await markRuntime(client, name);
|
|
15
|
+
const after = Date.now();
|
|
16
|
+
return {
|
|
17
|
+
hostEpochMs: (before + after) / 2,
|
|
18
|
+
uncertaintyMs: (after - before) / 2
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
async function markRuntime(client, name) {
|
|
24
|
+
await client.send(
|
|
25
|
+
"Runtime.evaluate",
|
|
26
|
+
{
|
|
27
|
+
expression: `performance.mark(${JSON.stringify(name)})`,
|
|
28
|
+
returnByValue: true,
|
|
29
|
+
awaitPromise: false
|
|
30
|
+
},
|
|
31
|
+
1e4
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
function resolvePlatform(env) {
|
|
35
|
+
const explicit = String(
|
|
36
|
+
env.MM_HARNESS_EXPLICIT_PLATFORM ?? env.RECIPE_HARNESS_PLATFORM ?? ""
|
|
37
|
+
).toLowerCase();
|
|
38
|
+
if (explicit === "android" || explicit === "ios") return explicit;
|
|
39
|
+
return env.ADB_SERIAL || env.ANDROID_SERIAL || env.ANDROID_DEVICE ? "android" : "ios";
|
|
40
|
+
}
|
|
41
|
+
export {
|
|
42
|
+
createMobilePerformanceBackend
|
|
43
|
+
};
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
launchActions
|
|
10
10
|
} from "./runtime-decision.js";
|
|
11
11
|
import {
|
|
12
|
-
applyMobileMetroEnvPolicy,
|
|
13
12
|
mobileMetroEnvCheck,
|
|
14
13
|
recordMobileMetroEnvBaseline
|
|
15
14
|
} from "./metro-env.js";
|
|
@@ -241,9 +240,8 @@ function withAppRestart(actions, restartApp) {
|
|
|
241
240
|
}
|
|
242
241
|
async function dispatchActionSequence(requestedActions, target, platform, json, preflightMode, watcherPort) {
|
|
243
242
|
const resolved = path.resolve(target);
|
|
244
|
-
const actions = applyMobileMetroEnvPolicy(resolved, requestedActions);
|
|
245
243
|
let pendingMetroEnvFingerprint;
|
|
246
|
-
for (const action of
|
|
244
|
+
for (const action of requestedActions) {
|
|
247
245
|
if (action.id === "start-metro") {
|
|
248
246
|
pendingMetroEnvFingerprint = mobileMetroEnvCheck(resolved).fingerprint;
|
|
249
247
|
}
|
|
@@ -13,7 +13,7 @@ import { probeMetroPackager } from "@farmslot/recipe-harness/runtime/metro-probe
|
|
|
13
13
|
import { recipeRuntimePath } from "../../paths.js";
|
|
14
14
|
import { resolveMobileToolPath } from "../../../library/actions/mobile/platform/tool-paths.mjs";
|
|
15
15
|
import { mobileProductMarkers } from "./deps-markers.js";
|
|
16
|
-
import {
|
|
16
|
+
import { mobileMetroEnvCheck } from "./metro-env.js";
|
|
17
17
|
const BUNDLE_ERR = /Bundling failed|Unable to resolve /u;
|
|
18
18
|
const BUNDLE_OK = /Bundled \d+ms|iOS Bundled|Android Bundled|Finished bundling/u;
|
|
19
19
|
const NATIVE_MODULE_STALE = /\[runtime not ready\].*HybridObject "([^"]+)" - It has not yet been registered in the Nitro Modules HybridObjectRegistry/u;
|
|
@@ -130,13 +130,10 @@ async function decideMobileReadiness(target, options = {}) {
|
|
|
130
130
|
decision: "launch",
|
|
131
131
|
reasonCode: report.decision === "ready" ? env.status === "missing" ? "metro-env-baseline-missing" : "metro-env-changed" : report.reasonCode,
|
|
132
132
|
reasons: [
|
|
133
|
-
env.status === "missing" ? "Metro environment has no successful bundle baseline; restart
|
|
133
|
+
env.status === "missing" ? "Metro environment has no successful bundle baseline; restart Metro to load the current environment without clearing its transform cache." : "Metro environment inputs changed since the successful bundle baseline; restart Metro without clearing its transform cache. Pass --clear-metro only if stale transforms are observed.",
|
|
134
134
|
...report.reasons
|
|
135
135
|
],
|
|
136
|
-
actions:
|
|
137
|
-
resolved,
|
|
138
|
-
report.decision === "ready" ? launchActions(resolved) : report.actions
|
|
139
|
-
)
|
|
136
|
+
actions: report.decision === "ready" ? launchActions(resolved) : report.actions
|
|
140
137
|
};
|
|
141
138
|
}
|
|
142
139
|
async function computeMobileReadiness(resolved, options, fast) {
|
|
@@ -216,7 +213,7 @@ async function computeMobileReadiness(resolved, options, fast) {
|
|
|
216
213
|
checks,
|
|
217
214
|
actions: [
|
|
218
215
|
{ id: "rebuild-native-dev-client" },
|
|
219
|
-
...launchActions(resolved
|
|
216
|
+
...launchActions(resolved)
|
|
220
217
|
]
|
|
221
218
|
};
|
|
222
219
|
}
|
|
@@ -231,11 +228,11 @@ async function computeMobileReadiness(resolved, options, fast) {
|
|
|
231
228
|
decision: "launch",
|
|
232
229
|
reasonCode: metroLog.reason === "stale-bundle-error" ? "metro-stale-log" : "bundle-errors-recoverable",
|
|
233
230
|
reasons: [
|
|
234
|
-
metroLog.reason === "stale-bundle-error" ? "Metro log shows prior bundle failures but cited modules are installed; restart Metro
|
|
231
|
+
metroLog.reason === "stale-bundle-error" ? "Metro log shows prior bundle failures but cited modules are installed; restart Metro without clearing the cache. Pass --clear-metro if the failure persists." : "Metro bundle is failing with current deps; restart Metro without clearing the cache. Pass --clear-metro if the failure persists.",
|
|
235
232
|
...excerpt ? [excerpt] : []
|
|
236
233
|
],
|
|
237
234
|
checks,
|
|
238
|
-
actions: launchActions(resolved
|
|
235
|
+
actions: launchActions(resolved)
|
|
239
236
|
};
|
|
240
237
|
}
|
|
241
238
|
return {
|