@deeeed/metamask-harness 0.33.2 → 0.34.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 +31 -0
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +18 -2
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +83 -23
- package/dist/adapters/mobile/prepare.js +14 -0
- package/dist/adapters/mobile/video-recorder.js +282 -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 +27 -5
- package/dist/commands/launch/mobile.js +9 -2
- package/dist/commands/run-engine.js +34 -2
- package/dist/devices.js +9 -2
- package/dist/heal-bounds.js +10 -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 +383 -0
- package/library/actions/mobile/perps/perps.mjs +16 -0
- package/library/actions/mobile/platform/bridge.mjs +229 -13
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +27 -6
- package/library/actions/mobile/wallet/select_account.mjs +21 -17
- package/library/manifests/mobile.action-manifest.json +130 -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 +147 -0
- package/library/recipes/mobile/perps/performance.homepage.android-cold-no-cache.recipe.json +137 -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/scripts/site-contrast.mjs +34 -1
- package/site/architecture.html +12 -2
- package/site/assets/style.css +20 -1
- package/site/cheatsheet.html +7 -6
- package/site/index.html +119 -77
- package/site/perps.html +195 -0
- package/site/recipes.html +23 -1
- package/site/reviewers.html +2 -1
- package/site/tutorials/index.html +2 -1
- package/site/tutorials/v1.html +3 -2
- package/site/tutorials/v2.html +6 -5
- package/site/tutorials/v3.html +43 -2
- package/site/tutorials/v4.html +2 -1
- package/site/tutorials/v5.html +2 -1
- package/site/tutorials/v6.html +2 -1
- package/site/tutorials/v7.html +2 -1
|
@@ -0,0 +1,383 @@
|
|
|
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 summarize(
|
|
209
|
+
records,
|
|
210
|
+
sourcePath,
|
|
211
|
+
offset,
|
|
212
|
+
bytesScanned,
|
|
213
|
+
requiredFreshContentVariants = [],
|
|
214
|
+
) {
|
|
215
|
+
const stages = {};
|
|
216
|
+
const lifecycles = {};
|
|
217
|
+
const streams = {};
|
|
218
|
+
const sources = {};
|
|
219
|
+
for (const record of records) {
|
|
220
|
+
increment(stages, record.stage);
|
|
221
|
+
increment(lifecycles, record.lifecycle);
|
|
222
|
+
increment(streams, record.stream);
|
|
223
|
+
increment(sources, record.source);
|
|
224
|
+
}
|
|
225
|
+
const monotonicValues = records
|
|
226
|
+
.map((record) => Number(record.monotonic_ms))
|
|
227
|
+
.filter(Number.isFinite);
|
|
228
|
+
return {
|
|
229
|
+
schemaVersion: 1,
|
|
230
|
+
marker: MARKER,
|
|
231
|
+
sourcePath,
|
|
232
|
+
offset,
|
|
233
|
+
bytesScanned,
|
|
234
|
+
recordCount: records.length,
|
|
235
|
+
stages,
|
|
236
|
+
lifecycles,
|
|
237
|
+
streams,
|
|
238
|
+
sources,
|
|
239
|
+
firstMonotonicMs: monotonicValues.length > 0 ? Math.min(...monotonicValues) : null,
|
|
240
|
+
lastMonotonicMs: monotonicValues.length > 0 ? Math.max(...monotonicValues) : null,
|
|
241
|
+
visibleMeasurements: visibleMeasurements(records, requiredFreshContentVariants),
|
|
242
|
+
socketPipelineMeasurements: socketPipelineMeasurements(records),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function captureHomepagePerformance(input) {
|
|
247
|
+
const node = input.node ?? {};
|
|
248
|
+
const phase = String(node.phase ?? '').toLowerCase();
|
|
249
|
+
if (phase !== 'start' && phase !== 'end') {
|
|
250
|
+
throw new Error('metamask.perps.capture_performance requires phase=start or phase=end.');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const projectRoot = input.context?.projectRoot;
|
|
254
|
+
const artifactsDir = input.context?.artifactsDir;
|
|
255
|
+
if (!projectRoot || !artifactsDir) {
|
|
256
|
+
throw new Error('metamask.perps.capture_performance requires projectRoot and artifactsDir.');
|
|
257
|
+
}
|
|
258
|
+
const sourcePath = String(node.source_path ?? 'temp/recipe/runtime/app-console.log');
|
|
259
|
+
const sourceFile = resolveWithin(projectRoot, sourcePath, 'source_path');
|
|
260
|
+
const stateFile = resolveWithin(artifactsDir, STATE_FILE, 'capture state');
|
|
261
|
+
await mkdir(artifactsDir, { recursive: true });
|
|
262
|
+
|
|
263
|
+
if (phase === 'start') {
|
|
264
|
+
const offset = await fileSize(sourceFile);
|
|
265
|
+
await writeFile(stateFile, `${JSON.stringify({ sourcePath, offset }, null, 2)}\n`);
|
|
266
|
+
return { phase, sourcePath, offset };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let state;
|
|
270
|
+
try {
|
|
271
|
+
state = JSON.parse(await readFile(stateFile, 'utf8'));
|
|
272
|
+
} catch (error) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
'metamask.perps.capture_performance phase=end requires a successful phase=start in the same recipe run.',
|
|
275
|
+
{ cause: error },
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (state.sourcePath !== sourcePath || !Number.isInteger(state.offset) || state.offset < 0) {
|
|
279
|
+
throw new Error('metamask.perps.capture_performance capture state is invalid or uses a different source_path.');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const content = await readFile(sourceFile);
|
|
283
|
+
const offset = state.offset <= content.length ? state.offset : 0;
|
|
284
|
+
const segment = content.subarray(offset).toString('utf8');
|
|
285
|
+
const lines = segment.split(/\r?\n/u).filter((line) => line.includes(MARKER));
|
|
286
|
+
const records = lines.map(parseRecord).filter(Boolean);
|
|
287
|
+
const requireRecords = node.require_records !== false;
|
|
288
|
+
const requiredStages = Array.isArray(node.required_stages)
|
|
289
|
+
? node.required_stages.map(String)
|
|
290
|
+
: [];
|
|
291
|
+
const presentStages = new Set(records.map((record) => String(record.stage ?? '')));
|
|
292
|
+
const missingStages = requiredStages.filter((stage) => !presentStages.has(stage));
|
|
293
|
+
const requiredLifecycles = Array.isArray(node.required_lifecycles)
|
|
294
|
+
? node.required_lifecycles.map(String)
|
|
295
|
+
: [];
|
|
296
|
+
const presentLifecycles = new Set(records.map((record) => String(record.lifecycle ?? '')));
|
|
297
|
+
const missingLifecycles = requiredLifecycles.filter(
|
|
298
|
+
(lifecycle) => !presentLifecycles.has(lifecycle),
|
|
299
|
+
);
|
|
300
|
+
const requiredSources = Array.isArray(node.required_sources)
|
|
301
|
+
? node.required_sources.map(String)
|
|
302
|
+
: [];
|
|
303
|
+
const presentSources = new Set(records.map((record) => String(record.source ?? '')));
|
|
304
|
+
const missingSources = requiredSources.filter((source) => !presentSources.has(source));
|
|
305
|
+
const requiredFreshContentVariants = Array.isArray(node.required_fresh_content_variants)
|
|
306
|
+
? node.required_fresh_content_variants.map(String)
|
|
307
|
+
: [];
|
|
308
|
+
const measurements = visibleMeasurements(records, requiredFreshContentVariants);
|
|
309
|
+
const presentFreshContentVariants = new Set(
|
|
310
|
+
measurements
|
|
311
|
+
.filter((measurement) => measurement.dfdMs !== null)
|
|
312
|
+
.map((measurement) => String(measurement.freshContentVariant ?? '')),
|
|
313
|
+
);
|
|
314
|
+
const missingFreshContentVariants = requiredFreshContentVariants.filter(
|
|
315
|
+
(variant) =>
|
|
316
|
+
![...presentFreshContentVariants].some((actual) =>
|
|
317
|
+
contentVariantSatisfies(actual, variant),
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const prefix = String(node.artifact_prefix ?? 'logs/homepage-performance');
|
|
322
|
+
const logPath = `${prefix}.log`;
|
|
323
|
+
const summaryPath = `${prefix}-summary.json`;
|
|
324
|
+
const logFile = resolveWithin(artifactsDir, logPath, 'artifact_prefix');
|
|
325
|
+
const summaryFile = resolveWithin(artifactsDir, summaryPath, 'artifact_prefix');
|
|
326
|
+
const validation = {
|
|
327
|
+
status:
|
|
328
|
+
(!requireRecords || records.length > 0) &&
|
|
329
|
+
missingStages.length === 0 &&
|
|
330
|
+
missingLifecycles.length === 0 &&
|
|
331
|
+
missingSources.length === 0 &&
|
|
332
|
+
missingFreshContentVariants.length === 0
|
|
333
|
+
? 'pass'
|
|
334
|
+
: 'fail',
|
|
335
|
+
requireRecords,
|
|
336
|
+
missingStages,
|
|
337
|
+
missingLifecycles,
|
|
338
|
+
missingSources,
|
|
339
|
+
missingFreshContentVariants,
|
|
340
|
+
};
|
|
341
|
+
const summary = {
|
|
342
|
+
...summarize(
|
|
343
|
+
records,
|
|
344
|
+
sourcePath,
|
|
345
|
+
offset,
|
|
346
|
+
content.length - offset,
|
|
347
|
+
requiredFreshContentVariants,
|
|
348
|
+
),
|
|
349
|
+
validation,
|
|
350
|
+
};
|
|
351
|
+
const nodeId = String(input.context?.nodeId ?? 'capture-performance');
|
|
352
|
+
await mkdir(path.dirname(logFile), { recursive: true });
|
|
353
|
+
await writeFile(logFile, lines.length > 0 ? `${lines.join('\n')}\n` : '');
|
|
354
|
+
await writeFile(summaryFile, `${JSON.stringify(summary, null, 2)}\n`);
|
|
355
|
+
await rm(stateFile, { force: true });
|
|
356
|
+
|
|
357
|
+
if (requireRecords && records.length === 0) {
|
|
358
|
+
throw new Error(`No ${MARKER} records were emitted after the capture started.`);
|
|
359
|
+
}
|
|
360
|
+
if (missingStages.length > 0) {
|
|
361
|
+
throw new Error(`Missing required ${MARKER} stages: ${missingStages.join(', ')}.`);
|
|
362
|
+
}
|
|
363
|
+
if (missingLifecycles.length > 0) {
|
|
364
|
+
throw new Error(`Missing required ${MARKER} lifecycles: ${missingLifecycles.join(', ')}.`);
|
|
365
|
+
}
|
|
366
|
+
if (missingSources.length > 0) {
|
|
367
|
+
throw new Error(`Missing required ${MARKER} sources: ${missingSources.join(', ')}.`);
|
|
368
|
+
}
|
|
369
|
+
if (missingFreshContentVariants.length > 0) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`Missing required fresh ${MARKER} content variants: ${missingFreshContentVariants.join(', ')}.`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
phase,
|
|
377
|
+
...summary,
|
|
378
|
+
artifacts: [
|
|
379
|
+
{ path: logPath, type: 'log', nodeId },
|
|
380
|
+
{ path: summaryPath, type: 'report', nodeId },
|
|
381
|
+
],
|
|
382
|
+
};
|
|
383
|
+
}
|
|
@@ -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
|
]);
|
|
@@ -442,34 +442,188 @@ export async function evalSync(input, expression) {
|
|
|
442
442
|
return parseMaybeJson(await bridgeCommand(input, ['eval', expression]));
|
|
443
443
|
}
|
|
444
444
|
|
|
445
|
+
const TARGET_TRANSITION_CODES = new Set([
|
|
446
|
+
BRIDGE_ERROR_CODES.NO_TARGET,
|
|
447
|
+
BRIDGE_ERROR_CODES.CDP_TIMEOUT,
|
|
448
|
+
BRIDGE_ERROR_CODES.WS_CLOSED,
|
|
449
|
+
]);
|
|
450
|
+
|
|
451
|
+
function isTargetTransition(error) {
|
|
452
|
+
return TARGET_TRANSITION_CODES.has(error?.code);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function valueContains(actual, expected) {
|
|
456
|
+
if (Array.isArray(expected)) {
|
|
457
|
+
return Array.isArray(actual) &&
|
|
458
|
+
actual.length === expected.length &&
|
|
459
|
+
expected.every((value, index) => valueContains(actual[index], value));
|
|
460
|
+
}
|
|
461
|
+
if (expected && typeof expected === 'object') {
|
|
462
|
+
return actual && typeof actual === 'object' &&
|
|
463
|
+
Object.entries(expected).every(([key, value]) => valueContains(actual[key], value));
|
|
464
|
+
}
|
|
465
|
+
return Object.is(actual, expected);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function routeMatches(route, expectedRoute, expectedParams) {
|
|
469
|
+
return routeName(route) === expectedRoute &&
|
|
470
|
+
(expectedParams === undefined || valueContains(route?.params ?? {}, expectedParams));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function routeTransitionProven(previousRoute, currentRoute, expectedRoute, expectedParams) {
|
|
474
|
+
if (!previousRoute || !routeMatches(currentRoute, expectedRoute, expectedParams)) return false;
|
|
475
|
+
if (!routeMatches(previousRoute, expectedRoute, expectedParams)) return true;
|
|
476
|
+
return Boolean(
|
|
477
|
+
previousRoute.key &&
|
|
478
|
+
currentRoute?.key &&
|
|
479
|
+
previousRoute.key !== currentRoute.key,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function waitForNextRouteProbe(deadline) {
|
|
484
|
+
const remaining = deadline - Date.now();
|
|
485
|
+
if (remaining > 0) await sleep(Math.min(250, remaining));
|
|
486
|
+
}
|
|
487
|
+
|
|
445
488
|
export async function navigate(input, route, params = {}, expectedRoute) {
|
|
446
|
-
const
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
489
|
+
const timeoutMs = Number(input.node?.navigation_timeout_ms ?? 15000);
|
|
490
|
+
const requestedRoute = String(expectedRoute ?? route);
|
|
491
|
+
const requestedParams = requestedRoute === String(route) ? params : undefined;
|
|
492
|
+
const transitionCodes = [];
|
|
493
|
+
let lastTransitionError = null;
|
|
494
|
+
let navigateAttempts = 0;
|
|
495
|
+
let previousRoute = null;
|
|
496
|
+
let recoveryDeadline = null;
|
|
497
|
+
let recoveryPending = false;
|
|
498
|
+
|
|
499
|
+
try {
|
|
500
|
+
previousRoute = await bridgeCommand(input, ['get-route']);
|
|
501
|
+
} catch (error) {
|
|
502
|
+
if (!isTargetTransition(error)) throw error;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
while (navigateAttempts < 2 || recoveryPending) {
|
|
506
|
+
if (recoveryPending) {
|
|
507
|
+
const deadline = recoveryDeadline ?? Date.now() + timeoutMs;
|
|
508
|
+
let currentRoute = null;
|
|
509
|
+
while (Date.now() < deadline) {
|
|
510
|
+
try {
|
|
511
|
+
currentRoute = await bridgeCommand(input, ['get-route']);
|
|
512
|
+
if (currentRoute !== null) break;
|
|
513
|
+
} catch (error) {
|
|
514
|
+
if (!isTargetTransition(error)) throw error;
|
|
515
|
+
lastTransitionError = error;
|
|
516
|
+
transitionCodes.push(error.code);
|
|
517
|
+
}
|
|
518
|
+
await waitForNextRouteProbe(deadline);
|
|
519
|
+
}
|
|
520
|
+
if (routeTransitionProven(
|
|
521
|
+
previousRoute,
|
|
522
|
+
currentRoute,
|
|
523
|
+
requestedRoute,
|
|
524
|
+
requestedParams,
|
|
525
|
+
)) {
|
|
526
|
+
return {
|
|
527
|
+
navigated: route,
|
|
528
|
+
params,
|
|
529
|
+
previousRoute,
|
|
530
|
+
currentRoute,
|
|
531
|
+
deviceName: null,
|
|
532
|
+
platform: null,
|
|
533
|
+
verifiedRoute: requestedRoute,
|
|
534
|
+
bridgeRecovery: {
|
|
535
|
+
codes: [...new Set(transitionCodes)],
|
|
536
|
+
navigateAttempts,
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
if (!previousRoute && routeMatches(currentRoute, requestedRoute, requestedParams)) {
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
recoveryPending = false;
|
|
544
|
+
if (Date.now() >= deadline || navigateAttempts >= 2) break;
|
|
545
|
+
previousRoute = currentRoute;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
navigateAttempts += 1;
|
|
549
|
+
try {
|
|
550
|
+
const navigation = await bridgeCommand(
|
|
551
|
+
input,
|
|
552
|
+
['navigate', route, JSON.stringify(params)],
|
|
553
|
+
);
|
|
554
|
+
const verifiedRoute = String(
|
|
555
|
+
expectedRoute ??
|
|
556
|
+
(navigation && typeof navigation === 'object' && navigation.navigated
|
|
557
|
+
? navigation.navigated
|
|
558
|
+
: route),
|
|
559
|
+
);
|
|
560
|
+
const verifiedParams = verifiedRoute === String(route) ? params : undefined;
|
|
561
|
+
const currentRoute = await waitForRoute(
|
|
562
|
+
input,
|
|
563
|
+
verifiedRoute,
|
|
564
|
+
timeoutMs,
|
|
565
|
+
verifiedParams,
|
|
566
|
+
);
|
|
567
|
+
return {
|
|
568
|
+
...navigation,
|
|
569
|
+
currentRoute,
|
|
570
|
+
verifiedRoute,
|
|
571
|
+
...(transitionCodes.length > 0
|
|
572
|
+
? {
|
|
573
|
+
bridgeRecovery: {
|
|
574
|
+
codes: [...new Set(transitionCodes)],
|
|
575
|
+
navigateAttempts,
|
|
576
|
+
},
|
|
577
|
+
}
|
|
578
|
+
: {}),
|
|
579
|
+
};
|
|
580
|
+
} catch (error) {
|
|
581
|
+
if (!isTargetTransition(error)) throw error;
|
|
582
|
+
lastTransitionError = error;
|
|
583
|
+
transitionCodes.push(error.code);
|
|
584
|
+
recoveryDeadline = Date.now() + timeoutMs;
|
|
585
|
+
recoveryPending = true;
|
|
586
|
+
await waitForNextRouteProbe(recoveryDeadline);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
throw lastTransitionError ?? new Error(
|
|
591
|
+
`Timed out navigating Mobile to route '${requestedRoute}' after ${timeoutMs}ms.`,
|
|
452
592
|
);
|
|
453
|
-
const currentRoute = await waitForRoute(input, verifiedRoute, Number(input.node?.navigation_timeout_ms ?? 15000));
|
|
454
|
-
return { ...navigation, currentRoute, verifiedRoute };
|
|
455
593
|
}
|
|
456
594
|
|
|
457
595
|
function routeName(route) {
|
|
458
596
|
return route && typeof route === 'object' ? String(route.name ?? '') : '';
|
|
459
597
|
}
|
|
460
598
|
|
|
461
|
-
export async function waitForRoute(
|
|
599
|
+
export async function waitForRoute(
|
|
600
|
+
input,
|
|
601
|
+
expectedRoute,
|
|
602
|
+
timeoutMs = 15000,
|
|
603
|
+
expectedParams,
|
|
604
|
+
) {
|
|
462
605
|
const expected = String(expectedRoute);
|
|
463
606
|
const deadline = Date.now() + timeoutMs;
|
|
464
607
|
// lastRoute is null when bridgeCommand returns null (transient: route not yet
|
|
465
608
|
// settled mid-navigation). Null means "not ready yet" — keep polling.
|
|
466
609
|
let lastRoute = null;
|
|
610
|
+
let lastTransitionError = null;
|
|
467
611
|
let pollCount = 0;
|
|
612
|
+
const transitionCodes = [];
|
|
468
613
|
while (Date.now() < deadline) {
|
|
469
|
-
|
|
470
|
-
|
|
614
|
+
try {
|
|
615
|
+
lastRoute = await bridgeCommand(input, ['get-route']);
|
|
616
|
+
} catch (error) {
|
|
617
|
+
if (!isTargetTransition(error)) throw error;
|
|
618
|
+
lastTransitionError = error;
|
|
619
|
+
transitionCodes.push(error.code);
|
|
620
|
+
pollCount += 1;
|
|
621
|
+
await waitForNextRouteProbe(deadline);
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (routeMatches(lastRoute, expected, expectedParams)) return lastRoute;
|
|
471
625
|
pollCount += 1;
|
|
472
|
-
await
|
|
626
|
+
await waitForNextRouteProbe(deadline);
|
|
473
627
|
}
|
|
474
628
|
const target = resolveMobileTarget(input);
|
|
475
629
|
const deviceHint = [
|
|
@@ -483,13 +637,18 @@ export async function waitForRoute(input, expectedRoute, timeoutMs = 15000) {
|
|
|
483
637
|
const lastReply = lastRoute === null
|
|
484
638
|
? 'empty/undefined (route transiently unavailable — bridge not yet settled)'
|
|
485
639
|
: JSON.stringify(lastRoute);
|
|
486
|
-
|
|
640
|
+
const timeoutError = new Error(
|
|
487
641
|
`Timed out waiting for Mobile route '${expected}' after ${timeoutMs}ms (${pollCount} polls).\n` +
|
|
488
642
|
` Expected route: ${expected}\n` +
|
|
643
|
+
` Expected params: ${expectedParams === undefined ? 'any' : JSON.stringify(expectedParams)}\n` +
|
|
489
644
|
` Last parsed route: ${JSON.stringify(lastRoute)}\n` +
|
|
490
645
|
` Last bridge reply: ${lastReply}\n` +
|
|
646
|
+
` Bridge transitions: ${transitionCodes.length > 0 ? [...new Set(transitionCodes)].join(', ') : 'none'}\n` +
|
|
491
647
|
` Device: ${deviceHint}`,
|
|
492
648
|
);
|
|
649
|
+
throw lastTransitionError
|
|
650
|
+
? coded(timeoutError, lastTransitionError.code)
|
|
651
|
+
: timeoutError;
|
|
493
652
|
}
|
|
494
653
|
|
|
495
654
|
export async function simulatorScreenshot(input, relPath) {
|
|
@@ -571,6 +730,7 @@ async function androidScreenshot(input, relPath) {
|
|
|
571
730
|
temporaryDirectory = await createScreenshotTemporaryDirectory();
|
|
572
731
|
const adbSerial = await resolveAndroidScreenshotSerial(input);
|
|
573
732
|
const adbPath = resolveMobileToolPath('adb', { required: true });
|
|
733
|
+
await assertAndroidScreenshotIsAllowed(adbPath, adbSerial);
|
|
574
734
|
let result;
|
|
575
735
|
try {
|
|
576
736
|
result = await runScreenshotProcess(adbPath, ['-s', adbSerial, 'exec-out', 'screencap', '-p'], true);
|
|
@@ -618,6 +778,62 @@ async function androidScreenshot(input, relPath) {
|
|
|
618
778
|
}
|
|
619
779
|
}
|
|
620
780
|
|
|
781
|
+
async function assertAndroidScreenshotIsAllowed(adbPath, adbSerial) {
|
|
782
|
+
let result;
|
|
783
|
+
try {
|
|
784
|
+
result = await runScreenshotProcess(
|
|
785
|
+
adbPath,
|
|
786
|
+
['-s', adbSerial, 'shell', 'dumpsys', 'window'],
|
|
787
|
+
false,
|
|
788
|
+
);
|
|
789
|
+
} catch (error) {
|
|
790
|
+
throw new Error(
|
|
791
|
+
`adb screenshot could not inspect the focused window for ${adbSerial}: ${error instanceof Error ? error.message : String(error)}\n` +
|
|
792
|
+
` Next: mm-harness doctor --adapter mobile --device ${adbSerial} --json`,
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
if (result.exitCode !== 0) {
|
|
796
|
+
throw new Error(
|
|
797
|
+
`adb screenshot could not inspect the focused window for ${adbSerial}: ${result.stderr || result.stdout}\n` +
|
|
798
|
+
` Next: mm-harness doctor --adapter mobile --device ${adbSerial} --json`,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const focusMatch = result.stdout.match(/mCurrentFocus=Window\{([^\s}]+)/u);
|
|
803
|
+
if (!focusMatch) {
|
|
804
|
+
throw new Error(
|
|
805
|
+
`adb screenshot could not identify the focused Android window for ${adbSerial}.\n` +
|
|
806
|
+
' Next: wake and unlock the selected device, wait for the target screen to settle, then rerun the same mm-harness command.',
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const focusedWindowId = focusMatch[1];
|
|
811
|
+
const windowStart = result.stdout.search(
|
|
812
|
+
new RegExp(`^\\s*Window #\\d+ Window\\{${escapeRegExp(focusedWindowId)}(?:\\s|\\})`, 'mu'),
|
|
813
|
+
);
|
|
814
|
+
const nextWindow = windowStart < 0
|
|
815
|
+
? -1
|
|
816
|
+
: result.stdout.slice(windowStart).search(/\n\s*Window #\d+ Window\{/u);
|
|
817
|
+
const focusedWindow = windowStart < 0
|
|
818
|
+
? ''
|
|
819
|
+
: result.stdout.slice(
|
|
820
|
+
windowStart,
|
|
821
|
+
nextWindow < 0 ? undefined : windowStart + nextWindow,
|
|
822
|
+
);
|
|
823
|
+
if (/\b(?:FLAG_)?SECURE\b/u.test(focusedWindow)) {
|
|
824
|
+
const error = new Error(
|
|
825
|
+
`Android screenshot is protected by FLAG_SECURE on the focused window for ${adbSerial}; the resulting black image is not valid visual evidence.\n` +
|
|
826
|
+
' Next: use a non-protected screen (for MetaMask Mobile, unlock the wallet first), then rerun the same mm-harness command.',
|
|
827
|
+
);
|
|
828
|
+
error.code = 'SCREENSHOT_PROTECTED';
|
|
829
|
+
throw error;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function escapeRegExp(value) {
|
|
834
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
835
|
+
}
|
|
836
|
+
|
|
621
837
|
async function resolveAndroidScreenshotSerial(input) {
|
|
622
838
|
const contextEnv = input.context?.env || {};
|
|
623
839
|
const explicit = input.node?.adb_serial ??
|