@deeeed/metamask-harness 0.42.0 → 0.44.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 (43) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +5 -0
  3. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +363 -55
  4. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +23 -10
  5. package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +2 -0
  6. package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +296 -25
  7. package/adapters/mobile/bridge-runtime/lib/config.cjs +14 -4
  8. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +4 -2
  9. package/adapters/mobile/reload-app.mjs +99 -1
  10. package/adapters/mobile/start-console-forwarder.sh +5 -1
  11. package/dist/adapters/extension/browser-cdp.js +174 -0
  12. package/dist/adapters/extension/network-observer.js +19 -110
  13. package/dist/adapters/extension/performance-observer.js +75 -0
  14. package/dist/adapters/mobile/frame-metrics.js +45 -0
  15. package/dist/adapters/mobile/performance-observer.js +43 -0
  16. package/dist/adapters/mobile/prepare.js +12 -0
  17. package/dist/adapters/performance/cdp-trace.js +342 -0
  18. package/dist/adapters/performance/js-task-metrics.js +35 -0
  19. package/dist/adapters.js +39 -5
  20. package/dist/artifact-files.js +92 -0
  21. package/dist/async.js +19 -0
  22. package/dist/commands/call.js +63 -4
  23. package/dist/commands/run-engine.js +265 -139
  24. package/dist/commands/run-report.js +68 -0
  25. package/dist/commands/run.js +67 -3
  26. package/dist/execution-provenance.js +342 -0
  27. package/dist/network-observation.js +59 -47
  28. package/dist/performance-observation.js +465 -0
  29. package/dist/run-diagnostics.js +36 -11
  30. package/dist/runner.js +44 -13
  31. package/docs/PERFORMANCE-CAPTURE.md +33 -0
  32. package/docs/RECIPES.md +7 -0
  33. package/library/actions/mobile/perps/performance-capture.mjs +570 -189
  34. package/library/actions/mobile/perps/perps.mjs +122 -0
  35. package/library/actions/mobile/platform/bridge.mjs +43 -8
  36. package/library/actions/mobile/platform/native-session.mjs +1 -1
  37. package/library/actions/mobile/wallet/lock.mjs +1 -4
  38. package/library/actions/mobile/wallet/select_account.mjs +129 -17
  39. package/library/manifests/extension.action-manifest.json +85 -0
  40. package/library/manifests/mobile.action-manifest.json +125 -3
  41. package/library/recipes/mobile/perps/performance.recipe.json +73 -47
  42. package/package.json +1 -1
  43. package/scripts/site-contrast.mjs +43 -27
@@ -2,6 +2,7 @@ import { pathToFileURL } from 'node:url';
2
2
  import {
3
3
  bridgeCommand,
4
4
  evalAsync,
5
+ isTargetTransition,
5
6
  navigate,
6
7
  runAdapter,
7
8
  } from '../platform/bridge.mjs';
@@ -69,6 +70,126 @@ export async function measureHomepageVisible(input) {
69
70
  };
70
71
  }
71
72
 
