@deeeed/metamask-harness 0.40.0 → 0.41.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/adapters/manifest.json +8 -0
  3. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +5 -4
  4. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +24 -5
  5. package/adapters/mobile/metro-config.cjs +94 -0
  6. package/adapters/mobile/start-metro.sh +13 -1
  7. package/dist/commands/device-target.js +3 -0
  8. package/dist/commands/run-engine.js +4 -14
  9. package/dist/funding-execution-context.js +35 -28
  10. package/dist/heal-bounds.js +1 -0
  11. package/dist/live-adapter-contract.js +2 -1
  12. package/dist/mm-harness-cli.js +1 -1
  13. package/docs/RECIPES.md +25 -19
  14. package/library/actions/extension/perps/mutation-receipt.mjs +12 -8
  15. package/library/actions/extension/perps/select_activity_filter.mjs +1 -1
  16. package/library/actions/extension/perps/select_market_filter.mjs +1 -1
  17. package/library/actions/mobile/perps/capture_performance.mjs +2 -2
  18. package/library/actions/mobile/perps/performance-capture.mjs +375 -17
  19. package/library/actions/mobile/perps/perps.mjs +56 -10
  20. package/library/actions/mobile/platform/bridge.mjs +59 -1
  21. package/library/actions/mobile/wallet/ensure_unlocked.mjs +60 -26
  22. package/library/actions/mobile/wallet/import.mjs +2 -0
  23. package/library/actions/mobile/wallet/select_account.mjs +21 -2
  24. package/library/manifests/mobile.action-manifest.json +47 -6
  25. package/library/recipes/mobile/perps/performance.recipe.json +680 -26
  26. package/package.json +1 -1
  27. package/library/recipes/mobile/perps/performance.background-resume.recipe.json +0 -56
  28. package/library/recipes/mobile/perps/performance.cold-start.recipe.json +0 -56
  29. package/library/recipes/mobile/perps/performance.homepage.android-background-reconnect.recipe.json +0 -159
  30. package/library/recipes/mobile/perps/performance.homepage.android-background-short.recipe.json +0 -157
  31. package/library/recipes/mobile/perps/performance.homepage.android-cold-disk-cache.recipe.json +0 -165
  32. package/library/recipes/mobile/perps/performance.homepage.android-cold-no-cache.recipe.json +0 -139
  33. package/library/recipes/mobile/perps/performance.homepage.android-network-recovery.recipe.json +0 -149
  34. package/library/recipes/mobile/perps/performance.homepage.cold-position-sample.recipe.json +0 -120
  35. package/library/recipes/mobile/perps/performance.homepage.ios-background-reconnect.recipe.json +0 -111
  36. package/library/recipes/mobile/perps/performance.homepage.ios-background-short.recipe.json +0 -108
  37. package/library/recipes/mobile/perps/performance.homepage.ios-cold-disk-cache.recipe.json +0 -122
  38. package/library/recipes/mobile/perps/performance.homepage.ios-cold-no-cache.recipe.json +0 -95
  39. package/library/recipes/mobile/perps/performance.warm-start.recipe.json +0 -49
@@ -1,8 +1,11 @@
1
1
  import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- const MARKER = "[HomepagePerf]";
4
+ const MARKER = "[PerpsPerf]";
5
+ const LEGACY_MARKER = "[HomepagePerf]";
6
+ const DETAILED_MARKERS = [MARKER, LEGACY_MARKER];
5
7
  const STARTUP_MARKER = "[StartupPerf]";
8
+ const LOAD_PROOF_MARKER = "[PerpsLoadProof]";
6
9
  const STATE_FILE = ".homepage-performance-capture.json";
