@deeeed/metamask-harness 0.2.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 (158) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/README.md +140 -0
  3. package/bin/mm-harness +99 -0
  4. package/docs/CHEATSHEET.md +61 -0
  5. package/docs/CLI-SPEC.md +915 -0
  6. package/docs/MENTAL-MODEL.md +295 -0
  7. package/docs/architecture.md +367 -0
  8. package/docs/extension-runtime-commands.md +60 -0
  9. package/docs/harness-cli.md +43 -0
  10. package/docs/live-adapter-contract.md +188 -0
  11. package/docs/package-boundaries.md +47 -0
  12. package/docs/perps-flow-catalog.md +235 -0
  13. package/docs/recipe-libraries.md +95 -0
  14. package/docs/runtime-file-conventions.md +36 -0
  15. package/library/actions/core/perps/_controller.mjs +727 -0
  16. package/library/actions/core/perps/assert_orders.mjs +53 -0
  17. package/library/actions/core/perps/assert_positions.mjs +52 -0
  18. package/library/actions/core/perps/close_orders.mjs +97 -0
  19. package/library/actions/core/perps/close_positions.mjs +118 -0
  20. package/library/actions/core/perps/ensure_orders.mjs +40 -0
  21. package/library/actions/core/perps/ensure_positions.mjs +37 -0
  22. package/library/actions/core/perps/place_order.mjs +201 -0
  23. package/library/actions/core/perps/read_account.mjs +30 -0
  24. package/library/actions/core/perps/read_orders.mjs +27 -0
  25. package/library/actions/core/perps/read_positions.mjs +27 -0
  26. package/library/actions/core/perps/start_state.mjs +92 -0
  27. package/library/actions/core/perps/teardown_state.mjs +86 -0
  28. package/library/actions/extension/perps/assert_orders.mjs +11 -0
  29. package/library/actions/extension/perps/assert_positions.mjs +11 -0
  30. package/library/actions/extension/perps/close_orders.mjs +8 -0
  31. package/library/actions/extension/perps/close_positions.mjs +8 -0
  32. package/library/actions/extension/perps/ensure_orders.mjs +4 -0
  33. package/library/actions/extension/perps/ensure_positions.mjs +4 -0
  34. package/library/actions/extension/perps/perps.mjs +730 -0
  35. package/library/actions/extension/perps/place_order.mjs +7 -0
  36. package/library/actions/extension/perps/read_orders.mjs +4 -0
  37. package/library/actions/extension/perps/read_positions.mjs +3 -0
  38. package/library/actions/extension/platform/cdp.mjs +541 -0
  39. package/library/actions/extension/ui/navigate.mjs +44 -0
  40. package/library/actions/extension/wallet/ensure_unlocked.mjs +36 -0
  41. package/library/actions/extension/wallet/read_state.mjs +27 -0
  42. package/library/actions/extension/wallet/select_account.mjs +48 -0
  43. package/library/actions/extension/wallet/setup.mjs +35 -0
  44. package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +185 -0
  45. package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +1662 -0
  46. package/library/actions/mobile/bridge-runtime/cdp-bridge.cjs +686 -0
  47. package/library/actions/mobile/bridge-runtime/lib/cdp-eval.cjs +110 -0
  48. package/library/actions/mobile/bridge-runtime/lib/config.cjs +39 -0
  49. package/library/actions/mobile/bridge-runtime/lib/issue-capture.cjs +446 -0
  50. package/library/actions/mobile/bridge-runtime/lib/target-discovery.cjs +204 -0
  51. package/library/actions/mobile/bridge-runtime/lib/ws-client.cjs +108 -0
  52. package/library/actions/mobile/bridge-runtime/setup-wallet.sh +442 -0
  53. package/library/actions/mobile/perps/assert_orders.mjs +11 -0
  54. package/library/actions/mobile/perps/assert_positions.mjs +11 -0
  55. package/library/actions/mobile/perps/close_orders.mjs +8 -0
  56. package/library/actions/mobile/perps/close_positions.mjs +8 -0
  57. package/library/actions/mobile/perps/ensure_orders.mjs +4 -0
  58. package/library/actions/mobile/perps/ensure_positions.mjs +4 -0
  59. package/library/actions/mobile/perps/perps.mjs +709 -0
  60. package/library/actions/mobile/perps/place_order.mjs +7 -0
  61. package/library/actions/mobile/perps/read_orders.mjs +4 -0
  62. package/library/actions/mobile/perps/read_positions.mjs +3 -0
  63. package/library/actions/mobile/platform/bridge.mjs +283 -0
  64. package/library/actions/mobile/ui/navigate.mjs +38 -0
  65. package/library/actions/mobile/wallet/ensure_unlocked.mjs +107 -0
  66. package/library/actions/mobile/wallet/home.mjs +35 -0
  67. package/library/actions/mobile/wallet/read_state.mjs +40 -0
  68. package/library/actions/mobile/wallet/select_account.mjs +48 -0
  69. package/library/actions/mobile/wallet/setup.mjs +220 -0
  70. package/library/flows/perps.flows.json +64 -0
  71. package/library/library.json +7 -0
  72. package/library/manifests/core.action-manifest.json +1282 -0
  73. package/library/manifests/extension.action-manifest.json +1749 -0
  74. package/library/manifests/mobile.action-manifest.json +1753 -0
  75. package/library/recipes/action-validation.extension.recipe.json +417 -0
  76. package/library/recipes/action-validation.mobile.recipe.json +422 -0
  77. package/library/recipes/order-lifecycle.core.recipe.json +78 -0
  78. package/library/recipes/perps-lifecycle.recipe.json +194 -0
  79. package/library/recipes/read-markets.core.recipe.json +38 -0
  80. package/library/recipes/smoke.extension.recipe.json +31 -0
  81. package/library/recipes/smoke.mobile.recipe.json +31 -0
  82. package/library/recipes/trading-lifecycle.core.recipe.json +76 -0
  83. package/orchestration/compat-overlays/README.md +19 -0
  84. package/orchestration/compat-overlays/mobile/README.md +13 -0
  85. package/orchestration/compat-overlays/mobile/rn81-message-event-source.patch +42 -0
  86. package/orchestration/core/cleanup.sh +37 -0
  87. package/orchestration/core/inject.sh +154 -0
  88. package/orchestration/doctor.mjs +72 -0
  89. package/orchestration/extension/cleanup.mjs +60 -0
  90. package/orchestration/extension/console-tail.mjs +228 -0
  91. package/orchestration/extension/ensure-browser.sh +416 -0
  92. package/orchestration/extension/ensure-ready.ts +185 -0
  93. package/orchestration/extension/extension-id.ts +107 -0
  94. package/orchestration/extension/inject.mjs +266 -0
  95. package/orchestration/extension/launch-browser.cjs +216 -0
  96. package/orchestration/extension/launch.sh +175 -0
  97. package/orchestration/extension/live.sh +320 -0
  98. package/orchestration/extension/pin-remote-flags.cjs +45 -0
  99. package/orchestration/extension/readiness.mjs +414 -0
  100. package/orchestration/extension/refresh-build.sh +190 -0
  101. package/orchestration/extension/runtime-decision.ts +445 -0
  102. package/orchestration/extension/runtime.ts +407 -0
  103. package/orchestration/extension/seed-fixture.sh +177 -0
  104. package/orchestration/extension/sidepanel-toggle.sh +291 -0
  105. package/orchestration/extension/snapshot-dist.sh +84 -0
  106. package/orchestration/extension/start-watch.sh +339 -0
  107. package/orchestration/extension/wallet-fixture-state.cjs +1086 -0
  108. package/orchestration/lib/activate-repo-node.sh +144 -0
  109. package/orchestration/lib/cli-color.mjs +84 -0
  110. package/orchestration/lib/cli-commands.mjs +243 -0
  111. package/orchestration/lib/cli-home.mjs +354 -0
  112. package/orchestration/lib/cli-ux.sh +252 -0
  113. package/orchestration/lib/cli-version.mjs +123 -0
  114. package/orchestration/lib/ensure-runner-deps.sh +56 -0
  115. package/orchestration/lib/harness-path.sh +55 -0
  116. package/orchestration/lib/hash-helpers.sh +44 -0
  117. package/orchestration/lib/json-field.sh +23 -0
  118. package/orchestration/lib/log-tui.mjs +304 -0
  119. package/orchestration/lib/open-debug.mjs +317 -0
  120. package/orchestration/lib/path-defaults.json +4 -0
  121. package/orchestration/lib/progress.mjs +107 -0
  122. package/orchestration/lib/recipe-paths.mjs +26 -0
  123. package/orchestration/lib/resolve-farmslot-ports.sh +144 -0
  124. package/orchestration/manifest.json +358 -0
  125. package/orchestration/mobile/cleanup.sh +192 -0
  126. package/orchestration/mobile/deps-markers.ts +21 -0
  127. package/orchestration/mobile/inject.sh +681 -0
  128. package/orchestration/mobile/launch.sh +137 -0
  129. package/orchestration/mobile/live.sh +125 -0
  130. package/orchestration/mobile/runtime-decision.ts +292 -0
  131. package/orchestration/porcelain/metamask-recipe +99 -0
  132. package/orchestration/porcelain/mm-recipe +1591 -0
  133. package/orchestration/porcelain/mme-recipe +1181 -0
  134. package/package.json +59 -0
  135. package/runner/extension/verify.sh +511 -0
  136. package/runner/mobile/verify.sh +501 -0
  137. package/runner/src/adapters.ts +601 -0
  138. package/runner/src/cli.ts +1820 -0
  139. package/runner/src/commands/debug.ts +44 -0
  140. package/runner/src/commands/fixtures.ts +99 -0
  141. package/runner/src/commands/launch.ts +397 -0
  142. package/runner/src/commands/logs.ts +60 -0
  143. package/runner/src/commands/shared.ts +138 -0
  144. package/runner/src/completions-cache.ts +86 -0
  145. package/runner/src/doctor.ts +203 -0
  146. package/runner/src/harness.ts +516 -0
  147. package/runner/src/heal-bounds.ts +179 -0
  148. package/runner/src/index.ts +6 -0
  149. package/runner/src/live-adapter-contract.ts +274 -0
  150. package/runner/src/manifest.ts +47 -0
  151. package/runner/src/mm-harness-cli.ts +488 -0
  152. package/runner/src/paths.ts +198 -0
  153. package/runner/src/recording-target.ts +147 -0
  154. package/runner/src/run-recording.ts +329 -0
  155. package/runner/src/runner.ts +108 -0
  156. package/runner/src/types.ts +57 -0
  157. package/scripts/completions.sh +125 -0
  158. package/scripts/install-completions.sh +62 -0