73
+ const MODE_TEST_IDS = {
74
+ lite: 'perps-mode-toggle-lite',
75
+ pro: 'perps-mode-toggle-pro',
76
+ };
77
+ const MODE_ROOT_TEST_IDS = {
78
+ lite: 'perps-market-details-view',
79
+ pro: 'perps-pro-market-scroll-view',
80
+ };
81
+ const READ_ATTEMPT_TIMEOUT_MS = 10_000;
82
+
83
+ function withRemainingDeadline(input, deadline, attemptTimeoutMs) {
84
+ const remainingMs = Math.max(1, deadline - Date.now());
85
+ const boundedTimeout = (configured) =>
86
+ Math.min(
87
+ remainingMs,
88
+ Number(configured ?? remainingMs),
89
+ attemptTimeoutMs ?? remainingMs,
90
+ );
91
+ return {
92
+ ...input,
93
+ node: {
94
+ ...input.node,
95
+ _action_deadline_epoch_ms: deadline,
96
+ bridge_timeout_ms: boundedTimeout(input.node?.bridge_timeout_ms),
97
+ cdp_timeout_ms: boundedTimeout(input.node?.cdp_timeout_ms),
98
+ controller_ready_timeout_ms: boundedTimeout(
99
+ input.node?.controller_ready_timeout_ms,
100
+ ),
101
+ },
102
+ };
103
+ }
104
+
105
+ async function isModePresent(input, mode, deadline) {
106
+ const rootTarget = JSON.stringify({
107
+ testId: MODE_ROOT_TEST_IDS[mode],
108
+ visibility: 'viewport',
109
+ });
110
+ const controlTarget = JSON.stringify({
111
+ testId: MODE_TEST_IDS[mode],
112
+ visibility: 'tree',
113
+ });
114
+ const result = await evalAsync(
115
+ withRemainingDeadline(input, deadline, READ_ATTEMPT_TIMEOUT_MS),
116
+ `(function(){
117
+ const api = globalThis.__AGENTIC__;
118
+ if (!api || typeof api.queryUiTarget !== 'function') {
119
+ return Promise.resolve(JSON.stringify({ unsupported: true }));
120
+ }
121
+ return Promise.all([
122
+ api.queryUiTarget(${rootTarget}),
123
+ api.queryUiTarget(${controlTarget})
124
+ ]).then(function(values){
125
+ return JSON.stringify({ root: values[0], control: values[1] });
126
+ });
127
+ })()`,
128
+ );
129
+ if (result?.unsupported) {
130
+ throw new Error('metamask.perps.ensure_mode requires UI target queries.');
131
+ }
132
+ return result?.root?.visible === true && result?.control?.present === true;
133
+ }
134
+
135
+ async function readModePresence(input, mode, deadline) {
136
+ try {
137
+ return await isModePresent(input, mode, deadline);
138
+ } catch (error) {
139
+ if (!isTargetTransition(error)) throw error;
140
+ return null;
141
+ }
142
+ }
143
+
144
+ export async function ensureMode(input) {
145
+ const requestedMode = String(input.node?.mode ?? '');
146
+ if (!(requestedMode in MODE_TEST_IDS)) {
147
+ throw new Error('metamask.perps.ensure_mode requires mode=lite|pro.');
148
+ }
149
+ const otherMode = requestedMode === 'lite' ? 'pro' : 'lite';
150
+ const deadline = Date.now() + Number(input.node?.timeout_ms ?? 30_000);
151
+ let switched = false;
152
+
153
+ while (Date.now() < deadline) {
154
+ const requestedModePresent = await readModePresence(
155
+ input,
156
+ requestedMode,
157
+ deadline,
158
+ );
159
+ if (requestedModePresent) {
160
+ return {
161
+ action: input.action,
162
+ mode: requestedMode,
163
+ alreadySelected: !switched,
164
+ proofPath: 'visible-market-detail-root-and-active-mode-control',
165
+ };
166
+ }
167
+ if (Date.now() >= deadline) break;
168
+ if (requestedModePresent !== null && !switched) {
169
+ const otherModePresent = await readModePresence(
170
+ input,
171
+ otherMode,
172
+ deadline,
173
+ );
174
+ if (otherModePresent && Date.now() < deadline) {
175
+ // The visible control names the mode it will switch to, not the mode
176
+ // currently selected.
177
+ const pressed = await bridgeCommand(withRemainingDeadline(input, deadline), [
178
+ 'press-test-id',
179
+ MODE_TEST_IDS[otherMode],
180
+ ]);
181
+ if (pressed?.ok === false) {
182
+ throw new Error(String(pressed.error || 'Perps mode control press failed.'));
183
+ }
184
+ switched = true;
185
+ }
186
+ }
187
+ await sleep(Math.min(250, Math.max(0, deadline - Date.now())));
188
+ }
189
+
190
+ throw new Error(`Perps did not reach ${requestedMode} mode before timeout.`);
191
+ }
192
+
72
193
  export async function retryPerpsClientRead(
73
194
  read,
74
195
  { timeoutMs = 20000, intervalMs = 500, sleep: wait = sleep } = {},
@@ -1074,6 +1195,7 @@ const DIRECT_ACTIONS = new Map([
1074
1195
  ['metamask.perps.ensure_positions', ensurePositions],
1075
1196
  ['metamask.perps.ensure_orders', ensureOrders],
1076
1197
  ['metamask.perps.clear_performance_caches', clearPerformanceCaches],
1198
+ ['metamask.perps.ensure_mode', ensureMode],
1077
1199
  ['metamask.perps.measure_homepage_visible', measureHomepageVisible],
1078
1200
  ['metamask.perps.start_state', startState],
1079
1201
  ['metamask.perps.teardown_state', teardownState],
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import bridgeErrors from '../../../../adapters/mobile/bridge-runtime/lib/bridge-errors.cjs';
10
10
  import brokerModule from '../../../../adapters/mobile/bridge-runtime/lib/cdp-broker.cjs';
11
+ import configModule from '../../../../adapters/mobile/bridge-runtime/lib/config.cjs';
11
12
  import { resolveMobileToolPath } from './tool-paths.mjs';
12
13
 
13
14
  const {
@@ -18,12 +19,14 @@ const {
18
19
  parseErrorMarker,
19
20
  } = bridgeErrors;
20
21
  const { brokerSocketPath } = brokerModule;
22
+ const { resolvePort } = configModule;
21
23
 
22
24
  // Re-exported so the TS adapter classifies on the same code constants without a
23
25
  // second import path into the cjs bridge-runtime.
24
26
  export const MOBILE_BRIDGE_ERROR_CODES = BRIDGE_ERROR_CODES;
25
27
 
26
28
  const execFileAsync = promisify(execFile);
29
+ const ACTION_DEADLINE_FIELD = '_action_deadline_epoch_ms';
27
30
 
28
31
  let adapterActionActive = false;
29
32
  let actionBridgeLockPath = null;
@@ -115,14 +118,17 @@ function runtimeDir() {
115
118
  * Returns the trimmed ro.product.model value, or null when adb is unavailable
116
119
  * or the serial does not respond. Uses execFile (never shell interpolation).
117
120
  */
118
- async function resolveAndroidModel(adbSerial) {
121
+ async function resolveAndroidModel(adbSerial, timeoutMs = 5_000) {
119
122
  const adbPath = resolveMobileToolPath('adb');
120
- if (!adbPath) return null;
123
+ if (!adbPath || timeoutMs <= 0) return null;
121
124
  try {
122
125
  const { stdout } = await execFileAsync(
123
126
  adbPath,
124
127
  ['-s', adbSerial, 'shell', 'getprop', 'ro.product.model'],
125
- { timeout: 5000, encoding: 'utf8' },
128
+ {
129
+ timeout: Math.max(1, Math.min(5_000, Math.floor(timeoutMs))),
130
+ encoding: 'utf8',
131
+ },
126
132
  );
127
133
  const model = stdout.trim();
128
134
  return model || null;
@@ -184,7 +190,11 @@ export async function bridgeEnv(input) {
184
190
  androidTargetDeviceName,
185
191
  )
186
192
  ) {
187
- const model = await resolveAndroidModel(serialStr);
193
+ const deadline = Number(input.node?.[ACTION_DEADLINE_FIELD]);
194
+ const modelTimeoutMs = Number.isFinite(deadline)
195
+ ? deadline - Date.now()
196
+ : 5_000;
197
+ const model = await resolveAndroidModel(serialStr, modelTimeoutMs);
188
198
  if (model) {
189
199
  env.ANDROID_TARGET_DEVICE_NAME = model;
190
200
  }
@@ -430,16 +440,41 @@ const TRANSIENT_NULL_COMMANDS = new Set(['get-route']);
430
440
 
431
441
  export async function bridgeCommand(input, args) {
432
442
  const script = bridgeScript(input);
433
- // bridgeEnv is async: it may call `adb getprop` to resolve the Metro device name.
434
443
  const env = await bridgeEnv(input);
435
444
  const brokerSocket = brokerSocketPath(
436
445
  path.dirname(resolveBridgeLockPath(input, env)),
446
+ resolvePort(env, input.context.projectRoot),
437
447
  );
438
448
  if (adapterActionActive && !existsSync(brokerSocket)) {
439
449
  env.CDP_BRIDGE_LOCK_OWNER_PID = await acquireActionBridgeLock(input, env);
440
450
  }
451
+ const actionDeadline = Number(input.node?.[ACTION_DEADLINE_FIELD]);
452
+ const configuredTimeoutValue = Number(
453
+ input.node?.bridge_timeout_ms ??
454
+ input.node?.cdp_timeout_ms ??
455
+ process.env.CDP_TIMEOUT ??
456
+ 30000,
457
+ );
458
+ const configuredTimeoutMs =
459
+ Number.isFinite(configuredTimeoutValue) && configuredTimeoutValue > 0
460
+ ? configuredTimeoutValue
461
+ : 30_000;
462
+ const remainingActionMs = Number.isFinite(actionDeadline)
463
+ ? actionDeadline - Date.now()
464
+ : configuredTimeoutMs;
465
+ if (remainingActionMs <= 0) {
466
+ throw coded(
467
+ new Error(
468
+ `Mobile CDP bridge command did not start before its action deadline: ${String(args[0] ?? 'unknown')}`,
469
+ ),
470
+ BRIDGE_ERROR_CODES.CDP_TIMEOUT,
471
+ );
472
+ }
473
+ const timeoutMs = Math.max(
474
+ 1,
475
+ Math.min(configuredTimeoutMs, remainingActionMs),
476
+ );
441
477
  const result = await new Promise((resolve, reject) => {
442
- const timeoutMs = Number(input.node?.bridge_timeout_ms ?? input.node?.cdp_timeout_ms ?? process.env.CDP_TIMEOUT ?? 30000);
443
478
  const child = spawn(process.execPath, [script, ...args], {
444
479
  cwd: input.context.projectRoot,
445
480
  env: { ...env, APP_ROOT: input.context.projectRoot },
@@ -564,7 +599,7 @@ export async function evalAsync(input, expression) {
564
599
  } catch (error) {
565
600
  lastError = error;
566
601
  if (!String(error?.message ?? error).includes('CLIENT_NOT_INITIALIZED')) throw error;
567
- await sleep(500);
602
+ await sleep(Math.min(500, Math.max(0, deadline - Date.now())));
568
603
  }
569
604
  }
570
605
  throw lastError ?? new Error('Timed out waiting for Mobile async evaluation.');
@@ -580,7 +615,7 @@ const TARGET_TRANSITION_CODES = new Set([
580
615
  BRIDGE_ERROR_CODES.WS_CLOSED,
581
616
  ]);
582
617
 
583
- function isTargetTransition(error) {
618
+ export function isTargetTransition(error) {
584
619
  return TARGET_TRANSITION_CODES.has(error?.code);
585
620
  }
586
621
 
@@ -9,7 +9,7 @@ import {
9
9
  export function createNativeSession(input, domain, sessionSuffix = '') {
10
10
  const env = { ...process.env, ...(input.context?.env ?? {}) };
11
11
  const device = String(env.ADB_SERIAL ?? env.ANDROID_SERIAL ?? '').trim();
12
- if (!device) throw new Error(`Opaque Mobile ${domain} actions require one explicit Android device serial.`);
12
+ if (!device) throw new Error(`Mobile ${domain} native actions require one explicit Android device serial.`);
13
13
  const app = String(env.ANDROID_PACKAGE_ID ?? 'io.metamask');
14
14
  const baseSession = nativeSessionName(env, device, app);
15
15
  const session = sessionSuffix
@@ -1,10 +1,7 @@
1
1
  import { runAdapter } from '../platform/bridge.mjs';
2
- import { isOpaqueMobileRuntime, lockWalletThroughNativeUi } from './native-ui.mjs';
2
+ import { lockWalletThroughNativeUi } from './native-ui.mjs';
3
3
 
4
4
  runAdapter(async (input) => {
5
- if (!isOpaqueMobileRuntime(input)) {
6
- throw new Error('metamask.wallet.lock requires an opaque Mobile runtime.');
7
- }
8
5
  return {
9
6
  action: input.action,
10
7
  ...await lockWalletThroughNativeUi(input),
@@ -1,7 +1,87 @@
1
1
  import { pathToFileURL } from 'node:url';
2
- import { bridgeCommand, runAdapter } from '../platform/bridge.mjs';
2
+ import {
3
+ bridgeCommand,
4
+ MOBILE_BRIDGE_ERROR_CODES,
5
+ runAdapter,
6
+ } from '../platform/bridge.mjs';
3
7
 
4
8
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
9
+ const TRANSIENT_READ_CODES = new Set([
10
+ MOBILE_BRIDGE_ERROR_CODES.NO_TARGET,
11
+ MOBILE_BRIDGE_ERROR_CODES.CDP_TIMEOUT,
12
+ MOBILE_BRIDGE_ERROR_CODES.WS_CLOSED,
13
+ ]);
14
+ const READ_ATTEMPT_TIMEOUT_MS = 10_000;
15
+
16
+ function withRemainingDeadline(input, deadline, attemptTimeoutMs) {
17
+ const remainingMs = Math.max(1, deadline - Date.now());
18
+ const boundedTimeout = (configured) =>
19
+ Math.min(
20
+ remainingMs,
21
+ Number(configured ?? remainingMs),
22
+ attemptTimeoutMs ?? remainingMs,
23
+ );
24
+ return {
25
+ ...input,
26
+ node: {
27
+ ...input.node,
28
+ _action_deadline_epoch_ms: deadline,
29
+ bridge_timeout_ms: boundedTimeout(input.node?.bridge_timeout_ms),
30
+ cdp_timeout_ms: boundedTimeout(input.node?.cdp_timeout_ms),
31
+ },
32
+ };
33
+ }
34
+
35
+ async function readSelectedAccount(input, deadline) {
36
+ let lastError = null;
37
+ while (Date.now() < deadline) {
38
+ try {
39
+ return await bridgeCommand(
40
+ withRemainingDeadline(input, deadline, READ_ATTEMPT_TIMEOUT_MS),
41
+ ['get-selected-account'],
42
+ );
43
+ } catch (error) {
44
+ if (!TRANSIENT_READ_CODES.has(error?.code)) throw error;
45
+ lastError = error;
46
+ await sleep(Math.min(100, Math.max(0, deadline - Date.now())));
47
+ }
48
+ }
49
+ throw lastError ?? new Error('Timed out reading the selected Mobile account.');
50
+ }
51
+
52
+ async function readAccounts(input, deadline) {
53
+ let lastError = null;
54
+ while (Date.now() < deadline) {
55
+ try {
56
+ return await bridgeCommand(
57
+ withRemainingDeadline(input, deadline, READ_ATTEMPT_TIMEOUT_MS),
58
+ ['list-accounts'],
59
+ );
60
+ } catch (error) {
61
+ if (!TRANSIENT_READ_CODES.has(error?.code)) throw error;
62
+ lastError = error;
63
+ await sleep(Math.min(100, Math.max(0, deadline - Date.now())));
64
+ }
65
+ }
66
+ throw lastError ?? new Error('Timed out reading Mobile accounts.');
67
+ }
68
+
69
+ function ambiguousWriteFailure(writeError, match, finalSelected, readError) {
70
+ const failure = new Error(
71
+ `Mobile account switch to ${match.address} returned an ambiguous ${String(writeError.code)} error and did not converge before the deadline; ` +
72
+ `writeError=${String(writeError.message ?? writeError)}; finalSelectedAccount=${JSON.stringify(finalSelected)}${
73
+ readError
74
+ ? `; finalReadError=${String(readError.message ?? readError)}`
75
+ : ''
76
+ }.`,
77
+ { cause: writeError },
78
+ );
79
+ failure.code = writeError.code;
80
+ failure.writeError = writeError;
81
+ failure.finalSelectedAccount = finalSelected;
82
+ if (readError) failure.finalReadError = readError;
83
+ return failure;
84
+ }
5
85
 
6
86
  export async function selectAccount(input) {
7
87
  const requestedAddress = input.node?.address ? String(input.node.address).toLowerCase() : null;
@@ -10,7 +90,9 @@ export async function selectAccount(input) {
10
90
  if (!requestedAddress && !requestedId && !requestedName) {
11
91
  throw new Error('metamask.wallet.select_account mobile live adapter requires node.address, node.id, or node.name.');
12
92
  }
13
- const accounts = await bridgeCommand(input, ['list-accounts']);
93
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
94
+ const deadline = Date.now() + timeoutMs;
95
+ const accounts = await readAccounts(input, deadline);
14
96
  if (!Array.isArray(accounts)) {
15
97
  throw new Error(`metamask.wallet.select_account expected list-accounts to return an array, got ${typeof accounts}.`);
16
98
  }
@@ -25,7 +107,7 @@ export async function selectAccount(input) {
25
107
  if (!match?.address) {
26
108
  throw new Error(`Requested mobile account was not found in list-accounts: ${JSON.stringify({ address: requestedAddress, id: requestedId, name: requestedName })}`);
27
109
  }
28
- const current = await bridgeCommand(input, ['get-selected-account']);
110
+ const current = await readSelectedAccount(input, deadline);
29
111
  const matchAddress = String(match.address).toLowerCase();
30
112
  if (String(current?.address ?? current ?? '').toLowerCase() === matchAddress) {
31
113
  return {
@@ -39,29 +121,59 @@ export async function selectAccount(input) {
39
121
  proofPath: 'agentic-account-selection',
40
122
  };
41
123
  }
42
- const result = await bridgeCommand(input, ['switch-account', String(match.address)]);
43
- const timeoutMs = Number(input.node?.timeout_ms ?? 5000);
44
- const deadline = Date.now() + timeoutMs;
45
- let selected = null;
46
- do {
47
- selected = await bridgeCommand(input, ['get-selected-account']);
48
- if (String(selected?.address ?? selected ?? '').toLowerCase() === String(match.address).toLowerCase()) {
49
- break;
50
- }
51
- await sleep(100);
52
- } while (Date.now() < deadline);
124
+ if (Date.now() >= deadline) {
125
+ throw new Error(
126
+ `Mobile account selection did not leave enough time to switch to ${match.address} within ${timeoutMs}ms.`,
127
+ );
128
+ }
129
+ let result = null;
130
+ let ambiguousWriteError = null;
131
+ try {
132
+ result = await bridgeCommand(withRemainingDeadline(input, deadline), [
133
+ 'switch-account',
134
+ String(match.address),
135
+ ]);
136
+ } catch (error) {
137
+ if (!TRANSIENT_READ_CODES.has(error?.code)) throw error;
138
+ ambiguousWriteError = error;
139
+ }
140
+ let selected = current;
141
+ let finalReadError = null;
142
+ try {
143
+ do {
144
+ selected = await readSelectedAccount(input, deadline);
145
+ if (String(selected?.address ?? selected ?? '').toLowerCase() === String(match.address).toLowerCase()) {
146
+ break;
147
+ }
148
+ await sleep(Math.min(100, Math.max(0, deadline - Date.now())));
149
+ } while (Date.now() < deadline);
150
+ } catch (error) {
151
+ if (!ambiguousWriteError) throw error;
152
+ finalReadError = error;
153
+ }
53
154
 
54
155
  if (String(selected?.address ?? selected ?? '').toLowerCase() !== String(match.address).toLowerCase()) {
156
+ if (ambiguousWriteError) {
157
+ throw ambiguousWriteFailure(
158
+ ambiguousWriteError,
159
+ match,
160
+ selected,
161
+ finalReadError,
162
+ );
163
+ }
55
164
  throw new Error(`Mobile account selection did not converge to ${match.address} after ${timeoutMs}ms.`);
56
165
  }
57
166
 
58
167
  return {
59
168
  action: input.action,
60
169
  selected: {
61
- address: selected?.address ?? result.address,
62
- id: selected?.id ?? result.id,
63
- name: selected?.name ?? result.name,
170
+ address: selected?.address ?? result?.address ?? match.address,
171
+ id: selected?.id ?? result?.id ?? match.id,
172
+ name: selected?.name ?? result?.name ?? match.name,
64
173
  },
174
+ ...(ambiguousWriteError
175
+ ? { recoveredAfterAmbiguousWrite: true }
176
+ : {}),
65
177
  proofPath: 'agentic-account-selection',
66
178
  };
67
179
  }
@@ -1207,6 +1207,91 @@
1207
1207
  },
1208
1208
  "execution_capabilities": []
1209
1209
  },
1210
+ "app.performance_capture": {
1211
+ "description": "Capture bounded CDP renderer frame metrics and separately labelled JavaScript work across Recipe Protocol v1 node boundaries.",
1212
+ "examples": [
1213
+ {
1214
+ "action": "app.performance_capture",
1215
+ "phase": "start",
1216
+ "id": "homepage-scroll",
1217
+ "max_duration_ms": 300000,
1218
+ "intent": "Start the bounded UI smoothness window",
1219
+ "next": "exercise-flow"
1220
+ },
1221
+ {
1222
+ "action": "app.performance_capture",
1223
+ "phase": "end",
1224
+ "id": "homepage-scroll",
1225
+ "artifact_path": "performance/homepage-scroll.json",
1226
+ "html_path": "performance/homepage-scroll.html",
1227
+ "intent": "End and index the UI smoothness window",
1228
+ "next": "assert-performance"
1229
+ }
1230
+ ],
1231
+ "schema": {
1232
+ "type": "object",
1233
+ "properties": {
1234
+ "phase": {
1235
+ "type": "string",
1236
+ "enum": ["start", "end"]
1237
+ },
1238
+ "id": { "type": "string" },
1239
+ "max_duration_ms": {
1240
+ "type": "integer",
1241
+ "minimum": 1,
1242
+ "description": "Maximum accepted at runtime: 3600000 ms."
1243
+ },
1244
+ "artifact_path": { "type": "string" },
1245
+ "html_path": { "type": "string" }
1246
+ },
1247
+ "required": ["phase", "id"],
1248
+ "additionalProperties": false
1249
+ },
1250
+ "execution_capabilities": ["host-read-export"]
1251
+ },
1252
+ "app.performance_assert": {
1253
+ "description": "Assert a previously indexed app.performance_capture summary without hiding unavailable or partial source coverage.",
1254
+ "examples": [
1255
+ {
1256
+ "action": "app.performance_assert",
1257
+ "id": "homepage-scroll",
1258
+ "artifact_path": "performance/homepage-scroll.json",
1259
+ "required_status": ["complete", "partial"],
1260
+ "minimum_frame_count": 1,
1261
+ "require_native_ui_when_supported": true,
1262
+ "required_node_ids": ["scroll-homepage"],
1263
+ "intent": "Assert the indexed UI smoothness summary",
1264
+ "next": "done"
1265
+ }
1266
+ ],
1267
+ "schema": {
1268
+ "type": "object",
1269
+ "properties": {
1270
+ "id": { "type": "string" },
1271
+ "artifact_path": { "type": "string" },
1272
+ "required_status": {
1273
+ "type": "array",
1274
+ "items": {
1275
+ "type": "string",
1276
+ "enum": ["complete", "partial", "unavailable"]
1277
+ }
1278
+ },
1279
+ "minimum_frame_count": {
1280
+ "type": "integer",
1281
+ "minimum": 0,
1282
+ "description": "Minimum MetaMask renderer frame count. JavaScript task count cannot satisfy this assertion."
1283
+ },
1284
+ "require_native_ui_when_supported": { "type": "boolean" },
1285
+ "required_node_ids": {
1286
+ "type": "array",
1287
+ "items": { "type": "string" }
1288
+ }
1289
+ },
1290
+ "required": ["id"],
1291
+ "additionalProperties": false
1292
+ },
1293
+ "execution_capabilities": ["host-read-export"]
1294
+ },
1210
1295
  "app.status": {
1211
1296
  "description": "Report the adapter's static status — platform, project root, resolved checkout shape, and headless compatibility mode (no live route or account).",
1212
1297
  "examples": [