7
10
  const FRESH_SOURCES = new Set([
8
11
  "fresh_socket",
@@ -44,6 +47,201 @@ function parseRecord(line, marker) {
44
47
  }
45
48
  }
46
49
 
50
+ function detailedMarker(line) {
51
+ return DETAILED_MARKERS.find((marker) => line.includes(marker)) ?? null;
52
+ }
53
+
54
+ function parseDetailedRecord(line) {
55
+ const marker = detailedMarker(line);
56
+ return marker ? parseRecord(line, marker) : null;
57
+ }
58
+
59
+ function latestPerpsBootstrapStartRecord(content) {
60
+ return content
61
+ .split(/\r?\n/u)
62
+ .filter((line) => line.includes(LOAD_PROOF_MARKER))
63
+ .map((line) => parseRecord(line, LOAD_PROOF_MARKER))
64
+ .filter((record) => record?.stage === "perps_bootstrap_start")
65
+ .at(-1) ?? null;
66
+ }
67
+
68
+ function consoleClockMs(line) {
69
+ const match = line.match(/\b(\d{2}):(\d{2}):(\d{2})\.(\d{3})\b/u);
70
+ if (!match) return null;
71
+ return (
72
+ Number(match[1]) * 3_600_000 +
73
+ Number(match[2]) * 60_000 +
74
+ Number(match[3]) * 1_000 +
75
+ Number(match[4])
76
+ );
77
+ }
78
+
79
+ function existingLiveStreamRecords(segmentLines, markerRecords) {
80
+ let bootstrapLineIndex = -1;
81
+ segmentLines.forEach((line, index) => {
82
+ if (
83
+ line.includes(LOAD_PROOF_MARKER) &&
84
+ line.includes('"stage":"perps_bootstrap_start"')
85
+ ) {
86
+ bootstrapLineIndex = index;
87
+ }
88
+ });
89
+ const bootstrapLine =
90
+ bootstrapLineIndex >= 0 ? segmentLines[bootstrapLineIndex] : undefined;
91
+ const liveLines =
92
+ bootstrapLineIndex >= 0
93
+ ? segmentLines.slice(bootstrapLineIndex + 1)
94
+ : segmentLines;
95
+ const bootstrapClock = bootstrapLine ? consoleClockMs(bootstrapLine) : null;
96
+ const bootstrapRecord = markerRecords
97
+ .filter((record) => record.stage === "perps_bootstrap_start")
98
+ .at(-1);
99
+ const patterns = new Map([
100
+ ["PerpsWS: First price data received", "prices"],
101
+ ["PerpsWS: First position data received", "positions"],
102
+ ["PerpsWS: First order data received", "orders"],
103
+ ["PerpsWS: First account data received", "account"],
104
+ ]);
105
+ const records = [];
106
+ for (const [message, stream] of patterns) {
107
+ const line = liveLines.find((candidate) => candidate.includes(message));
108
+ if (!line) continue;
109
+ const eventClock = consoleClockMs(line);
110
+ let elapsedMs = null;
111
+ if (bootstrapClock !== null && eventClock !== null) {
112
+ elapsedMs = eventClock - bootstrapClock;
113
+ if (elapsedMs < 0) elapsedMs += 24 * 3_600_000;
114
+ }
115
+ records.push({
116
+ stage: "values_ready",
117
+ stream,
118
+ source: "fresh_socket",
119
+ item_count: null,
120
+ elapsed_ms: elapsedMs,
121
+ monotonic_ms:
122
+ elapsedMs !== null &&
123
+ Number.isFinite(Number(bootstrapRecord?.monotonic_ms))
124
+ ? Number(bootstrapRecord.monotonic_ms) + elapsedMs
125
+ : null,
126
+ evidence: "existing_sentry_ws_marker",
127
+ });
128
+ }
129
+ return records;
130
+ }
131
+
132
+ function mergeLoadProofRecords(records) {
133
+ const byStageStreamSource = new Map();
134
+ for (const record of records) {
135
+ const key = `${record.stage ?? "unknown"}:${record.stream ?? ""}:${record.source ?? ""}`;
136
+ const current = byStageStreamSource.get(key);
137
+ if (record.stage === "perps_bootstrap_start") {
138
+ if (
139
+ !current ||
140
+ Number(record.monotonic_ms) > Number(current.monotonic_ms)
141
+ ) {
142
+ byStageStreamSource.set(key, record);
143
+ }
144
+ continue;
145
+ }
146
+ const recordIsFallback =
147
+ record.evidence === "existing_sentry_ws_marker";
148
+ const currentIsFallback =
149
+ current?.evidence === "existing_sentry_ws_marker";
150
+ const recordElapsedMs = Number(record.elapsed_ms);
151
+ const currentElapsedMs = Number(current?.elapsed_ms);
152
+ const sameEvidenceClassEarlier =
153
+ recordIsFallback === currentIsFallback &&
154
+ record.elapsed_ms !== null &&
155
+ record.elapsed_ms !== undefined &&
156
+ Number.isFinite(recordElapsedMs) &&
157
+ current?.elapsed_ms !== null &&
158
+ current?.elapsed_ms !== undefined &&
159
+ Number.isFinite(currentElapsedMs) &&
160
+ recordElapsedMs < currentElapsedMs;
161
+ if (
162
+ !current ||
163
+ (currentIsFallback && !recordIsFallback) ||
164
+ sameEvidenceClassEarlier
165
+ ) {
166
+ byStageStreamSource.set(key, record);
167
+ }
168
+ }
169
+ return [...byStageStreamSource.values()];
170
+ }
171
+
172
+ async function waitForPerformanceRecord({ node, sourceFile, offset }) {
173
+ const stage = node.wait_for_stage;
174
+ const requiredLiveStreams = Array.isArray(node.required_live_streams)
175
+ ? node.required_live_streams.map(String)
176
+ : [];
177
+ if (stage === undefined && requiredLiveStreams.length === 0) return;
178
+
179
+ const expected =
180
+ stage === undefined
181
+ ? null
182
+ : {
183
+ stage: String(stage),
184
+ ...(node.wait_for_source !== undefined
185
+ ? { source: String(node.wait_for_source) }
186
+ : {}),
187
+ ...(node.wait_for_lifecycle !== undefined
188
+ ? { lifecycle: String(node.wait_for_lifecycle) }
189
+ : {}),
190
+ };
191
+ const timeoutMs = Math.max(1, Number(node.wait_timeout_ms ?? 60_000));
192
+ const pollIntervalMs = Math.max(
193
+ 10,
194
+ Number(node.wait_poll_interval_ms ?? 100),
195
+ );
196
+ const deadline = Date.now() + timeoutMs;
197
+
198
+ while (true) {
199
+ const content = await readFile(sourceFile);
200
+ const start = offset <= content.length ? offset : 0;
201
+ const segmentLines = content
202
+ .subarray(start)
203
+ .toString("utf8")
204
+ .split(/\r?\n/u);
205
+ const performanceRecords = segmentLines
206
+ .filter(detailedMarker)
207
+ .map(parseDetailedRecord)
208
+ .filter(Boolean);
209
+ const stageMatched =
210
+ expected === null ||
211
+ performanceRecords.some((record) =>
212
+ Object.entries(expected).every(
213
+ ([key, value]) => String(record[key] ?? "") === value,
214
+ ),
215
+ );
216
+ const markerLoadProofRecords = segmentLines
217
+ .filter((line) => line.includes(LOAD_PROOF_MARKER))
218
+ .map((line) => parseRecord(line, LOAD_PROOF_MARKER))
219
+ .filter(Boolean);
220
+ const liveStreams = new Set(
221
+ mergeLoadProofRecords([
222
+ ...markerLoadProofRecords,
223
+ ...existingLiveStreamRecords(segmentLines, markerLoadProofRecords),
224
+ ])
225
+ .filter(
226
+ (record) =>
227
+ record.stage === "values_ready" &&
228
+ record.source === "fresh_socket",
229
+ )
230
+ .map((record) => String(record.stream ?? "")),
231
+ );
232
+ const liveStreamsMatched = requiredLiveStreams.every((stream) =>
233
+ liveStreams.has(stream),
234
+ );
235
+ if (stageMatched && liveStreamsMatched) return;
236
+ if (Date.now() >= deadline) {
237
+ throw new Error(
238
+ `Timed out waiting ${timeoutMs}ms for ${MARKER} stage ${JSON.stringify(expected)} and live streams ${JSON.stringify(requiredLiveStreams)}.`,
239
+ );
240
+ }
241
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
242
+ }
243
+ }
244
+
47
245
  function startupMeasurements(records) {
48
246
  return records.map((record) => {
49
247
  const processToStageMs = Number(
@@ -72,6 +270,60 @@ function startupMeasurements(records) {
72
270
  });
73
271
  }
74
272
 
273
+ function perpsBootstrapBoundary(homepageRecords, loadProofRecords) {
274
+ const demandTimes = homepageRecords
275
+ .filter((record) => record.stage === "surface_demand")
276
+ .map((record) => Number(record.monotonic_ms))
277
+ .filter(Number.isFinite);
278
+ const firstDemandAt = demandTimes.length > 0 ? Math.min(...demandTimes) : null;
279
+ const bootstrapTimes = loadProofRecords
280
+ .filter((record) => record.stage === "perps_bootstrap_start")
281
+ .map((record) => Number(record.monotonic_ms))
282
+ .filter(
283
+ (value) =>
284
+ Number.isFinite(value) &&
285
+ (firstDemandAt === null || value <= firstDemandAt),
286
+ );
287
+ const bootstrapAt =
288
+ bootstrapTimes.length > 0 ? Math.max(...bootstrapTimes) : null;
289
+ const failureReason =
290
+ firstDemandAt === null
291
+ ? "missing_surface_demand"
292
+ : bootstrapAt === null
293
+ ? "missing_prior_perps_bootstrap_start"
294
+ : null;
295
+ return {
296
+ status: failureReason === null ? "pass" : "fail",
297
+ perpsBootstrapStartAtMonotonicMs: bootstrapAt,
298
+ firstDemandAtMonotonicMs: firstDemandAt,
299
+ demandAfterPerpsBootstrapStartMs:
300
+ bootstrapAt !== null && firstDemandAt !== null
301
+ ? roundMs(firstDemandAt - bootstrapAt)
302
+ : null,
303
+ failureReason,
304
+ };
305
+ }
306
+
307
+ function loadProofMeasurements(records) {
308
+ return records
309
+ .filter((record) => record.stage === "values_ready")
310
+ .map((record) => {
311
+ const elapsedMs = finiteNumberOrNull(record.elapsed_ms);
312
+ return {
313
+ stream: String(record.stream ?? "unknown"),
314
+ source: String(record.source ?? "unknown"),
315
+ evidence: String(record.evidence ?? LOAD_PROOF_MARKER),
316
+ itemCount: finiteNumberOrNull(record.item_count),
317
+ elapsedAfterPerpsBootstrapStartMs:
318
+ elapsedMs === null ? null : roundMs(elapsedMs),
319
+ mainMarketCount: finiteNumberOrNull(record.main_market_count),
320
+ hip3MarketCount: finiteNumberOrNull(record.hip3_market_count),
321
+ pricedMarketCount: finiteNumberOrNull(record.priced_market_count),
322
+ trendMarketCount: finiteNumberOrNull(record.trend_market_count),
323
+ };
324
+ });
325
+ }
326
+
75
327
  function increment(counts, value) {
76
328
  const key = value == null || value === "" ? "unknown" : String(value);
77
329
  counts[key] = (counts[key] ?? 0) + 1;
@@ -81,6 +333,12 @@ function roundMs(value) {
81
333
  return Number(value.toFixed(3));
82
334
  }
83
335
 
336
+ function finiteNumberOrNull(value) {
337
+ if (value === null || value === undefined) return null;
338
+ const number = Number(value);
339
+ return Number.isFinite(number) ? number : null;
340
+ }
341
+
84
342
  function contentVariantSatisfies(actual, required) {
85
343
  if (actual === required) return true;
86
344
  return (
@@ -139,7 +397,7 @@ function frameIsCached(record) {
139
397
  function visibleFrameGroups(records) {
140
398
  const demandLifecycles = new Map(
141
399
  records
142
- .filter((record) => record.stage === "viewport_demand")
400
+ .filter((record) => record.stage === "surface_demand")
143
401
  .map((record) => [
144
402
  String(record.demand_id ?? ""),
145
403
  record.lifecycle ?? "unknown",
@@ -215,7 +473,7 @@ function cacheTakeoverMeasurement(records, requiredContentVariants = []) {
215
473
  const failureReason = !cached
216
474
  ? "missing_cached_visible_frame"
217
475
  : !fresh
218
- ? "missing_later_fresh_visible_frame"
476
+ ? "missing_later_surface_live_frame"
219
477
  : cacheHydratedAt === null
220
478
  ? "missing_disk_cache_hydrated"
221
479
  : null;
@@ -248,7 +506,7 @@ function cacheTakeoverMeasurement(records, requiredContentVariants = []) {
248
506
 
249
507
  function visibleMeasurements(records, requiredFreshContentVariants = []) {
250
508
  const demands = records.filter(
251
- (record) => record.stage === "viewport_demand",
509
+ (record) => record.stage === "surface_demand",
252
510
  );
253
511
  return demands.map((demand) => {
254
512
  const demandId = String(demand.demand_id ?? "");
@@ -265,13 +523,16 @@ function visibleMeasurements(records, requiredFreshContentVariants = []) {
265
523
  (record) =>
266
524
  record.stage === "next_frame_checkpoint" && belongsToDemand(record),
267
525
  );
526
+ const commits = records.filter(
527
+ (record) => record.stage === "react_commit" && belongsToDemand(record),
528
+ );
268
529
  const firstVisibleBoundary = records.find(
269
530
  (record) =>
270
- record.stage === "first_visible_recorded" && belongsToDemand(record),
531
+ record.stage === "surface_resolved_recorded" && belongsToDemand(record),
271
532
  );
272
533
  const freshVisibleBoundary = records.find(
273
534
  (record) =>
274
- record.stage === "fresh_visible_recorded" && belongsToDemand(record),
535
+ record.stage === "surface_live_recorded" && belongsToDemand(record),
275
536
  );
276
537
  const frameTimes = frames
277
538
  .map((record) =>
@@ -352,9 +613,12 @@ function visibleMeasurements(records, requiredFreshContentVariants = []) {
352
613
  : null,
353
614
  freshVisibleAtMonotonicMs: freshVisibleAt,
354
615
  freshContentVariant,
355
- dataReadyAtDemand: sortedFrames.some(
356
- (frame) => frame.data_ready_at_demand === true && frameIsFresh(frame),
357
- ),
616
+ dataReadyAtDemand:
617
+ firstVisibleBoundary?.data_ready_at_demand === true ||
618
+ commits.some(
619
+ (commit) =>
620
+ commit.data_ready_at_demand === true && frameIsFresh(commit),
621
+ ),
358
622
  dfdMs: Number.isFinite(Number(freshVisibleBoundary?.duration_ms))
359
623
  ? roundMs(Number(freshVisibleBoundary.duration_ms))
360
624
  : Number.isFinite(startedAt) && freshVisibleAt != null
@@ -527,6 +791,7 @@ function firstFreshDeliveryChecks(records, requirements) {
527
791
  function summarize(
528
792
  records,
529
793
  startupRecords,
794
+ loadProofRecords,
530
795
  sourcePath,
531
796
  offset,
532
797
  bytesScanned,
@@ -554,6 +819,10 @@ function summarize(
554
819
  for (const record of startupRecords) {
555
820
  increment(startupStages, record.stage);
556
821
  }
822
+ const loadProofStages = {};
823
+ for (const record of loadProofRecords) {
824
+ increment(loadProofStages, record.stage);
825
+ }
557
826
  return {
558
827
  schemaVersion: 1,
559
828
  marker: MARKER,
@@ -565,6 +834,11 @@ function summarize(
565
834
  startupRecordCount: startupRecords.length,
566
835
  startupStages,
567
836
  startupMeasurements: startupMeasurements(startupRecords),
837
+ loadProofMarker: LOAD_PROOF_MARKER,
838
+ loadProofRecordCount: loadProofRecords.length,
839
+ loadProofStages,
840
+ loadProofMeasurements: loadProofMeasurements(loadProofRecords),
841
+ perpsBootstrapBoundary: perpsBootstrapBoundary(records, loadProofRecords),
568
842
  stages,
569
843
  lifecycles,
570
844
  streams,
@@ -587,7 +861,7 @@ function summarize(
587
861
  };
588
862
  }
589
863
 
590
- export async function captureHomepagePerformance(input) {
864
+ export async function capturePerpsPerformance(input) {
591
865
  const node = input.node ?? {};
592
866
  const phase = String(node.phase ?? "").toLowerCase();
593
867
  if (phase !== "start" && phase !== "end") {
@@ -612,9 +886,21 @@ export async function captureHomepagePerformance(input) {
612
886
 
613
887
  if (phase === "start") {
614
888
  const offset = await fileSize(sourceFile);
889
+ const existingContent =
890
+ offset > 0 ? (await readFile(sourceFile)).toString("utf8") : "";
615
891
  await writeFile(
616
892
  stateFile,
617
- `${JSON.stringify({ sourcePath, offset }, null, 2)}\n`,
893
+ `${JSON.stringify(
894
+ {
895
+ sourcePath,
896
+ offset,
897
+ captureStartedAtEpochMs: Date.now(),
898
+ perpsBootstrapStartRecord:
899
+ latestPerpsBootstrapStartRecord(existingContent),
900
+ },
901
+ null,
902
+ 2,
903
+ )}\n`,
618
904
  );
619
905
  return { phase, sourcePath, offset };
620
906
  }
@@ -631,27 +917,52 @@ export async function captureHomepagePerformance(input) {
631
917
  if (
632
918
  state.sourcePath !== sourcePath ||
633
919
  !Number.isInteger(state.offset) ||
634
- state.offset < 0
920
+ state.offset < 0 ||
921
+ !Number.isFinite(state.captureStartedAtEpochMs)
635
922
  ) {
636
923
  throw new Error(
637
924
  "metamask.perps.capture_performance capture state is invalid or uses a different source_path.",
638
925
  );
639
926
  }
640
927
 
928
+ await waitForPerformanceRecord({
929
+ node,
930
+ sourceFile,
931
+ offset: state.offset,
932
+ });
933
+
641
934
  const content = await readFile(sourceFile);
642
935
  const offset = state.offset <= content.length ? state.offset : 0;
643
936
  const segment = content.subarray(offset).toString("utf8");
644
- const lines = segment
645
- .split(/\r?\n/u)
646
- .filter((line) => line.includes(MARKER) || line.includes(STARTUP_MARKER));
937
+ const segmentLines = segment.split(/\r?\n/u);
938
+ const lines = segmentLines
939
+ .filter(
940
+ (line) =>
941
+ detailedMarker(line) ||
942
+ line.includes(STARTUP_MARKER) ||
943
+ line.includes(LOAD_PROOF_MARKER),
944
+ );
647
945
  const records = lines
648
- .filter((line) => line.includes(MARKER))
649
- .map((line) => parseRecord(line, MARKER))
946
+ .filter(detailedMarker)
947
+ .map(parseDetailedRecord)
650
948
  .filter(Boolean);
651
949
  const startupRecords = lines
652
950
  .filter((line) => line.includes(STARTUP_MARKER))
653
951
  .map((line) => parseRecord(line, STARTUP_MARKER))
654
952
  .filter(Boolean);
953
+ const markerLoadProofRecords = [
954
+ ...(state.perpsBootstrapStartRecord
955
+ ? [state.perpsBootstrapStartRecord]
956
+ : []),
957
+ ...lines
958
+ .filter((line) => line.includes(LOAD_PROOF_MARKER))
959
+ .map((line) => parseRecord(line, LOAD_PROOF_MARKER))
960
+ .filter(Boolean),
961
+ ];
962
+ const loadProofRecords = mergeLoadProofRecords([
963
+ ...markerLoadProofRecords,
964
+ ...existingLiveStreamRecords(segmentLines, markerLoadProofRecords),
965
+ ]);
655
966
  const requireRecords = node.require_records !== false;
656
967
  const requiredStages = Array.isArray(node.required_stages)
657
968
  ? node.required_stages.map(String)
@@ -732,6 +1043,26 @@ export async function captureHomepagePerformance(input) {
732
1043
  (check) => check.status === "fail",
733
1044
  );
734
1045
  const requireCacheBeforeFresh = node.require_cache_before_fresh === true;
1046
+ const requirePerpsBootstrapStartBeforeDemand =
1047
+ node.require_perps_bootstrap_start_before_demand === true;
1048
+ const requiredLiveStreams = Array.isArray(node.required_live_streams)
1049
+ ? node.required_live_streams.map(String)
1050
+ : [];
1051
+ const presentLiveStreams = new Set(
1052
+ loadProofRecords
1053
+ .filter(
1054
+ (record) =>
1055
+ record.stage === "values_ready" && record.source === "fresh_socket",
1056
+ )
1057
+ .map((record) => String(record.stream ?? "")),
1058
+ );
1059
+ const missingLiveStreams = requiredLiveStreams.filter(
1060
+ (stream) => !presentLiveStreams.has(stream),
1061
+ );
1062
+ const measuredPerpsBootstrapBoundary = perpsBootstrapBoundary(
1063
+ records,
1064
+ loadProofRecords,
1065
+ );
735
1066
  const cacheTakeover = cacheTakeoverMeasurement(
736
1067
  records,
737
1068
  requiredFreshContentVariants,
@@ -756,6 +1087,9 @@ export async function captureHomepagePerformance(input) {
756
1087
  missingFreshLifecycles.length === 0 &&
757
1088
  missingFreshContentVariants.length === 0 &&
758
1089
  failedFirstFreshDeliveries.length === 0 &&
1090
+ missingLiveStreams.length === 0 &&
1091
+ (!requirePerpsBootstrapStartBeforeDemand ||
1092
+ measuredPerpsBootstrapBoundary.status === "pass") &&
759
1093
  (!requireCacheBeforeFresh || cacheTakeover.status === "pass")
760
1094
  ? "pass"
761
1095
  : "fail",
@@ -767,20 +1101,31 @@ export async function captureHomepagePerformance(input) {
767
1101
  missingFreshLifecycles,
768
1102
  missingFreshContentVariants,
769
1103
  failedFirstFreshDeliveries,
1104
+ missingLiveStreams,
1105
+ perpsBootstrapStartBeforeDemand: requirePerpsBootstrapStartBeforeDemand
1106
+ ? measuredPerpsBootstrapBoundary.status
1107
+ : "not_required",
770
1108
  cacheBeforeFresh: requireCacheBeforeFresh
771
1109
  ? cacheTakeover.status
772
1110
  : "not_required",
773
1111
  };
1112
+ const captureEndedAtEpochMs = Date.now();
774
1113
  const summary = {
775
1114
  ...summarize(
776
1115
  records,
777
1116
  startupRecords,
1117
+ loadProofRecords,
778
1118
  sourcePath,
779
1119
  offset,
780
1120
  content.length - offset,
781
1121
  requiredFreshContentVariants,
782
1122
  firstFreshDeliveryRequirements,
783
1123
  ),
1124
+ captureWindow: {
1125
+ startedAtEpochMs: state.captureStartedAtEpochMs,
1126
+ endedAtEpochMs: captureEndedAtEpochMs,
1127
+ durationMs: captureEndedAtEpochMs - state.captureStartedAtEpochMs,
1128
+ },
784
1129
  validation,
785
1130
  };
786
1131
  const nodeId = String(input.context?.nodeId ?? "capture-performance");
@@ -804,6 +1149,19 @@ export async function captureHomepagePerformance(input) {
804
1149
  `Missing required ${STARTUP_MARKER} stages: ${missingStartupStages.join(", ")}.`,
805
1150
  );
806
1151
  }
1152
+ if (
1153
+ requirePerpsBootstrapStartBeforeDemand &&
1154
+ measuredPerpsBootstrapBoundary.status !== "pass"
1155
+ ) {
1156
+ throw new Error(
1157
+ `Perps bootstrap boundary requirement failed: ${measuredPerpsBootstrapBoundary.failureReason}.`,
1158
+ );
1159
+ }
1160
+ if (missingLiveStreams.length > 0) {
1161
+ throw new Error(
1162
+ `Missing required live Perps streams: ${missingLiveStreams.join(", ")}.`,
1163
+ );
1164
+ }
807
1165
  if (missingLifecycles.length > 0) {
808
1166
  throw new Error(
809
1167
  `Missing required ${MARKER} lifecycles: ${missingLifecycles.join(", ")}.`,
@@ -6,6 +6,7 @@ import {
6
6
  runAdapter,
7
7
  } from '../platform/bridge.mjs';
8
8
  import { ensureUnlocked } from '../wallet/ensure_unlocked.mjs';
9
+ import { selectAccount } from '../wallet/select_account.mjs';
9
10
 
10
11
  function sleep(ms) {
11
12
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -961,29 +962,74 @@ async function applyTutorialState(input, config) {
961
962
  export async function startState(input) {
962
963
  const params = paramsForState(input);
963
964
  const config = mergeStateConfig(profileDefaults(params.profile), params);
965
+ const timeoutMs = Math.max(1, Number(input.node?.timeout_ms ?? 30_000));
966
+ const deadline = Date.now() + timeoutMs;
967
+ const stateInput = () => {
968
+ const remainingMs = Math.max(1, deadline - Date.now());
969
+ const configuredTargetMs = Number(input.node?.target_timeout_ms);
970
+ const targetTimeoutMs = Math.min(
971
+ remainingMs,
972
+ Number.isFinite(configuredTargetMs) && configuredTargetMs > 0
973
+ ? configuredTargetMs
974
+ : Math.min(30_000, Math.ceil(remainingMs / 3)),
975
+ );
976
+ const configuredUnlockMs = Number(input.node?.unlock_timeout_ms);
977
+ const unlockTimeoutMs = Math.min(
978
+ Math.max(1, remainingMs - targetTimeoutMs),
979
+ Number.isFinite(configuredUnlockMs) && configuredUnlockMs > 0
980
+ ? configuredUnlockMs
981
+ : remainingMs,
982
+ );
983
+ return {
984
+ ...input,
985
+ node: {
986
+ ...input.node,
987
+ timeout_ms: remainingMs,
988
+ bridge_timeout_ms: remainingMs,
989
+ cdp_timeout_ms: remainingMs,
990
+ controller_ready_timeout_ms: remainingMs,
991
+ target_timeout_ms: targetTimeoutMs,
992
+ unlock_timeout_ms: unlockTimeoutMs,
993
+ },
994
+ };
995
+ };
964
996
  // Product edits require an app restart before proof. Mobile relocks on restart,
965
997
  // so a deterministic Perps start state must restore the fixture-backed wallet
966
998
  // prerequisite before it navigates or converges controller state.
999
+ const walletInput = stateInput();
967
1000
  const wallet = await ensureUnlocked({
968
- ...input,
1001
+ ...walletInput,
969
1002
  action: 'metamask.wallet.ensure_unlocked',
970
- node: { ...input.node, action: 'metamask.wallet.ensure_unlocked' },
1003
+ node: { ...walletInput.node, action: 'metamask.wallet.ensure_unlocked' },
971
1004
  });
1005
+ const accountInput = stateInput();
1006
+ const account = typeof config.account === 'string' && config.account.trim()
1007
+ ? await selectAccount({
1008
+ ...accountInput,
1009
+ action: 'metamask.wallet.select_account',
1010
+ node: {
1011
+ ...accountInput.node,
1012
+ action: 'metamask.wallet.select_account',
1013
+ name: config.account.trim(),
1014
+ },
1015
+ })
1016
+ : { skipped: true };
972
1017
  // Entering Perps initializes the provider client on a freshly launched app.
973
1018
  // State reads before this navigation fail with CLIENT_NOT_INITIALIZED.
974
- const navigation = await applyStateNavigation(input, config);
975
- const provider = await ensureProvider(input, config);
976
- const network = await ensureNetwork(input, config);
977
- const tutorial = await applyTutorialState(input, config);
978
- const readyToTrade = await assertReadyToTrade(input, config);
979
- const balance = await assertBalance(input, config);
980
- const orders = await applyOrdersState(input, config.orders);
981
- const positions = await applyPositionsState(input, config.positions);
1019
+ const navigation = await applyStateNavigation(stateInput(), config);
1020
+ const provider = await ensureProvider(stateInput(), config);
1021
+ const network = await ensureNetwork(stateInput(), config);
1022
+ const tutorial = await applyTutorialState(stateInput(), config);
1023
+ const readyToTrade = await assertReadyToTrade(stateInput(), config);
1024
+ const balance = await assertBalance(stateInput(), config);
1025
+ const orders = await applyOrdersState(stateInput(), config.orders);
1026
+ const positions = await applyPositionsState(stateInput(), config.positions);
982
1027
  return {
983
1028
  action: input.action,
984
1029
  profile: config.profile ?? params.profile ?? 'clean_market_testnet',
985
1030
  phase: 'start_state',
986
1031
  wallet,
1032
+ account,
987
1033
  provider,
988
1034
  network,
989
1035
  tutorial,