@@ -0,0 +1,601 @@
1
+ import http from 'node:http';
2
+ import path from 'node:path';
3
+ import { compatibilityMode, fixtureSummary, repoShape } from './doctor.ts';
4
+ import { runLiveAdapterScript } from './live-adapter-contract.ts';
5
+ import { withExtensionPage } from '../../library/actions/extension/platform/cdp.mjs';
6
+ import { bridgeCommand, evalSync, simulatorScreenshot } from '../../library/actions/mobile/platform/bridge.mjs';
7
+ import type {
8
+ ActionAdapter,
9
+ ActionExecutionContext,
10
+ ActionResult,
11
+ UiActionTransport,
12
+ } from '@farmslot/recipe-harness';
13
+ import type { MetaMaskRecipeAdapter } from './types.ts';
14
+
15
+ type ActionNode = Record<string, unknown>;
16
+
17
+ function sleep(ms: number): Promise<void> {
18
+ return new Promise((resolve) => setTimeout(resolve, ms));
19
+ }
20
+
21
+ interface MetaMaskUiActionInput {
22
+ action: string;
23
+ nodeId?: string;
24
+ node: ActionNode;
25
+ }
26
+
27
+ type ActionExecutor = (
28
+ node: ActionNode,
29
+ context: ActionExecutionContext,
30
+ ) => Promise<ActionResult> | ActionResult;
31
+
32
+ function simpleAdapter(action: string, executor: ActionExecutor): ActionAdapter {
33
+ return {
34
+ action,
35
+ async execute(node, context) {
36
+ return executor(node, context);
37
+ },
38
+ };
39
+ }
40
+
41
+ const LIVE_ONLY_PERPS_ACTIONS = new Set([
42
+ 'metamask.perps.read_positions',
43
+ 'metamask.perps.ensure_positions',
44
+ 'metamask.perps.assert_positions',
45
+ 'metamask.perps.place_order',
46
+ 'metamask.perps.close_positions',
47
+ 'metamask.perps.read_orders',
48
+ 'metamask.perps.close_orders',
49
+ 'metamask.perps.ensure_orders',
50
+ 'metamask.perps.assert_orders',
51
+ 'metamask.perps.start_state',
52
+ 'metamask.perps.teardown_state',
53
+ ]);
54
+
55
+ // read_account is implemented only by the headless `core` adapter (it reads
56
+ // HyperLiquid account state directly via the standalone controller path). Mobile/
57
+ // extension have no live adapter for it, so it is gated to core: not advertised by
58
+ // the mobile/extension semantic adapter list, and only treated as a required live
59
+ // action when running under core.
60
+ const CORE_ONLY_PERPS_ACTIONS = new Set(['metamask.perps.read_account']);
61
+ const LIVE_ONLY_WALLET_ACTIONS = new Set([
62
+ 'metamask.wallet.setup',
63
+ 'metamask.wallet.ensure_unlocked',
64
+ 'metamask.wallet.select_account',
65
+ 'metamask.wallet.read_state',
66
+ ]);
67
+ const LIVE_ONLY_APP_ACTIONS = new Set(['ui.navigate']);
68
+
69
+ function requiresLiveAdapter(platform: MetaMaskRecipeAdapter, action: string) {
70
+ return (
71
+ LIVE_ONLY_PERPS_ACTIONS.has(action) ||
72
+ (platform === 'core' && CORE_ONLY_PERPS_ACTIONS.has(action)) ||
73
+ LIVE_ONLY_WALLET_ACTIONS.has(action) ||
74
+ LIVE_ONLY_APP_ACTIONS.has(action)
75
+ );
76
+ }
77
+
78
+ function liveRuntimeConfigured(platform: MetaMaskRecipeAdapter, node: ActionNode) {
79
+ // core drives the perps controller directly (HTTP reads) — no CDP/bridge runtime to probe.
80
+ if (platform === 'mobile' || platform === 'core') return true;
81
+ return Boolean(node.cdp_port ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT);
82
+ }
83
+
84
+ async function runLiveFirst(
85
+ platform: MetaMaskRecipeAdapter,
86
+ action: string,
87
+ node: ActionNode,
88
+ context: ActionExecutionContext,
89
+ ): Promise<ActionResult | null> {
90
+ if (node.allow_static_placeholder === true) {
91
+ throw new Error(
92
+ `${action} refused allow_static_placeholder. MetaMask runner proof runs must use live adapters and real artifacts.`,
93
+ );
94
+ }
95
+ if (!requiresLiveAdapter(platform, action) && !liveRuntimeConfigured(platform, node)) return null;
96
+ const live = await runLiveAdapterScript({ platform, action, node, context });
97
+ if (!live) return null;
98
+ const liveResult = isRecord(live.result) ? live.result : { result: live.result };
99
+ const output = { ...liveResult, liveAdapter: live.script };
100
+ return {
101
+ output,
102
+ artifacts:
103
+ isRecord(live.result) && Array.isArray(live.result.artifacts)
104
+ ? live.result.artifacts
105
+ : undefined,
106
+ };
107
+ }
108
+
109
+ function liveAdapterPathHint(platform: MetaMaskRecipeAdapter, action: string) {
110
+ if (action.startsWith('metamask.')) {
111
+ return `library/actions/${platform}/${action.replace(/^metamask[.]/u, '').replaceAll('.', '/')}.mjs`;
112
+ }
113
+ return `library/actions/${platform}/${action.replaceAll('.', '/')}.mjs`;
114
+ }
115
+
116
+ async function semanticResult(
117
+ platform: MetaMaskRecipeAdapter,
118
+ action: string,
119
+ node: ActionNode,
120
+ context: ActionExecutionContext,
121
+ ): Promise<ActionResult> {
122
+ const live = await runLiveFirst(platform, action, node, context);
123
+ if (live) return live;
124
+ if (requiresLiveAdapter(platform, action)) {
125
+ const expected = liveAdapterPathHint(platform, action);
126
+ throw new Error(
127
+ `${action} requires a live ${platform} adapter that drives a real supported app/API path; ` +
128
+ `no adapter script was found or no live runtime is configured (for example ${expected}). Static placeholders are refused to avoid fabricated proof. ` +
129
+ 'Set METAMASK_RECIPE_LIVE_ADAPTER_DIR or add a runner library/actions script.',
130
+ );
131
+ }
132
+ const output = { platform, action, redacted: true };
133
+ if (action === 'metamask.wallet.fixture_status') return { output: fixtureSummary(context.projectRoot) };
134
+ return {
135
+ output: {
136
+ ...output,
137
+ requested: Object.fromEntries(Object.entries(node).filter(([key]) => key !== 'action')),
138
+ note: 'static semantic adapter; live proof must use a real supported app/API path',
139
+ },
140
+ };
141
+ }
142
+
143
+ function isRecord(value: unknown): value is Record<string, unknown> {
144
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
145
+ }
146
+
147
+ function optionalScalarText(value: unknown, label: string): string | undefined {
148
+ if (value === undefined || value === null) return undefined;
149
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
150
+ return String(value);
151
+ }
152
+ throw new Error(`${label} must be a string, number, or boolean.`);
153
+ }
154
+
155
+ function scalarText(value: unknown, label: string, fallback: string): string {
156
+ return optionalScalarText(value, label) ?? fallback;
157
+ }
158
+
159
+ function firstScalarText(
160
+ record: Record<string, unknown>,
161
+ keys: string[],
162
+ label: string,
163
+ fallback?: string,
164
+ ): string {
165
+ for (const key of keys) {
166
+ const value = optionalScalarText(record[key], `${label}.${key}`);
167
+ if (value !== undefined) return value;
168
+ }
169
+ if (fallback !== undefined) return fallback;
170
+ throw new Error(`${label} requires one of: ${keys.join(', ')}.`);
171
+ }
172
+
173
+ function traceText(value: unknown): string {
174
+ if (value === undefined || value === null) return '';
175
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
176
+ return String(value);
177
+ }
178
+ return JSON.stringify(value);
179
+ }
180
+
181
+ function probeHttpJson(url: string, timeoutMs = 1000): Promise<Record<string, unknown>> {
182
+ return new Promise((resolve) => {
183
+ const request = http.get(url, { timeout: timeoutMs }, (response) => {
184
+ let body = '';
185
+ response.setEncoding('utf8');
186
+ response.on('data', (chunk) => {
187
+ body += chunk;
188
+ });
189
+ response.on('end', () => {
190
+ if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
191
+ resolve({ reachable: false, statusCode: response.statusCode ?? null, error: `HTTP ${response.statusCode ?? 'unknown'}` });
192
+ return;
193
+ }
194
+ try {
195
+ resolve({ reachable: true, statusCode: response.statusCode, json: JSON.parse(body) });
196
+ } catch (error) {
197
+ resolve({
198
+ reachable: true,
199
+ statusCode: response.statusCode,
200
+ json: null,
201
+ parseError: error instanceof Error ? error.message : String(error),
202
+ });
203
+ }
204
+ });
205
+ });
206
+ request.on('timeout', () => {
207
+ request.destroy(new Error(`Timed out after ${timeoutMs}ms`));
208
+ });
209
+ request.on('error', (error) => {
210
+ resolve({ reachable: false, statusCode: null, error: error.message });
211
+ });
212
+ });
213
+ }
214
+
215
+ function createMetaMaskSemanticAdapters(platform: MetaMaskRecipeAdapter): ActionAdapter[] {
216
+ const walletActions = [
217
+ 'metamask.wallet.fixture_status',
218
+ 'metamask.wallet.setup',
219
+ 'metamask.wallet.ensure_unlocked',
220
+ 'metamask.wallet.select_account',
221
+ 'metamask.wallet.read_state',
222
+ ];
223
+ const actions = [
224
+ ...walletActions,
225
+ 'metamask.perps.read_positions',
226
+ 'metamask.perps.ensure_positions',
227
+ 'metamask.perps.assert_positions',
228
+ 'metamask.perps.place_order',
229
+ 'metamask.perps.close_positions',
230
+ 'metamask.perps.read_orders',
231
+ 'metamask.perps.close_orders',
232
+ 'metamask.perps.ensure_orders',
233
+ 'metamask.perps.assert_orders',
234
+ 'metamask.perps.start_state',
235
+ 'metamask.perps.teardown_state',
236
+ // read_account is core-only: only the headless core adapter implements it.
237
+ ...(platform === 'core' ? ['metamask.perps.read_account'] : []),
238
+ ];
239
+ return actions.map((action) =>
240
+ simpleAdapter(action, async (node, context) =>
241
+ semanticResult(platform, action, node, context),
242
+ ),
243
+ );
244
+ }
245
+
246
+ function uiInputFor(context: ActionExecutionContext, input: MetaMaskUiActionInput) {
247
+ return {
248
+ action: input.action,
249
+ node: input.node,
250
+ context: {
251
+ nodeId: context.nodeId,
252
+ projectRoot: context.projectRoot,
253
+ artifactsDir: context.artifactsDir,
254
+ env: context.env,
255
+ },
256
+ };
257
+ }
258
+
259
+
260
+ function mobileProbeOutput(status: unknown, input: MetaMaskUiActionInput, projectRoot: string) {
261
+ const entries = Array.isArray(status) ? status : [status];
262
+ const preferredDevices = [
263
+ input.node?.ios_simulator,
264
+ input.node?.simulator,
265
+ input.node?.android_device,
266
+ input.node?.adb_serial,
267
+ process.env.IOS_SIMULATOR,
268
+ process.env.ANDROID_DEVICE,
269
+ process.env.ADB_SERIAL,
270
+ ].filter((value) => typeof value === 'string' && value.length > 0);
271
+ const selected = entries.find((entry) => {
272
+ if (!isRecord(entry)) return false;
273
+ return typeof entry.deviceName === 'string' && preferredDevices.includes(entry.deviceName);
274
+ }) ?? entries.find((entry) => isRecord(entry) && isRecord(entry.route)) ?? null;
275
+ const route = isRecord(selected) && isRecord(selected.route) ? selected.route : null;
276
+ return {
277
+ reachable: Boolean(selected),
278
+ bridge: mobileBridgePath(projectRoot),
279
+ targetCount: entries.filter((entry) => isRecord(entry)).length,
280
+ deviceName: isRecord(selected) && typeof selected.deviceName === 'string' ? selected.deviceName : null,
281
+ routeName: route && typeof route.name === 'string' ? route.name : null,
282
+ accountPresent: isRecord(selected) && isRecord(selected.account),
283
+ };
284
+ }
285
+
286
+ function mobileBridgePath(_projectRoot: string): string {
287
+ return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT || 'runner:library/actions/mobile/bridge-runtime/cdp-bridge.cjs';
288
+ }
289
+
290
+ async function waitForMobileTarget(input: ReturnType<typeof uiInputFor>, payload: ActionNode) {
291
+ const timeoutMs = Number(payload.timeout_ms ?? payload.timeoutMs ?? 10000);
292
+ const deadline = Date.now() + timeoutMs;
293
+ const testId = firstScalarText(payload, ['test_id', 'testID'], 'Mobile ui.wait_for');
294
+ const expected = scalarText(payload.expected, 'ui.wait_for.expected', 'present').toLowerCase();
295
+ const expectedText = optionalScalarText(payload.text, 'ui.wait_for.text');
296
+ const textMatch = scalarText(payload.text_match ?? payload.textMatch, 'ui.wait_for.text_match', 'contains').toLowerCase();
297
+ const textExpression = `(function(){
298
+ const api = globalThis.__AGENTIC__;
299
+ if (!api?.getTextByTestId) return null;
300
+ return api.getTextByTestId(${JSON.stringify(testId)});
301
+ })()`;
302
+ const expression = `Boolean(globalThis.__AGENTIC__?.findFiberByTestId?.(${JSON.stringify(testId)}))`;
303
+ let lastValue: unknown = null;
304
+ let lastText: unknown = null;
305
+ const expectsAbsent = expected === 'absent' || expected === 'hidden' || expected === 'not_present';
306
+ while (Date.now() <= deadline) {
307
+ lastValue = await evalSync(input, expression);
308
+ const present = Boolean(lastValue);
309
+ if (expectsAbsent && !present) {
310
+ return { matched: true, testId, expected, present };
311
+ }
312
+ if (!expectsAbsent && present) {
313
+ if (!expectedText) {
314
+ return { matched: true, testId, expected, present };
315
+ }
316
+ lastText = await evalSync(input, textExpression);
317
+ const text = traceText(lastText);
318
+ const textMatched = textMatch === 'exact' ? text === expectedText : text.includes(expectedText);
319
+ if (textMatched) {
320
+ return { matched: true, testId, expected, present, text, textMatch };
321
+ }
322
+ }
323
+ await sleep(250);
324
+ }
325
+ const textReason = expectedText ? ` and text ${textMatch} ${JSON.stringify(expectedText)}; last text=${JSON.stringify(lastText)}` : '';
326
+ throw new Error(`Timed out waiting for mobile testID ${testId} to be ${expected}${textReason}; last present=${Boolean(lastValue)}.`);
327
+ }
328
+
329
+ interface MobileBridgeCommand {
330
+ command: string;
331
+ payload: ActionNode;
332
+ }
333
+
334
+ type MobileBridgeHandler = (
335
+ payload: ActionNode,
336
+ context: ActionExecutionContext,
337
+ ) => Promise<unknown>;
338
+
339
+ const MOBILE_BRIDGE_HANDLERS: Record<string, MobileBridgeHandler> = {
340
+ screenshot: handleMobileScreenshot,
341
+ status: handleMobileStatus,
342
+ navigate: handleMobileNavigate,
343
+ press: handleMobilePress,
344
+ setInput: handleMobileSetInput,
345
+ scroll: handleMobileScroll,
346
+ waitFor: handleMobileWaitFor,
347
+ hud: handleMobileHud,
348
+ };
349
+
350
+ function createMetaMaskMobileBridge() {
351
+ return {
352
+ async send(command: MobileBridgeCommand, context: ActionExecutionContext) {
353
+ const handler = MOBILE_BRIDGE_HANDLERS[command.command];
354
+ if (!handler) {
355
+ throw new Error(`React Native bridge command ${command.command} is not implemented by the MetaMask runner transport.`);
356
+ }
357
+ return handler(command.payload, context);
358
+ },
359
+ };
360
+ }
361
+
362
+ function mobileUiInput(context: ActionExecutionContext, command: string, payload: ActionNode) {
363
+ return uiInputFor(context, { action: `ui.${command}`, node: payload });
364
+ }
365
+
366
+ async function handleMobileScreenshot(payload: ActionNode, context: ActionExecutionContext) {
367
+ const input = mobileUiInput(context, 'screenshot', payload);
368
+ const relPath = scalarText(payload.path, 'ui.screenshot.path', `screenshots/${context.nodeId}.png`);
369
+ const artifact = await simulatorScreenshot(input, relPath);
370
+ context.registerArtifact(artifact);
371
+ return { captured: true, path: artifact.path, artifact };
372
+ }
373
+
374
+ async function handleMobileStatus(payload: ActionNode, context: ActionExecutionContext) {
375
+ return bridgeCommand(mobileUiInput(context, 'status', payload), ['status']);
376
+ }
377
+
378
+ async function handleMobileNavigate(payload: ActionNode, context: ActionExecutionContext) {
379
+ const live = await runLiveAdapterScript({
380
+ platform: 'mobile',
381
+ action: 'ui.navigate',
382
+ node: payload,
383
+ context,
384
+ });
385
+ if (!live) throw new Error('ui.navigate requires library/actions/mobile/ui/navigate.mjs.');
386
+ return isRecord(live.result) ? { ...live.result, liveAdapter: live.script } : live.result;
387
+ }
388
+
389
+ async function handleMobilePress(payload: ActionNode, context: ActionExecutionContext) {
390
+ const target = firstScalarText(payload, ['test_id', 'testID', 'selector', 'text'], 'ui.press');
391
+ return bridgeCommand(mobileUiInput(context, 'press', payload), ['press-test-id', target]);
392
+ }
393
+
394
+
395
+ async function handleMobileSetInput(payload: ActionNode, context: ActionExecutionContext) {
396
+ const input = mobileUiInput(context, 'set_input', payload);
397
+ const testId = firstScalarText(payload, ['test_id', 'testID'], 'ui.set_input');
398
+ const value = scalarText(payload.value ?? payload.text, 'ui.set_input.value', '');
399
+ const result = await bridgeCommand(input, ['set-input', testId, value]);
400
+ if (isRecord(result) && result.ok === false) {
401
+ throw new Error(`ui.set_input failed for mobile testID ${testId}: ${traceText(result.error ?? result)}`);
402
+ }
403
+ return isRecord(result) ? result : { result, testId, value };
404
+ }
405
+
406
+ async function handleMobileScroll(payload: ActionNode, context: ActionExecutionContext) {
407
+ const input = mobileUiInput(context, 'scroll', payload);
408
+ const testId = optionalScalarText(payload.test_id ?? payload.testID, 'ui.scroll.test_id');
409
+ const offset = scalarText(payload.offset ?? payload.delta_y ?? payload.deltaY, 'ui.scroll.offset', '600');
410
+ const args = testId
411
+ ? ['scroll-view', '--test-id', testId, '--offset', offset, animatedFlag(payload)]
412
+ : ['scroll-view', '--offset', offset, animatedFlag(payload)];
413
+ const result = await bridgeCommand(input, args);
414
+ return {
415
+ ...(isRecord(result) ? result : { result }),
416
+ intoView: payload.scroll_into_view === true || payload.into_view === true,
417
+ };
418
+ }
419
+
420
+ async function handleMobileWaitFor(payload: ActionNode, context: ActionExecutionContext) {
421
+ return waitForMobileTarget(mobileUiInput(context, 'waitFor', payload), payload);
422
+ }
423
+
424
+ async function handleMobileHud(payload: ActionNode, context: ActionExecutionContext) {
425
+ const input = mobileUiInput(context, 'hud', payload);
426
+ if (payload.clear === true) return bridgeCommand(input, ['hide-step']);
427
+ const hud = mobileHudPayload(payload, context);
428
+ const result = await bridgeCommand(input, ['show-step-json', JSON.stringify(hud.step)]);
429
+ return { hud: true, nodeId: hud.nodeId, status: hud.status, result };
430
+ }
431
+
432
+ function animatedFlag(payload: ActionNode): string {
433
+ return payload.animated === true ? '--animated' : '--no-animated';
434
+ }
435
+
436
+ function mobileHudPayload(payload: ActionNode, context: ActionExecutionContext) {
437
+ const nodeId = scalarText(payload.node_id ?? payload.nodeId, 'app.hud.node_id', context.nodeId);
438
+ const status = scalarText(payload.status, 'app.hud.status', 'running');
439
+ const text = optionalScalarText(payload.intent ?? payload.text ?? payload.detail, 'app.hud.intent');
440
+ if (!text || !text.trim()) {
441
+ throw new Error('app.hud requires intent/text/detail; automatic recipe progress should provide node intent.');
442
+ }
443
+ const detail = scalarText(payload.detail, 'app.hud.detail', '');
444
+ const error = scalarText(payload.error, 'app.hud.error', '');
445
+ const progress = mobileHudProgress(payload.progress);
446
+ const display = isRecord(payload.display) ? payload.display : {};
447
+ const showDebug = display.showDebug === true;
448
+ const showDetail = display.showDetail === true;
449
+ const proofTarget = payload.proofTarget ?? payload.proof_target;
450
+ const displayId = [mobileHudStatusLabel(status), progress.text].filter(Boolean).join(' ');
451
+ return {
452
+ nodeId,
453
+ status,
454
+ step: {
455
+ id: displayId || nodeId,
456
+ nodeId,
457
+ status,
458
+ intent: text,
459
+ progress: progress.value,
460
+ detail: showDetail && detail !== text ? detail : undefined,
461
+ error: error || undefined,
462
+ debug: showDebug
463
+ ? {
464
+ nodeId,
465
+ proofTarget,
466
+ }
467
+ : undefined,
468
+ },
469
+ };
470
+ }
471
+
472
+ function mobileHudProgress(progress: unknown): {
473
+ text: string;
474
+ value?: { current: number; total: number };
475
+ } {
476
+ if (!isRecord(progress)) return { text: '' };
477
+ if (typeof progress.current !== 'number' || typeof progress.total !== 'number') return { text: '' };
478
+ return {
479
+ text: `${progress.current}/${progress.total}`,
480
+ value: { current: progress.current, total: progress.total },
481
+ };
482
+ }
483
+
484
+ function mobileHudStatusLabel(status: string): string {
485
+ if (status === 'fail') return 'fail';
486
+ if (status === 'pass') return 'pass';
487
+ return 'run';
488
+ }
489
+
490
+ export function createMetaMaskUiTransport(
491
+ platform: MetaMaskRecipeAdapter,
492
+ harness: {
493
+ createReactNativeBridgeUiTransport: (options: unknown) => UiActionTransport;
494
+ createCdpWebUiTransport: (options: unknown) => UiActionTransport;
495
+ },
496
+ ): UiActionTransport {
497
+ if (platform === 'core') {
498
+ // Headless core has no UI surface. The core manifest declares no ui.* actions,
499
+ // so this transport is never exercised; reject explicitly if a recipe adds one.
500
+ return {
501
+ async execute(action) {
502
+ throw new Error(
503
+ `core adapter is headless and cannot execute UI action ${action}. ` +
504
+ 'Use a perps read action or run the recipe against the mobile/extension adapter.',
505
+ );
506
+ },
507
+ };
508
+ }
509
+ const base =
510
+ platform === 'mobile'
511
+ ? harness.createReactNativeBridgeUiTransport({ bridge: createMetaMaskMobileBridge() })
512
+ : harness.createCdpWebUiTransport({
513
+ async withPage(input: { action: string; node: ActionNode; context: ActionExecutionContext }, callback: (page: unknown) => Promise<unknown>) {
514
+ return withExtensionPage(
515
+ {
516
+ action: input.action,
517
+ node: input.node,
518
+ context: {
519
+ nodeId: input.context.nodeId,
520
+ projectRoot: input.context.projectRoot,
521
+ artifactsDir: input.context.artifactsDir,
522
+ },
523
+ },
524
+ callback,
525
+ );
526
+ },
527
+ });
528
+ return {
529
+ async execute(action, node, context) {
530
+ if (action === 'ui.navigate') {
531
+ const live = await runLiveFirst(platform, action, node, context);
532
+ if (live) return live.output;
533
+ throw new Error(`ui.navigate requires library/actions/${platform}/ui/navigate.mjs.`);
534
+ }
535
+ return base.execute(action, node, context);
536
+ },
537
+ };
538
+ }
539
+
540
+ export function createMetaMaskAdapters(adapter: MetaMaskRecipeAdapter): ActionAdapter[] {
541
+ const platform = adapter;
542
+ const adapters = [
543
+ simpleAdapter('app.status', async (_node, context) => ({
544
+ output: {
545
+ platform,
546
+ projectRoot: context.projectRoot,
547
+ compatibilityMode: compatibilityMode(adapter, context.projectRoot),
548
+ shape: repoShape(context.projectRoot),
549
+ },
550
+ })),
551
+ simpleAdapter('cdp.target', async (node, context) => {
552
+ const cdpPort = node.cdp_port ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT ?? null;
553
+ const metroPort = node.metro_port ?? node.watcher_port ?? process.env.WATCHER_PORT ?? null;
554
+ const timeoutMs = Number(node.probe_timeout_ms ?? node.cdp_timeout_ms ?? node.timeout_ms ?? process.env.CDP_TIMEOUT ?? 10000);
555
+ const required = node.require_reachable === true || node.required === true;
556
+ if (platform === 'mobile' && required) {
557
+ const uiInput: MetaMaskUiActionInput = { action: 'cdp.target', nodeId: context.nodeId, node };
558
+ const status = await bridgeCommand(uiInputFor(context, uiInput), ['status']);
559
+ const probe = mobileProbeOutput(status, uiInput, context.projectRoot);
560
+ if (!probe.reachable) {
561
+ throw new Error('cdp.target required a reachable mobile React Native bridge, but bridge status did not expose a selected app route.');
562
+ }
563
+ return {
564
+ output: {
565
+ platform,
566
+ transport: 'react-native-debug-bridge',
567
+ cdpPort,
568
+ metroPort,
569
+ timeoutMs,
570
+ ...probe,
571
+ },
572
+ };
573
+ }
574
+ const targetPort = platform === 'mobile' ? metroPort ?? cdpPort : cdpPort;
575
+ const url = targetProbeUrl(platform, targetPort);
576
+ const probe = url ? await probeHttpJson(url, timeoutMs) : { reachable: null, statusCode: null, error: 'no port declared' };
577
+ if (required && !probe.reachable) {
578
+ throw new Error(`cdp.target required a reachable ${platform} runtime at ${url ?? '<no port>'}: ${traceText(probe.error) || 'not reachable'}`);
579
+ }
580
+ return {
581
+ output: {
582
+ platform,
583
+ transport: platform === 'mobile' ? 'react-native-debug-bridge' : 'chrome-extension-cdp',
584
+ cdpPort,
585
+ metroPort,
586
+ probeUrl: url,
587
+ timeoutMs,
588
+ ...probe,
589
+ },
590
+ };
591
+ }),
592
+ ...createMetaMaskSemanticAdapters(platform),
593
+ ];
594
+ return adapters;
595
+ }
596
+
597
+ function targetProbeUrl(platform: MetaMaskRecipeAdapter, targetPort: unknown): string | null {
598
+ if (!targetPort) return null;
599
+ const pathSuffix = platform === 'mobile' ? 'json/list' : 'json/version';
600
+ return `http://127.0.0.1:${traceText(targetPort)}/${pathSuffix}`;
601
+ }