@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/adapters/manifest.json +0 -8
  3. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +32 -5
  4. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +91 -24
  5. package/adapters/mobile/launch-metro.cjs +6 -4
  6. package/adapters/mobile/open-device.sh +164 -15
  7. package/adapters/mobile/start-metro.sh +165 -93
  8. package/adapters/mobile/stop-metro.sh +1 -0
  9. package/dist/adapters/mobile/prepare.js +14 -0
  10. package/dist/adapters/mobile/video-recorder.js +284 -0
  11. package/dist/adapters.js +225 -35
  12. package/dist/command-contract.js +1 -0
  13. package/dist/commands/device-target.js +17 -6
  14. package/dist/commands/launch/index.js +36 -5
  15. package/dist/commands/launch/mobile.js +10 -3
  16. package/dist/commands/run-engine.js +34 -2
  17. package/dist/devices.js +9 -2
  18. package/dist/heal-bounds.js +10 -0
  19. package/dist/live-adapter-contract.js +7 -0
  20. package/dist/mm-harness-cli.js +4 -2
  21. package/dist/recipe-security.js +1 -0
  22. package/dist/recording-target.js +23 -5
  23. package/dist/runner.js +107 -6
  24. package/dist/runtime-context.js +33 -1
  25. package/docs/RECIPES.md +41 -0
  26. package/library/actions/mobile/app/network-control.mjs +135 -0
  27. package/library/actions/mobile/app/network.mjs +4 -0
  28. package/library/actions/mobile/perps/capture_performance.mjs +4 -0
  29. package/library/actions/mobile/perps/performance-capture.mjs +465 -0
  30. package/library/actions/mobile/perps/perps.mjs +16 -0
  31. package/library/actions/mobile/platform/bridge.mjs +121 -3
  32. package/library/actions/mobile/wallet/ensure_unlocked.mjs +56 -12
  33. package/library/actions/mobile/wallet/select_account.mjs +21 -17
  34. package/library/manifests/mobile.action-manifest.json +148 -1
  35. package/library/recipes/mobile/perps/performance.homepage.android-background-reconnect.recipe.json +159 -0
  36. package/library/recipes/mobile/perps/performance.homepage.android-background-short.recipe.json +157 -0
  37. package/library/recipes/mobile/perps/performance.homepage.android-cold-disk-cache.recipe.json +165 -0
  38. package/library/recipes/mobile/perps/performance.homepage.android-cold-no-cache.recipe.json +139 -0
  39. package/library/recipes/mobile/perps/performance.homepage.android-network-recovery.recipe.json +149 -0
  40. package/library/recipes/mobile/perps/performance.homepage.ios-background-reconnect.recipe.json +110 -0
  41. package/library/recipes/mobile/perps/performance.homepage.ios-background-short.recipe.json +108 -0
  42. package/library/recipes/mobile/perps/performance.homepage.ios-cold-disk-cache.recipe.json +122 -0
  43. package/library/recipes/mobile/perps/performance.homepage.ios-cold-no-cache.recipe.json +95 -0
  44. package/package.json +1 -1
  45. 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
- const output = await callback(input);
56
- await writeOutput(input, output);
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 (serialStr && deviceStr === serialStr) {
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 ??