@deeeed/metamask-harness 0.33.3 → 0.34.1
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/adapters/manifest.json +0 -8
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +32 -5
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +91 -24
- package/adapters/mobile/launch-metro.cjs +6 -4
- package/adapters/mobile/open-device.sh +164 -15
- package/adapters/mobile/start-metro.sh +165 -93
- package/adapters/mobile/stop-metro.sh +1 -0
- package/dist/adapters/mobile/prepare.js +14 -0
- package/dist/adapters/mobile/video-recorder.js +284 -0
- package/dist/adapters.js +225 -35
- package/dist/command-contract.js +1 -0
- package/dist/commands/device-target.js +17 -6
- package/dist/commands/launch/index.js +36 -5
- package/dist/commands/launch/mobile.js +10 -3
- package/dist/commands/run-engine.js +34 -2
- package/dist/devices.js +9 -2
- package/dist/heal-bounds.js +10 -0
- package/dist/live-adapter-contract.js +7 -0
- package/dist/mm-harness-cli.js +4 -2
- package/dist/recipe-security.js +1 -0
- package/dist/recording-target.js +23 -5
- package/dist/runner.js +107 -6
- package/dist/runtime-context.js +33 -1
- package/docs/RECIPES.md +41 -0
- package/library/actions/mobile/app/network-control.mjs +135 -0
- package/library/actions/mobile/app/network.mjs +4 -0
- package/library/actions/mobile/perps/capture_performance.mjs +4 -0
- package/library/actions/mobile/perps/performance-capture.mjs +465 -0
- package/library/actions/mobile/perps/perps.mjs +16 -0
- package/library/actions/mobile/platform/bridge.mjs +121 -3
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +56 -12
- package/library/actions/mobile/wallet/select_account.mjs +21 -17
- package/library/manifests/mobile.action-manifest.json +148 -1
- package/library/recipes/mobile/perps/performance.homepage.android-background-reconnect.recipe.json +159 -0
- package/library/recipes/mobile/perps/performance.homepage.android-background-short.recipe.json +157 -0
- package/library/recipes/mobile/perps/performance.homepage.android-cold-disk-cache.recipe.json +165 -0
- package/library/recipes/mobile/perps/performance.homepage.android-cold-no-cache.recipe.json +139 -0
- package/library/recipes/mobile/perps/performance.homepage.android-network-recovery.recipe.json +149 -0
- package/library/recipes/mobile/perps/performance.homepage.ios-background-reconnect.recipe.json +110 -0
- package/library/recipes/mobile/perps/performance.homepage.ios-background-short.recipe.json +108 -0
- package/library/recipes/mobile/perps/performance.homepage.ios-cold-disk-cache.recipe.json +122 -0
- package/library/recipes/mobile/perps/performance.homepage.ios-cold-no-cache.recipe.json +95 -0
- package/package.json +1 -1
- package/adapters/mobile/metro-config.cjs +0 -93
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const MARKER = '[HomepagePerf]';
|
|
5
|
+
const STATE_FILE = '.homepage-performance-capture.json';
|
|
6
|
+
|
|
7
|
+
function resolveWithin(root, relativePath, label) {
|
|
8
|
+
const absoluteRoot = path.resolve(root);
|
|
9
|
+
const absolutePath = path.resolve(absoluteRoot, relativePath);
|
|
10
|
+
const relative = path.relative(absoluteRoot, absolutePath);
|
|
11
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
12
|
+
throw new Error(`${label} must stay within ${absoluteRoot}.`);
|
|
13
|
+
}
|
|
14
|
+
return absolutePath;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function fileSize(file) {
|
|
18
|
+
try {
|
|
19
|
+
return (await stat(file)).size;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error?.code === 'ENOENT') return 0;
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseRecord(line) {
|
|
27
|
+
const markerIndex = line.indexOf(MARKER);
|
|
28
|
+
if (markerIndex < 0) return null;
|
|
29
|
+
const payload = line.slice(markerIndex + MARKER.length).trim();
|
|
30
|
+
try {
|
|
31
|
+
const event = JSON.parse(payload);
|
|
32
|
+
return event && typeof event === 'object' && !Array.isArray(event) ? event : null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function increment(counts, value) {
|
|
39
|
+
const key = value == null || value === '' ? 'unknown' : String(value);
|
|
40
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function roundMs(value) {
|
|
44
|
+
return Number(value.toFixed(3));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function contentVariantSatisfies(actual, required) {
|
|
48
|
+
if (actual === required) return true;
|
|
49
|
+
return (
|
|
50
|
+
actual === 'positions_and_orders' &&
|
|
51
|
+
(required === 'positions' || required === 'orders')
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function requiredFreshStreams(contentVariant, requiredVariants) {
|
|
56
|
+
if (requiredVariants.length === 0) return ['positions', 'orders'];
|
|
57
|
+
const streams = new Set();
|
|
58
|
+
for (const required of requiredVariants) {
|
|
59
|
+
if (!contentVariantSatisfies(contentVariant, required)) continue;
|
|
60
|
+
if (required === 'positions') streams.add('positions');
|
|
61
|
+
else if (required === 'orders') streams.add('orders');
|
|
62
|
+
else if (required === 'positions_and_orders') {
|
|
63
|
+
streams.add('positions');
|
|
64
|
+
streams.add('orders');
|
|
65
|
+
} else {
|
|
66
|
+
// Empty/trending content is derived from both collections.
|
|
67
|
+
streams.add('positions');
|
|
68
|
+
streams.add('orders');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return [...streams];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function visibleMeasurements(records, requiredFreshContentVariants = []) {
|
|
75
|
+
const demands = records.filter((record) => record.stage === 'viewport_demand');
|
|
76
|
+
return demands.map((demand) => {
|
|
77
|
+
const demandId = String(demand.demand_id ?? '');
|
|
78
|
+
const startedAt = Number(demand.monotonic_ms);
|
|
79
|
+
const frames = records.filter(
|
|
80
|
+
(record) =>
|
|
81
|
+
record.stage === 'next_frame_checkpoint' &&
|
|
82
|
+
String(record.demand_id ?? '') === demandId,
|
|
83
|
+
);
|
|
84
|
+
const frameTimes = frames
|
|
85
|
+
.map((record) => Number(record.frame_checkpoint_monotonic_ms ?? record.monotonic_ms))
|
|
86
|
+
.filter(Number.isFinite);
|
|
87
|
+
const firstVisibleAt = frameTimes.length > 0 ? Math.min(...frameTimes) : null;
|
|
88
|
+
const firstFreshByStream = {};
|
|
89
|
+
const freshStreamsSeen = new Set();
|
|
90
|
+
let freshVisibleAt = null;
|
|
91
|
+
let freshContentVariant = null;
|
|
92
|
+
const sortedFrames = [...frames].sort(
|
|
93
|
+
(left, right) =>
|
|
94
|
+
Number(left.frame_checkpoint_monotonic_ms ?? left.monotonic_ms) -
|
|
95
|
+
Number(right.frame_checkpoint_monotonic_ms ?? right.monotonic_ms),
|
|
96
|
+
);
|
|
97
|
+
for (const frame of sortedFrames) {
|
|
98
|
+
const freshVisible =
|
|
99
|
+
frame.source === 'fresh_socket' ||
|
|
100
|
+
(frame.source === 'resident_state' &&
|
|
101
|
+
frame.origin_source === 'fresh_socket' &&
|
|
102
|
+
frame.fresh_for_lifecycle === true);
|
|
103
|
+
if (!freshVisible || !frame.stream) continue;
|
|
104
|
+
const frameAt = Number(frame.frame_checkpoint_monotonic_ms ?? frame.monotonic_ms);
|
|
105
|
+
if (!Number.isFinite(frameAt)) continue;
|
|
106
|
+
const stream = String(frame.stream);
|
|
107
|
+
firstFreshByStream[stream] = Math.min(firstFreshByStream[stream] ?? frameAt, frameAt);
|
|
108
|
+
freshStreamsSeen.add(stream);
|
|
109
|
+
const contentVariant = String(frame.content_variant ?? '');
|
|
110
|
+
const contentMatches =
|
|
111
|
+
requiredFreshContentVariants.length === 0 ||
|
|
112
|
+
requiredFreshContentVariants.every((required) =>
|
|
113
|
+
contentVariantSatisfies(contentVariant, required),
|
|
114
|
+
);
|
|
115
|
+
const requiredStreams = requiredFreshStreams(
|
|
116
|
+
contentVariant,
|
|
117
|
+
requiredFreshContentVariants,
|
|
118
|
+
);
|
|
119
|
+
if (
|
|
120
|
+
freshVisibleAt === null &&
|
|
121
|
+
contentMatches &&
|
|
122
|
+
requiredStreams.length > 0 &&
|
|
123
|
+
requiredStreams.every((requiredStream) =>
|
|
124
|
+
freshStreamsSeen.has(requiredStream),
|
|
125
|
+
)
|
|
126
|
+
) {
|
|
127
|
+
freshVisibleAt = frameAt;
|
|
128
|
+
freshContentVariant = contentVariant || null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (
|
|
132
|
+
freshVisibleAt === null &&
|
|
133
|
+
requiredFreshContentVariants.length === 0 &&
|
|
134
|
+
firstFreshByStream.positions != null &&
|
|
135
|
+
firstFreshByStream.orders != null
|
|
136
|
+
) {
|
|
137
|
+
freshVisibleAt = Math.max(firstFreshByStream.positions, firstFreshByStream.orders);
|
|
138
|
+
}
|
|
139
|
+
const dataAges = frames.map((record) => Number(record.data_age_ms)).filter(Number.isFinite);
|
|
140
|
+
return {
|
|
141
|
+
demandId,
|
|
142
|
+
lifecycle: demand.lifecycle ?? 'unknown',
|
|
143
|
+
startedAtMonotonicMs: Number.isFinite(startedAt) ? startedAt : null,
|
|
144
|
+
firstVisibleAtMonotonicMs: firstVisibleAt,
|
|
145
|
+
ttcMs:
|
|
146
|
+
Number.isFinite(startedAt) && firstVisibleAt != null
|
|
147
|
+
? roundMs(firstVisibleAt - startedAt)
|
|
148
|
+
: null,
|
|
149
|
+
freshVisibleAtMonotonicMs: freshVisibleAt,
|
|
150
|
+
freshContentVariant,
|
|
151
|
+
dataReadyAtDemand: sortedFrames.some(
|
|
152
|
+
(frame) =>
|
|
153
|
+
frame.data_ready_at_demand === true &&
|
|
154
|
+
(frame.source === 'fresh_socket' ||
|
|
155
|
+
(frame.origin_source === 'fresh_socket' && frame.fresh_for_lifecycle === true)),
|
|
156
|
+
),
|
|
157
|
+
dfdMs:
|
|
158
|
+
Number.isFinite(startedAt) && freshVisibleAt != null
|
|
159
|
+
? roundMs(freshVisibleAt - startedAt)
|
|
160
|
+
: null,
|
|
161
|
+
deliverySources: [...new Set(frames.map((record) => String(record.source ?? 'unknown')))],
|
|
162
|
+
contentVariant: frames.find((record) => record.content_variant)?.content_variant ?? null,
|
|
163
|
+
maxDataAgeMs: dataAges.length > 0 ? Math.max(...dataAges) : null,
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function socketPipelineMeasurements(records) {
|
|
169
|
+
const sockets = records.filter(
|
|
170
|
+
(record) => record.stage === 'socket_received' && record.delivery_id,
|
|
171
|
+
);
|
|
172
|
+
return sockets.flatMap((socket) => {
|
|
173
|
+
const deliveryId = String(socket.delivery_id);
|
|
174
|
+
const socketAt = Number(socket.monotonic_ms);
|
|
175
|
+
const commit = records.find(
|
|
176
|
+
(record) => record.stage === 'react_commit' && String(record.delivery_id ?? '') === deliveryId,
|
|
177
|
+
);
|
|
178
|
+
const frame = records.find(
|
|
179
|
+
(record) => record.stage === 'next_frame_checkpoint' && String(record.delivery_id ?? '') === deliveryId,
|
|
180
|
+
);
|
|
181
|
+
if (!commit || !frame || !Number.isFinite(socketAt)) return [];
|
|
182
|
+
const commitAt = Number(commit.monotonic_ms);
|
|
183
|
+
const frameAt = Number(frame.frame_checkpoint_monotonic_ms ?? frame.monotonic_ms);
|
|
184
|
+
const subscriberCandidates = records
|
|
185
|
+
.filter(
|
|
186
|
+
(record) =>
|
|
187
|
+
record.stage === 'subscriber_delivery' &&
|
|
188
|
+
String(record.delivery_id ?? '') === deliveryId &&
|
|
189
|
+
Number(record.monotonic_ms) <= commitAt,
|
|
190
|
+
)
|
|
191
|
+
.map((record) => Number(record.monotonic_ms))
|
|
192
|
+
.filter(Number.isFinite);
|
|
193
|
+
const subscriberAt = subscriberCandidates.length > 0
|
|
194
|
+
? Math.max(...subscriberCandidates)
|
|
195
|
+
: socketAt;
|
|
196
|
+
return [{
|
|
197
|
+
deliveryId,
|
|
198
|
+
stream: socket.stream ?? null,
|
|
199
|
+
lifecycle: commit.lifecycle ?? socket.lifecycle ?? 'unknown',
|
|
200
|
+
socketToVisibleMs: roundMs(frameAt - socketAt),
|
|
201
|
+
socketToSubscriberMs: roundMs(subscriberAt - socketAt),
|
|
202
|
+
subscriberToCommitMs: roundMs(commitAt - subscriberAt),
|
|
203
|
+
commitToFrameMs: roundMs(frameAt - commitAt),
|
|
204
|
+
}];
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function firstFreshDeliveryChecks(records, requirements) {
|
|
209
|
+
return requirements.map((requirement) => {
|
|
210
|
+
const stream = String(requirement.stream);
|
|
211
|
+
const requiredLifecycle =
|
|
212
|
+
requirement.lifecycle === undefined
|
|
213
|
+
? null
|
|
214
|
+
: String(requirement.lifecycle);
|
|
215
|
+
const subscriberThrottleMs = Number(requirement.subscriber_throttle_ms);
|
|
216
|
+
const maxSocketToSubscriberMs = Number(
|
|
217
|
+
requirement.max_socket_to_subscriber_ms,
|
|
218
|
+
);
|
|
219
|
+
const socket = records.find(
|
|
220
|
+
(record) =>
|
|
221
|
+
record.stage === 'socket_received' &&
|
|
222
|
+
record.source === 'fresh_socket' &&
|
|
223
|
+
record.stream === stream &&
|
|
224
|
+
(requiredLifecycle === null ||
|
|
225
|
+
record.lifecycle === requiredLifecycle) &&
|
|
226
|
+
record.delivery_id,
|
|
227
|
+
);
|
|
228
|
+
const delivery = socket
|
|
229
|
+
? records.find(
|
|
230
|
+
(record) =>
|
|
231
|
+
record.stage === 'subscriber_delivery' &&
|
|
232
|
+
record.delivery_id === socket.delivery_id &&
|
|
233
|
+
record.stream === stream &&
|
|
234
|
+
record.lifecycle === socket.lifecycle &&
|
|
235
|
+
Number(record.throttle_ms ?? 0) === subscriberThrottleMs,
|
|
236
|
+
)
|
|
237
|
+
: null;
|
|
238
|
+
const socketAt = Number(socket?.monotonic_ms);
|
|
239
|
+
const subscriberAt = Number(delivery?.monotonic_ms);
|
|
240
|
+
const socketToSubscriberMs =
|
|
241
|
+
Number.isFinite(socketAt) && Number.isFinite(subscriberAt)
|
|
242
|
+
? roundMs(subscriberAt - socketAt)
|
|
243
|
+
: null;
|
|
244
|
+
return {
|
|
245
|
+
stream,
|
|
246
|
+
lifecycle: socket?.lifecycle ?? requiredLifecycle,
|
|
247
|
+
subscriberThrottleMs,
|
|
248
|
+
maxSocketToSubscriberMs,
|
|
249
|
+
deliveryId: socket?.delivery_id ?? null,
|
|
250
|
+
socketToSubscriberMs,
|
|
251
|
+
status:
|
|
252
|
+
socketToSubscriberMs !== null &&
|
|
253
|
+
socketToSubscriberMs <= maxSocketToSubscriberMs
|
|
254
|
+
? 'pass'
|
|
255
|
+
: 'fail',
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function summarize(
|
|
261
|
+
records,
|
|
262
|
+
sourcePath,
|
|
263
|
+
offset,
|
|
264
|
+
bytesScanned,
|
|
265
|
+
requiredFreshContentVariants = [],
|
|
266
|
+
firstFreshDeliveryRequirements = [],
|
|
267
|
+
) {
|
|
268
|
+
const stages = {};
|
|
269
|
+
const lifecycles = {};
|
|
270
|
+
const streams = {};
|
|
271
|
+
const sources = {};
|
|
272
|
+
for (const record of records) {
|
|
273
|
+
increment(stages, record.stage);
|
|
274
|
+
increment(lifecycles, record.lifecycle);
|
|
275
|
+
increment(streams, record.stream);
|
|
276
|
+
increment(sources, record.source);
|
|
277
|
+
}
|
|
278
|
+
const monotonicValues = records
|
|
279
|
+
.map((record) => Number(record.monotonic_ms))
|
|
280
|
+
.filter(Number.isFinite);
|
|
281
|
+
return {
|
|
282
|
+
schemaVersion: 1,
|
|
283
|
+
marker: MARKER,
|
|
284
|
+
sourcePath,
|
|
285
|
+
offset,
|
|
286
|
+
bytesScanned,
|
|
287
|
+
recordCount: records.length,
|
|
288
|
+
stages,
|
|
289
|
+
lifecycles,
|
|
290
|
+
streams,
|
|
291
|
+
sources,
|
|
292
|
+
firstMonotonicMs: monotonicValues.length > 0 ? Math.min(...monotonicValues) : null,
|
|
293
|
+
lastMonotonicMs: monotonicValues.length > 0 ? Math.max(...monotonicValues) : null,
|
|
294
|
+
visibleMeasurements: visibleMeasurements(records, requiredFreshContentVariants),
|
|
295
|
+
socketPipelineMeasurements: socketPipelineMeasurements(records),
|
|
296
|
+
firstFreshDeliveryChecks: firstFreshDeliveryChecks(
|
|
297
|
+
records,
|
|
298
|
+
firstFreshDeliveryRequirements,
|
|
299
|
+
),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function captureHomepagePerformance(input) {
|
|
304
|
+
const node = input.node ?? {};
|
|
305
|
+
const phase = String(node.phase ?? '').toLowerCase();
|
|
306
|
+
if (phase !== 'start' && phase !== 'end') {
|
|
307
|
+
throw new Error('metamask.perps.capture_performance requires phase=start or phase=end.');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const projectRoot = input.context?.projectRoot;
|
|
311
|
+
const artifactsDir = input.context?.artifactsDir;
|
|
312
|
+
if (!projectRoot || !artifactsDir) {
|
|
313
|
+
throw new Error('metamask.perps.capture_performance requires projectRoot and artifactsDir.');
|
|
314
|
+
}
|
|
315
|
+
const sourcePath = String(node.source_path ?? 'temp/recipe/runtime/app-console.log');
|
|
316
|
+
const sourceFile = resolveWithin(projectRoot, sourcePath, 'source_path');
|
|
317
|
+
const stateFile = resolveWithin(artifactsDir, STATE_FILE, 'capture state');
|
|
318
|
+
await mkdir(artifactsDir, { recursive: true });
|
|
319
|
+
|
|
320
|
+
if (phase === 'start') {
|
|
321
|
+
const offset = await fileSize(sourceFile);
|
|
322
|
+
await writeFile(stateFile, `${JSON.stringify({ sourcePath, offset }, null, 2)}\n`);
|
|
323
|
+
return { phase, sourcePath, offset };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let state;
|
|
327
|
+
try {
|
|
328
|
+
state = JSON.parse(await readFile(stateFile, 'utf8'));
|
|
329
|
+
} catch (error) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
'metamask.perps.capture_performance phase=end requires a successful phase=start in the same recipe run.',
|
|
332
|
+
{ cause: error },
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
if (state.sourcePath !== sourcePath || !Number.isInteger(state.offset) || state.offset < 0) {
|
|
336
|
+
throw new Error('metamask.perps.capture_performance capture state is invalid or uses a different source_path.');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const content = await readFile(sourceFile);
|
|
340
|
+
const offset = state.offset <= content.length ? state.offset : 0;
|
|
341
|
+
const segment = content.subarray(offset).toString('utf8');
|
|
342
|
+
const lines = segment.split(/\r?\n/u).filter((line) => line.includes(MARKER));
|
|
343
|
+
const records = lines.map(parseRecord).filter(Boolean);
|
|
344
|
+
const requireRecords = node.require_records !== false;
|
|
345
|
+
const requiredStages = Array.isArray(node.required_stages)
|
|
346
|
+
? node.required_stages.map(String)
|
|
347
|
+
: [];
|
|
348
|
+
const presentStages = new Set(records.map((record) => String(record.stage ?? '')));
|
|
349
|
+
const missingStages = requiredStages.filter((stage) => !presentStages.has(stage));
|
|
350
|
+
const requiredLifecycles = Array.isArray(node.required_lifecycles)
|
|
351
|
+
? node.required_lifecycles.map(String)
|
|
352
|
+
: [];
|
|
353
|
+
const presentLifecycles = new Set(records.map((record) => String(record.lifecycle ?? '')));
|
|
354
|
+
const missingLifecycles = requiredLifecycles.filter(
|
|
355
|
+
(lifecycle) => !presentLifecycles.has(lifecycle),
|
|
356
|
+
);
|
|
357
|
+
const requiredSources = Array.isArray(node.required_sources)
|
|
358
|
+
? node.required_sources.map(String)
|
|
359
|
+
: [];
|
|
360
|
+
const presentSources = new Set(records.map((record) => String(record.source ?? '')));
|
|
361
|
+
const missingSources = requiredSources.filter((source) => !presentSources.has(source));
|
|
362
|
+
const requiredFreshContentVariants = Array.isArray(node.required_fresh_content_variants)
|
|
363
|
+
? node.required_fresh_content_variants.map(String)
|
|
364
|
+
: [];
|
|
365
|
+
const measurements = visibleMeasurements(records, requiredFreshContentVariants);
|
|
366
|
+
const presentFreshContentVariants = new Set(
|
|
367
|
+
measurements
|
|
368
|
+
.filter((measurement) => measurement.dfdMs !== null)
|
|
369
|
+
.map((measurement) => String(measurement.freshContentVariant ?? '')),
|
|
370
|
+
);
|
|
371
|
+
const missingFreshContentVariants = requiredFreshContentVariants.filter(
|
|
372
|
+
(variant) =>
|
|
373
|
+
![...presentFreshContentVariants].some((actual) =>
|
|
374
|
+
contentVariantSatisfies(actual, variant),
|
|
375
|
+
),
|
|
376
|
+
);
|
|
377
|
+
const firstFreshDeliveryRequirements = Array.isArray(
|
|
378
|
+
node.first_fresh_delivery_requirements,
|
|
379
|
+
)
|
|
380
|
+
? node.first_fresh_delivery_requirements
|
|
381
|
+
: [];
|
|
382
|
+
const deliveryChecks = firstFreshDeliveryChecks(
|
|
383
|
+
records,
|
|
384
|
+
firstFreshDeliveryRequirements,
|
|
385
|
+
);
|
|
386
|
+
const failedFirstFreshDeliveries = deliveryChecks.filter(
|
|
387
|
+
(check) => check.status === 'fail',
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
const prefix = String(node.artifact_prefix ?? 'logs/homepage-performance');
|
|
391
|
+
const logPath = `${prefix}.log`;
|
|
392
|
+
const summaryPath = `${prefix}-summary.json`;
|
|
393
|
+
const logFile = resolveWithin(artifactsDir, logPath, 'artifact_prefix');
|
|
394
|
+
const summaryFile = resolveWithin(artifactsDir, summaryPath, 'artifact_prefix');
|
|
395
|
+
const validation = {
|
|
396
|
+
status:
|
|
397
|
+
(!requireRecords || records.length > 0) &&
|
|
398
|
+
missingStages.length === 0 &&
|
|
399
|
+
missingLifecycles.length === 0 &&
|
|
400
|
+
missingSources.length === 0 &&
|
|
401
|
+
missingFreshContentVariants.length === 0 &&
|
|
402
|
+
failedFirstFreshDeliveries.length === 0
|
|
403
|
+
? 'pass'
|
|
404
|
+
: 'fail',
|
|
405
|
+
requireRecords,
|
|
406
|
+
missingStages,
|
|
407
|
+
missingLifecycles,
|
|
408
|
+
missingSources,
|
|
409
|
+
missingFreshContentVariants,
|
|
410
|
+
failedFirstFreshDeliveries,
|
|
411
|
+
};
|
|
412
|
+
const summary = {
|
|
413
|
+
...summarize(
|
|
414
|
+
records,
|
|
415
|
+
sourcePath,
|
|
416
|
+
offset,
|
|
417
|
+
content.length - offset,
|
|
418
|
+
requiredFreshContentVariants,
|
|
419
|
+
firstFreshDeliveryRequirements,
|
|
420
|
+
),
|
|
421
|
+
validation,
|
|
422
|
+
};
|
|
423
|
+
const nodeId = String(input.context?.nodeId ?? 'capture-performance');
|
|
424
|
+
await mkdir(path.dirname(logFile), { recursive: true });
|
|
425
|
+
await writeFile(logFile, lines.length > 0 ? `${lines.join('\n')}\n` : '');
|
|
426
|
+
await writeFile(summaryFile, `${JSON.stringify(summary, null, 2)}\n`);
|
|
427
|
+
await rm(stateFile, { force: true });
|
|
428
|
+
|
|
429
|
+
if (requireRecords && records.length === 0) {
|
|
430
|
+
throw new Error(`No ${MARKER} records were emitted after the capture started.`);
|
|
431
|
+
}
|
|
432
|
+
if (missingStages.length > 0) {
|
|
433
|
+
throw new Error(`Missing required ${MARKER} stages: ${missingStages.join(', ')}.`);
|
|
434
|
+
}
|
|
435
|
+
if (missingLifecycles.length > 0) {
|
|
436
|
+
throw new Error(`Missing required ${MARKER} lifecycles: ${missingLifecycles.join(', ')}.`);
|
|
437
|
+
}
|
|
438
|
+
if (missingSources.length > 0) {
|
|
439
|
+
throw new Error(`Missing required ${MARKER} sources: ${missingSources.join(', ')}.`);
|
|
440
|
+
}
|
|
441
|
+
if (missingFreshContentVariants.length > 0) {
|
|
442
|
+
throw new Error(
|
|
443
|
+
`Missing required fresh ${MARKER} content variants: ${missingFreshContentVariants.join(', ')}.`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
if (failedFirstFreshDeliveries.length > 0) {
|
|
447
|
+
const failures = failedFirstFreshDeliveries.map(
|
|
448
|
+
(check) =>
|
|
449
|
+
`${check.stream}/${check.lifecycle ?? 'any'} throttle=${check.subscriberThrottleMs}ms ` +
|
|
450
|
+
`measured=${check.socketToSubscriberMs ?? 'missing'}ms max=${check.maxSocketToSubscriberMs}ms`,
|
|
451
|
+
);
|
|
452
|
+
throw new Error(
|
|
453
|
+
`First fresh ${MARKER} delivery requirements failed: ${failures.join('; ')}.`,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
phase,
|
|
459
|
+
...summary,
|
|
460
|
+
artifacts: [
|
|
461
|
+
{ path: logPath, type: 'log', nodeId },
|
|
462
|
+
{ path: summaryPath, type: 'report', nodeId },
|
|
463
|
+
],
|
|
464
|
+
};
|
|
465
|
+
}
|
|
@@ -139,6 +139,21 @@ async function readOpenOrders(input) {
|
|
|
139
139
|
return orders;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
async function clearPerformanceCaches(input) {
|
|
143
|
+
return evalAsync(
|
|
144
|
+
input,
|
|
145
|
+
`(function(){
|
|
146
|
+
var bridge = globalThis.__AGENTIC__;
|
|
147
|
+
if (!bridge || typeof bridge.clearPerpsPerformanceCaches !== 'function') {
|
|
148
|
+
throw new Error('__AGENTIC__.clearPerpsPerformanceCaches is unavailable; reload the current Mobile development source.');
|
|
149
|
+
}
|
|
150
|
+
return Promise.resolve(bridge.clearPerpsPerformanceCaches()).then(function(result){
|
|
151
|
+
return JSON.stringify(result);
|
|
152
|
+
});
|
|
153
|
+
})()`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
142
157
|
async function waitForPositionsAbsent(input, symbols, timeoutMs = 30000) {
|
|
143
158
|
const deadline = Date.now() + timeoutMs;
|
|
144
159
|
let last = [];
|
|
@@ -830,6 +845,7 @@ const DIRECT_ACTIONS = new Map([
|
|
|
830
845
|
['metamask.perps.place_order', placeOrder],
|
|
831
846
|
['metamask.perps.ensure_positions', ensurePositions],
|
|
832
847
|
['metamask.perps.ensure_orders', ensureOrders],
|
|
848
|
+
['metamask.perps.clear_performance_caches', clearPerformanceCaches],
|
|
833
849
|
['metamask.perps.start_state', startState],
|
|
834
850
|
['metamask.perps.teardown_state', teardownState],
|
|
835
851
|
]);
|
|
@@ -23,6 +23,41 @@ export const MOBILE_BRIDGE_ERROR_CODES = BRIDGE_ERROR_CODES;
|
|
|
23
23
|
|
|
24
24
|
const execFileAsync = promisify(execFile);
|
|
25
25
|
|
|
26
|
+
let adapterActionActive = false;
|
|
27
|
+
let actionBridgeLockPath = null;
|
|
28
|
+
|
|
29
|
+
function resolveBridgeLockPath(input, env) {
|
|
30
|
+
const configuredRuntime = env.RECIPE_RUNTIME_DIR;
|
|
31
|
+
const runtime = configuredRuntime
|
|
32
|
+
? (path.isAbsolute(configuredRuntime)
|
|
33
|
+
? configuredRuntime
|
|
34
|
+
: path.resolve(input.context.projectRoot, configuredRuntime))
|
|
35
|
+
: path.join(input.context.projectRoot, 'temp', 'recipe', 'runtime');
|
|
36
|
+
return path.join(runtime, 'cdp-bridge.lock');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function acquireActionBridgeLock(input, env) {
|
|
40
|
+
if (actionBridgeLockPath) return String(process.pid);
|
|
41
|
+
const lockPath = resolveBridgeLockPath(input, env);
|
|
42
|
+
await mkdir(path.dirname(lockPath), { recursive: true });
|
|
43
|
+
await writeFile(lockPath, String(process.pid));
|
|
44
|
+
actionBridgeLockPath = lockPath;
|
|
45
|
+
return String(process.pid);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function releaseActionBridgeLock() {
|
|
49
|
+
const lockPath = actionBridgeLockPath;
|
|
50
|
+
actionBridgeLockPath = null;
|
|
51
|
+
if (!lockPath) return;
|
|
52
|
+
try {
|
|
53
|
+
if ((await readFile(lockPath, 'utf8')).trim() === String(process.pid)) {
|
|
54
|
+
await rm(lockPath, { force: true });
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// The bridge child or a newer owner may already have replaced the lock.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
26
61
|
// Recover the typed code a cdp-bridge child reported: the stderr marker is
|
|
27
62
|
// authoritative (it carries the class the bridge classified at the source), the
|
|
28
63
|
// coded exit status is the fallback for output-less failures, and a raw message
|
|
@@ -52,8 +87,14 @@ export async function writeOutput(input, output) {
|
|
|
52
87
|
|
|
53
88
|
export async function runAdapter(callback) {
|
|
54
89
|
const input = await loadInput();
|
|
55
|
-
|
|
56
|
-
|
|
90
|
+
adapterActionActive = true;
|
|
91
|
+
try {
|
|
92
|
+
const output = await callback(input);
|
|
93
|
+
await writeOutput(input, output);
|
|
94
|
+
} finally {
|
|
95
|
+
adapterActionActive = false;
|
|
96
|
+
await releaseActionBridgeLock();
|
|
97
|
+
}
|
|
57
98
|
}
|
|
58
99
|
|
|
59
100
|
function bridgeScript(input) {
|
|
@@ -127,7 +168,13 @@ export async function bridgeEnv(input) {
|
|
|
127
168
|
// (e.g. "Pixel 6" matches Metro deviceName "Pixel 6 - 16 - API 36").
|
|
128
169
|
const serialStr = adbSerial != null ? String(adbSerial) : '';
|
|
129
170
|
const deviceStr = androidDevice != null ? String(androidDevice) : '';
|
|
130
|
-
if (
|
|
171
|
+
if (
|
|
172
|
+
shouldResolveAndroidTargetModel(
|
|
173
|
+
serialStr,
|
|
174
|
+
deviceStr,
|
|
175
|
+
androidTargetDeviceName,
|
|
176
|
+
)
|
|
177
|
+
) {
|
|
131
178
|
const model = await resolveAndroidModel(serialStr);
|
|
132
179
|
if (model) {
|
|
133
180
|
env.ANDROID_TARGET_DEVICE_NAME = model;
|
|
@@ -150,6 +197,17 @@ export async function bridgeEnv(input) {
|
|
|
150
197
|
return env;
|
|
151
198
|
}
|
|
152
199
|
|
|
200
|
+
export function shouldResolveAndroidTargetModel(
|
|
201
|
+
adbSerial,
|
|
202
|
+
androidDevice,
|
|
203
|
+
androidTargetDeviceName,
|
|
204
|
+
) {
|
|
205
|
+
const serial = String(adbSerial ?? '').trim();
|
|
206
|
+
const device = String(androidDevice ?? '').trim();
|
|
207
|
+
const targetName = String(androidTargetDeviceName ?? '').trim();
|
|
208
|
+
return Boolean(serial && !targetName && (!device || device === serial));
|
|
209
|
+
}
|
|
210
|
+
|
|
153
211
|
function resolveMobileTarget(input) {
|
|
154
212
|
const contextEnv = input.context?.env || {};
|
|
155
213
|
const watcherPort = input.node?.watcher_port ?? input.node?.metro_port ?? input.node?.cdp_port ?? contextEnv.WATCHER_PORT ?? contextEnv.CDP_PORT ?? contextEnv.RECIPE_CDP_PORT ?? process.env.WATCHER_PORT ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
|
|
@@ -314,6 +372,9 @@ export async function bridgeCommand(input, args) {
|
|
|
314
372
|
const script = bridgeScript(input);
|
|
315
373
|
// bridgeEnv is async: it may call `adb getprop` to resolve the Metro device name.
|
|
316
374
|
const env = await bridgeEnv(input);
|
|
375
|
+
if (adapterActionActive) {
|
|
376
|
+
env.CDP_BRIDGE_LOCK_OWNER_PID = await acquireActionBridgeLock(input, env);
|
|
377
|
+
}
|
|
317
378
|
const result = await new Promise((resolve, reject) => {
|
|
318
379
|
const timeoutMs = Number(input.node?.bridge_timeout_ms ?? input.node?.cdp_timeout_ms ?? process.env.CDP_TIMEOUT ?? 30000);
|
|
319
380
|
const child = spawn(process.execPath, [script, ...args], {
|
|
@@ -730,6 +791,7 @@ async function androidScreenshot(input, relPath) {
|
|
|
730
791
|
temporaryDirectory = await createScreenshotTemporaryDirectory();
|
|
731
792
|
const adbSerial = await resolveAndroidScreenshotSerial(input);
|
|
732
793
|
const adbPath = resolveMobileToolPath('adb', { required: true });
|
|
794
|
+
await assertAndroidScreenshotIsAllowed(adbPath, adbSerial);
|
|
733
795
|
let result;
|
|
734
796
|
try {
|
|
735
797
|
result = await runScreenshotProcess(adbPath, ['-s', adbSerial, 'exec-out', 'screencap', '-p'], true);
|
|
@@ -777,6 +839,62 @@ async function androidScreenshot(input, relPath) {
|
|
|
777
839
|
}
|
|
778
840
|
}
|
|
779
841
|
|
|
842
|
+
async function assertAndroidScreenshotIsAllowed(adbPath, adbSerial) {
|
|
843
|
+
let result;
|
|
844
|
+
try {
|
|
845
|
+
result = await runScreenshotProcess(
|
|
846
|
+
adbPath,
|
|
847
|
+
['-s', adbSerial, 'shell', 'dumpsys', 'window'],
|
|
848
|
+
false,
|
|
849
|
+
);
|
|
850
|
+
} catch (error) {
|
|
851
|
+
throw new Error(
|
|
852
|
+
`adb screenshot could not inspect the focused window for ${adbSerial}: ${error instanceof Error ? error.message : String(error)}\n` +
|
|
853
|
+
` Next: mm-harness doctor --adapter mobile --device ${adbSerial} --json`,
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
if (result.exitCode !== 0) {
|
|
857
|
+
throw new Error(
|
|
858
|
+
`adb screenshot could not inspect the focused window for ${adbSerial}: ${result.stderr || result.stdout}\n` +
|
|
859
|
+
` Next: mm-harness doctor --adapter mobile --device ${adbSerial} --json`,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const focusMatch = result.stdout.match(/mCurrentFocus=Window\{([^\s}]+)/u);
|
|
864
|
+
if (!focusMatch) {
|
|
865
|
+
throw new Error(
|
|
866
|
+
`adb screenshot could not identify the focused Android window for ${adbSerial}.\n` +
|
|
867
|
+
' Next: wake and unlock the selected device, wait for the target screen to settle, then rerun the same mm-harness command.',
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const focusedWindowId = focusMatch[1];
|
|
872
|
+
const windowStart = result.stdout.search(
|
|
873
|
+
new RegExp(`^\\s*Window #\\d+ Window\\{${escapeRegExp(focusedWindowId)}(?:\\s|\\})`, 'mu'),
|
|
874
|
+
);
|
|
875
|
+
const nextWindow = windowStart < 0
|
|
876
|
+
? -1
|
|
877
|
+
: result.stdout.slice(windowStart).search(/\n\s*Window #\d+ Window\{/u);
|
|
878
|
+
const focusedWindow = windowStart < 0
|
|
879
|
+
? ''
|
|
880
|
+
: result.stdout.slice(
|
|
881
|
+
windowStart,
|
|
882
|
+
nextWindow < 0 ? undefined : windowStart + nextWindow,
|
|
883
|
+
);
|
|
884
|
+
if (/\b(?:FLAG_)?SECURE\b/u.test(focusedWindow)) {
|
|
885
|
+
const error = new Error(
|
|
886
|
+
`Android screenshot is protected by FLAG_SECURE on the focused window for ${adbSerial}; the resulting black image is not valid visual evidence.\n` +
|
|
887
|
+
' Next: use a non-protected screen (for MetaMask Mobile, unlock the wallet first), then rerun the same mm-harness command.',
|
|
888
|
+
);
|
|
889
|
+
error.code = 'SCREENSHOT_PROTECTED';
|
|
890
|
+
throw error;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function escapeRegExp(value) {
|
|
895
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
896
|
+
}
|
|
897
|
+
|
|
780
898
|
async function resolveAndroidScreenshotSerial(input) {
|
|
781
899
|
const contextEnv = input.context?.env || {};
|
|
782
900
|
const explicit = input.node?.adb_serial ??
|