@deeeed/metamask-harness 0.89.0 → 0.91.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 (35) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/adapters/mobile/reload-app.mjs +10 -0
  3. package/dist/adapters/extension/reload.js +3 -3
  4. package/dist/adapters/extension/run.js +1 -6
  5. package/dist/adapters/mobile/android-screenrecord.js +28 -58
  6. package/dist/adapters/mobile/devices.js +2 -1
  7. package/dist/adapters/mobile/reload.js +3 -3
  8. package/dist/adapters/mobile/run.js +2 -3
  9. package/dist/checkout-view.js +10 -3
  10. package/dist/command-contract.js +2 -0
  11. package/dist/commands/check.js +18 -20
  12. package/dist/commands/device-target.js +1 -1
  13. package/dist/commands/doctor.js +31 -27
  14. package/dist/commands/logs.js +13 -30
  15. package/dist/doctor.js +26 -85
  16. package/dist/mm-harness-cli.js +1 -7
  17. package/dist/provider-doctor.js +36 -0
  18. package/dist/provider-lifecycle.js +26 -0
  19. package/dist/provider-logs.js +31 -0
  20. package/dist/provider.js +6 -1
  21. package/dist/runner.js +1 -0
  22. package/library/actions/core/perps/_controller.mjs +1 -19
  23. package/library/actions/extension/perps/perps.mjs +1 -11
  24. package/library/actions/extension/platform/cdp.mjs +3 -19
  25. package/library/actions/extension/ui/navigate.mjs +27 -2
  26. package/library/actions/mobile/perps/perps.mjs +1 -7
  27. package/library/actions/mobile/platform/observe-ui.mjs +45 -15
  28. package/library/actions/shared/perps/item-side.mjs +18 -0
  29. package/library/manifests/core.action-manifest.json +6 -1
  30. package/library/manifests/extension.action-manifest.json +8 -2
  31. package/library/manifests/mobile.action-manifest.json +13 -3
  32. package/library/manifests/web-dapp.action-manifest.json +38 -0
  33. package/library/recipes/extension/performance/navigation-memory-suite.recipe.json +30 -8
  34. package/library/recipes/extension/performance/navigation-memory.recipe.json +23 -1
  35. package/package.json +9 -9
package/dist/doctor.js CHANGED
@@ -1,11 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { RECIPE_CLI_VERSION } from "@farmslot/recipe-cli";
4
- import { color, harnessContext, harnessContextField, harnessExecutable, recipeHarnessRoot, recipeRuntimeDir, readRuntimeContextField, resolveRuntimeContextPath } from "@farmslot/recipe-cli/harness";
3
+ import { color, createDoctorReport as sharedDoctorReport, harnessContext, recipeHarnessRoot, recipeRuntimeDir, readRuntimeContextField, resolveRuntimeContextPath, runnerInstallKind as sharedInstallKind, runnerProvenance as sharedProvenance } from "@farmslot/recipe-cli/harness";
5
4
  import { normalizeFeatureFlagRecord } from "./feature-flags.js";
6
5
  import { getAdapterSurface } from "./adapters/surface.js";
7
6
  import { isPin } from "./harness-pin.js";
8
- import { readJson, runnerDir } from "./paths.js";
7
+ import { readJson } from "./paths.js";
9
8
  function requiredDoctorCheckSummary(checks) {
10
9
  const required = checks.filter((check) => check.required);
11
10
  const failed = required.filter((check) => check.status === "fail").map((check) => check.id);
@@ -139,99 +138,40 @@ function renderRuntimeContext(runtimeContext) {
139
138
  }
140
139
  return lines.join("\n");
141
140
  }
142
- function createDoctorReport(adapter, target, manifestValidation, actionManifestPath = getAdapterSurface(adapter).actions.manifestPath(), platform) {
143
- const readiness = getAdapterSurface(adapter).readiness;
144
- const mode = compatibilityMode(adapter, target);
145
- const manifestErrors = Number(manifestValidation.summary?.errors ?? 0);
146
- const checks = [
147
- {
148
- id: "manifest",
149
- status: manifestErrors === 0 ? "pass" : "fail",
150
- required: true,
151
- message: manifestErrors === 0 ? "Action manifest is valid Recipe v1." : `Action manifest has ${manifestErrors} validation error(s).`
152
- },
153
- {
141
+ const doctorReportOptions = {
142
+ provenance: { name: "@metamask/recipe-runner", pinned: isPin },
143
+ checks(target, adapter) {
144
+ const mode = compatibilityMode(adapter, target);
145
+ return [{
154
146
  id: "bridge",
155
147
  status: mode === "unsupported/no bridge" ? "fail" : "pass",
156
148
  required: false,
157
149
  message: mode === "unsupported/no bridge" ? `No ${adapter} bridge is available for this checkout.` : `${adapter} compatibility mode: ${mode}.`
158
- },
159
- ...readiness.checks?.(target, platform) ?? []
160
- ];
161
- const requiredChecks = requiredDoctorCheckSummary(checks);
162
- return {
163
- schemaVersion: 1,
164
- protocolVersion: "v1",
165
- runner_protocol_version: 1,
166
- status: requiredChecks.status,
167
- checks,
168
- requiredChecks,
169
- adapter,
170
- target,
171
- ...harnessContextField(),
172
- runner: runnerProvenance(actionManifestPath),
173
- compatibilityMode: mode,
150
+ }];
151
+ },
152
+ fields: (target, adapter, environment) => ({
153
+ compatibilityMode: compatibilityMode(adapter, target),
174
154
  shape: repoShape(target),
175
155
  fixture: fixtureSummary(target, adapter),
176
- environment: readiness.environment?.(target) ?? {},
177
- runtimeContext: runtimeContextSummary(target, adapter),
178
- manifestValidation: manifestValidation.summary
179
- };
156
+ environment,
157
+ runtimeContext: runtimeContextSummary(target, adapter)
158
+ })
159
+ };
160
+ function createDoctorReport(adapter, target, manifestValidation, actionManifestPath = getAdapterSurface(adapter).actions.manifestPath(), platform) {
161
+ const report = sharedDoctorReport(adapter, target, manifestValidation, actionManifestPath, platform, doctorReportOptions);
162
+ return { ...report, runner: metaMaskProvenance(report.runner), manifestValidation: manifestValidation.summary };
180
163
  }
181
164
  function runnerInstallKind(runnerRoot, invokedPath, executablePath) {
182
- if (isPin(runnerRoot)) return "pinned-install";
183
- const normalizedRoot = path.normalize(runnerRoot);
184
- const nodeModulesSegment = `${path.sep}node_modules${path.sep}`;
185
- const globalNodeModulesSegment = `${path.sep}lib${path.sep}node_modules${path.sep}`;
186
- if (normalizedRoot.includes(globalNodeModulesSegment)) return "global-install";
187
- const nodeModulesIndex = normalizedRoot.lastIndexOf(nodeModulesSegment);
188
- if (nodeModulesIndex >= 0) {
189
- const nodeModulesRoot = normalizedRoot.slice(
190
- 0,
191
- nodeModulesIndex + nodeModulesSegment.length - 1
192
- );
193
- const invoked = path.resolve(invokedPath);
194
- const projectBin = `${path.join(nodeModulesRoot, ".bin")}${path.sep}`;
195
- if (invoked === path.resolve(executablePath) || invoked.startsWith(projectBin)) {
196
- return "project-install";
197
- }
198
- return "global-install";
199
- }
200
- if (path.resolve(invokedPath) !== path.resolve(executablePath)) return "local-link";
201
- return "source-checkout";
165
+ return sharedInstallKind(runnerRoot, invokedPath, executablePath, isPin);
202
166
  }
203
167
  function runnerProvenance(actionManifestPath) {
204
- const packagePath = path.join(runnerDir, "package.json");
205
- const packageDocument = readJsonObject(packagePath);
206
- const packageName = packageDocument.name;
207
- const version = packageDocument.version;
208
- if (packageName !== "@deeeed/metamask-harness") {
209
- throw new Error(`Expected @deeeed/metamask-harness package name in ${packagePath}`);
210
- }
211
- if (typeof version !== "string" || !version) {
212
- throw new Error(`Expected package version in ${packagePath}`);
168
+ return metaMaskProvenance(sharedProvenance(actionManifestPath, doctorReportOptions.provenance));
169
+ }
170
+ function metaMaskProvenance(provenance) {
171
+ if (provenance.name !== "@metamask/recipe-runner" || provenance.packageName !== "@deeeed/metamask-harness") {
172
+ throw new Error("Doctor provenance does not identify the MetaMask provider.");
213
173
  }
214
- const executablePath = harnessExecutable();
215
- const invokedPath = path.resolve(process.env.MM_HARNESS_INVOKED_AS ?? executablePath);
216
- const installKind = runnerInstallKind(runnerDir, invokedPath, executablePath);
217
- const packageSource = isPin(runnerDir) ? "pin" : runnerDir.includes(`${path.sep}node_modules${path.sep}`) ? "node_modules" : "source-checkout";
218
- return {
219
- name: "@metamask/recipe-runner",
220
- packageName,
221
- version,
222
- runnerDir,
223
- packagePath,
224
- packageSource,
225
- installKind,
226
- linked: installKind === "local-link",
227
- global: installKind === "global-install",
228
- invokedPath,
229
- executablePath,
230
- actionManifestPath,
231
- harnessPackage: "@farmslot/recipe-runner",
232
- // The @farmslot/recipe-cli this harness runs on (`--version --verbose` prints it too).
233
- recipeCliVersion: RECIPE_CLI_VERSION
234
- };
174
+ return { ...provenance, name: provenance.name, packageName: provenance.packageName };
235
175
  }
236
176
  function readJsonObject(file) {
237
177
  const value = readJson(file);
@@ -243,6 +183,7 @@ function readJsonObject(file) {
243
183
  export {
244
184
  compatibilityMode,
245
185
  createDoctorReport,
186
+ doctorReportOptions,
246
187
  fixtureFileSummary,
247
188
  fixtureSummary,
248
189
  renderRuntimeContext,
@@ -20,7 +20,7 @@ import {
20
20
  parseArgs,
21
21
  targetPath
22
22
  } from "@farmslot/recipe-cli/harness";
23
- import { stopProjectCollector } from "../library/actions/shared/analytics/collector.mjs";
23
+ import { stopOptions } from "./provider-lifecycle.js";
24
24
  import { adoptPluginAdapter, afterAdapterLoad, configuredLibrarySources } from "./adapters/plugin-surface.js";
25
25
  import { withMainnetOrderAbort } from "./adapters/perps-mainnet-cleanup.js";
26
26
  import { adapterRegistry } from "./adapters/registry.js";
@@ -70,12 +70,6 @@ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "
70
70
  function hostVersion() {
71
71
  return JSON.parse(fs.readFileSync(path.join(harnessHost().packageRoot, "package.json"), "utf8")).version;
72
72
  }
73
- const stopOptions = {
74
- companions: async (target) => {
75
- const collector = await stopProjectCollector(target);
76
- return collector.stopped && collector.pid !== null ? [{ label: "analytics collector", pid: collector.pid }] : [];
77
- }
78
- };
79
73
  const targetDevice = (command, adapter, options) => applyDeviceTargeting(command, adapter, options, { gate: true, rerun: "" });
80
74
  const runOptions = {
81
75
  engine: metaMaskEngine,
@@ -0,0 +1,36 @@
1
+ import { doctorFixtureNextActions, repairDoctorFixture, renderSkillsView, renderUpdateStatus, skillsView } from "./commands/doctor.js";
2
+ import { farmslotReadyIndicator } from "./commands/farmslot-ready.js";
3
+ import { updateStatus } from "./commands/update.js";
4
+ import { doctorReportOptions, fixtureSummary } from "./doctor.js";
5
+ import { loadActionManifest, validateManifest } from "./manifest.js";
6
+ import { statusOptions } from "./provider-lifecycle.js";
7
+ import { ensureRuntimeContext } from "./runtime-context.js";
8
+ const doctorOptions = {
9
+ ...statusOptions,
10
+ manifest: async (adapter, override) => validateManifest(loadActionManifest(adapter, override)),
11
+ report: doctorReportOptions,
12
+ orphanDevServers: { field: "orphanMetros", label: "orphan Metro" },
13
+ advisory(target) {
14
+ const skills = skillsView(target);
15
+ const update = updateStatus();
16
+ return [
17
+ { key: "skills", value: skills, render: (out) => renderSkillsView(skills, out) },
18
+ { key: "update", value: update, render: (out) => renderUpdateStatus(update, out) }
19
+ ];
20
+ },
21
+ fix: {
22
+ ensureRuntimeContext,
23
+ repair: repairDoctorFixture,
24
+ nextActions: (failed, { adapter, target, scope }) => doctorFixtureNextActions(failed, fixtureSummary(target, adapter), scope),
25
+ userAction: (failed, retry) => failed.includes("wallet-fixture") ? `choose one wallet fixture source from nextActions, run it, then retry: ${retry}` : void 0
26
+ }
27
+ };
28
+ function configureProviderReadiness(runtime) {
29
+ Object.assign(runtime.readiness, {
30
+ readyIndicator: (devices) => farmslotReadyIndicator(runtime.id, [...devices])
31
+ });
32
+ }
33
+ export {
34
+ configureProviderReadiness,
35
+ doctorOptions
36
+ };
@@ -0,0 +1,26 @@
1
+ import { stopProjectCollector } from "../library/actions/shared/analytics/collector.mjs";
2
+ import { collectCheckoutView, renderCheckoutView } from "./checkout-view.js";
3
+ import { applyDeviceTargeting } from "./commands/device-target.js";
4
+ import { featureFlagReport } from "./feature-flags.js";
5
+ const statusOptions = {
6
+ checkoutView(target, adapter) {
7
+ const view = collectCheckoutView(target, adapter);
8
+ return { json: view, text: renderCheckoutView(view) };
9
+ },
10
+ featureFlags: { report: (overrides, detail) => featureFlagReport(overrides, detail) },
11
+ previewDevice: (command, adapter, options) => applyDeviceTargeting(command, adapter, options, {
12
+ gate: false,
13
+ rerun: "",
14
+ persistPin: false
15
+ })
16
+ };
17
+ const stopOptions = {
18
+ companions: async (target) => {
19
+ const collector = await stopProjectCollector(target);
20
+ return collector.stopped && collector.pid !== null ? [{ label: "analytics collector", pid: collector.pid }] : [];
21
+ }
22
+ };
23
+ export {
24
+ statusOptions,
25
+ stopOptions
26
+ };
@@ -0,0 +1,31 @@
1
+ import path from "node:path";
2
+ import { EXIT, spawnScriptStreaming } from "@farmslot/recipe-cli/harness";
3
+ import { runnerDir } from "./paths.js";
4
+ function createLogsOptions(surface) {
5
+ return {
6
+ async compact({ target, logFile, events, mode, signal }) {
7
+ const result = await spawnScriptStreaming(
8
+ process.execPath,
9
+ [path.join(runnerDir, "adapters/shared/log-tui.mjs"), "tail", "--log", logFile, "--events", events, "--follow", "--mode", mode],
10
+ target,
11
+ { signal, forward: "original", capture: false }
12
+ );
13
+ return result.status === 0 ? EXIT.ok : EXIT.runtime;
14
+ },
15
+ async window({ target, logFile, signal }) {
16
+ surface.resolveSlotPorts(target);
17
+ const port = process.env.WATCHER_PORT ?? process.env.METRO_PORT ?? "default";
18
+ const window = `${surface.devServer.label}-${port}`;
19
+ const result = await spawnScriptStreaming(
20
+ path.join(runnerDir, "adapters/shared/open-log-window.sh"),
21
+ ["--window", window, "--log", logFile, "--runtime-dir", path.dirname(logFile)],
22
+ target,
23
+ { signal, forward: "stderr", capture: false }
24
+ );
25
+ return { window, logFile, exitCode: result.status === 0 ? EXIT.ok : EXIT.runtime };
26
+ }
27
+ };
28
+ }
29
+ export {
30
+ createLogsOptions
31
+ };
package/dist/provider.js CHANGED
@@ -17,6 +17,9 @@ import { getAdapterSurface } from "./adapters/surface.js";
17
17
  import { applyDeviceTargeting } from "./commands/device-target.js";
18
18
  import { metaMaskEngine } from "./engine.js";
19
19
  import { providerHost } from "./host.js";
20
+ import { configureProviderReadiness, doctorOptions } from "./provider-doctor.js";
21
+ import { statusOptions, stopOptions } from "./provider-lifecycle.js";
22
+ import { createLogsOptions } from "./provider-logs.js";
20
23
  import { providerHost as providerHost2 } from "./host.js";
21
24
  import { PUBLIC_COMMANDS } from "./command-contract.js";
22
25
  async function createProvider(context) {
@@ -37,8 +40,9 @@ async function createProvider(context) {
37
40
  });
38
41
  await afterAdapterLoad();
39
42
  const runtime = getAdapterSurface(adapter);
43
+ configureProviderReadiness(runtime);
40
44
  const executes = isRecipeExecution(context.command, options);
41
- if (executes) {
45
+ if (executes || context.command === "reload") {
42
46
  applyRuntimeDirOption(options);
43
47
  runtime.resolveSlotPorts(context.target.value);
44
48
  const selected = applyDeviceTargeting(context.command, adapter, {
@@ -52,6 +56,7 @@ async function createProvider(context) {
52
56
  return {
53
57
  runtime,
54
58
  engine: metaMaskEngine,
59
+ commandOptions: { status: statusOptions, stop: stopOptions, doctor: doctorOptions, logs: createLogsOptions(runtime) },
55
60
  ...executes ? {
56
61
  async cancel() {
57
62
  await abortRunMainnetOrders(context.target.value);
package/dist/runner.js CHANGED
@@ -70,6 +70,7 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
70
70
  const core = createStandardCoreAdapters({ actions });
71
71
  const projectOwnedOfficialActions = /* @__PURE__ */ new Set(["app.status", "app.lifecycle"]);
72
72
  const ui = createStandardUiAdapters({
73
+ captureHudEvidence: options.captureHudEvidence,
73
74
  actions: actions.filter((action) => !projectOwnedOfficialActions.has(action)),
74
75
  transport: createMetaMaskUiTransport(adapter, {
75
76
  createCdpWebUiTransport,
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { pathToFileURL } from 'node:url';
7
7
 
8
8
  import { walletFixturePath } from '../../harness-exports.mjs';
9
+ import { normalizeSide, sideForItem } from '../../shared/perps/item-side.mjs';
9
10
  import { readFixtureAccounts } from '../../shared/wallet/fixture-accounts.mjs';
10
11
  import { createRewardsClient, rewardsApiUrl } from './_rewards.mjs';
11
12
 
@@ -597,25 +598,6 @@ export function symbolForItem(item) {
597
598
  return normalizeMarketSymbol(item?.symbol ?? item?.coin ?? '');
598
599
  }
599
600
 
600
- // Orders carry buy/sell sides; Position.size is signed and authoritative.
601
- function normalizeSide(rawSide) {
602
- const side = String(rawSide ?? '').toLowerCase();
603
- if (side === 'buy' || side === 'b') return 'long';
604
- if (side === 'sell' || side === 'a' || side === 's') return 'short';
605
- return side;
606
- }
607
-
608
- function sideForItem(item, itemType) {
609
- if (itemType === 'position') {
610
- const rawSize = item?.size ?? item?.szi;
611
- if (typeof rawSize !== 'string' && typeof rawSize !== 'number') return '';
612
- const size = Number(rawSize);
613
- if (!Number.isFinite(size) || size === 0) return '';
614
- return size > 0 ? 'long' : 'short';
615
- }
616
- return normalizeSide(item?.side ?? item?.direction ?? '');
617
- }
618
-
619
601
  function uniqueSymbols(symbols) {
620
602
  return Array.from(new Set(symbols.filter(Boolean)));
621
603
  }
@@ -1,6 +1,7 @@
1
1
  // Implement Extension Perps state reads, UI setup and funded trade operations.
2
2
  // Funded paths check runtime identity and retain mutation receipts for cleanup.
3
3
 
4
+ import { sideForItem } from '../../shared/perps/item-side.mjs';
4
5
  import { pathToFileURL } from 'node:url';
5
6
  import { createHash } from 'node:crypto';
6
7
  import { readFile, writeFile } from 'node:fs/promises';
@@ -66,17 +67,6 @@ function symbolForItem(item) {
66
67
  return normalizeMarketSymbol(item?.coin ?? item?.symbol ?? '');
67
68
  }
68
69
 
69
- function sideForItem(item) {
70
- const side = String(item?.side ?? item?.direction ?? '').toLowerCase();
71
- if (side === 'buy' || side === 'b') return 'long';
72
- if (side === 'sell' || side === 'a' || side === 's') return 'short';
73
- if (side) return side;
74
- // Positions carry no side, only a signed size (long > 0, short < 0).
75
- const size = Number(item?.size ?? item?.szi);
76
- if (Number.isFinite(size) && size !== 0) return size > 0 ? 'long' : 'short';
77
- return side;
78
- }
79
-
80
70
  function uniqueSymbols(symbols) {
81
71
  return Array.from(new Set(symbols.filter(Boolean)));
82
72
  }
@@ -20,7 +20,7 @@ import { spawn } from 'node:child_process';
20
20
  import { createRequire } from 'node:module';
21
21
  import path from 'node:path';
22
22
 
23
- import { captureActiveRecipeRecordingSnapshot, recipeHarnessPath } from '@farmslot/recipe-cli/harness';
23
+ import { recipeHarnessPath } from '@farmslot/recipe-cli/harness';
24
24
  import { importRecipeHarnessRuntimeBrowserExtension, importRecipeHarnessRuntimeCdp, extensionIdPath, walletFixturePath } from '../../harness-exports.mjs';
25
25
  import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
26
26
  import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
@@ -375,25 +375,9 @@ export async function captureHelperSnapshot(page, context, relPath, metadata) {
375
375
  try {
376
376
  pid = await captureHelperBrowserPid(context, page.port);
377
377
  const timeoutMs = Number(metadata?.timeoutMs ?? 30000);
378
- const sessionSnapshot = await captureActiveRecipeRecordingSnapshot(
379
- pid,
380
- destination.stagedPath,
381
- timeoutMs,
382
- );
383
- if (sessionSnapshot) {
378
+ if (context.captureRecordingSnapshot) {
384
379
  mode = 'record_session_snapshot';
385
- details = sessionSnapshot;
386
- } else if (
387
- parsePositivePid(process.env.METAMASK_RECIPE_EXTENSION_ACTIVE_RECORDING_PID) === pid
388
- ) {
389
- await cleanupEvidenceStaging(destination.stagingDir);
390
- return captureCdpSnapshot(
391
- page,
392
- context,
393
- relPath,
394
- metadata,
395
- 'native snapshot skipped while full-run recording owns the browser window',
396
- );
380
+ details = await context.captureRecordingSnapshot(path.relative(artifactsRoot, destination.stagedPath));
397
381
  } else {
398
382
  await reapOrphanedCaptureHelpers(context);
399
383
  const result = await runProcess(
@@ -46,7 +46,7 @@ function text(value) {
46
46
  function pageIntent(node) {
47
47
  const page = text(node?.page);
48
48
  if (!page) return undefined;
49
- if (page === 'back' || page === 'home' || page === 'perps' || page === 'swap') return { page };
49
+ if (page === 'back' || page === 'home' || page === 'perps' || page === 'swap' || page === 'settings') return { page };
50
50
  if (page === 'perps-market-list' || page === 'perps-activity') return { page };
51
51
  if (page === 'network') {
52
52
  const network = text(node.network);
@@ -61,7 +61,7 @@ function pageIntent(node) {
61
61
  if (!market) throw new Error('extension ui.navigate page=perps-market requires market or symbol.');
62
62
  return { page, market };
63
63
  }
64
- throw new Error('extension ui.navigate supported page intents: back, home, network, perps, perps-market-list, perps-market, perps-activity, swap.');
64
+ throw new Error('extension ui.navigate supported page intents: back, home, settings, network, perps, perps-market-list, perps-market, perps-activity, swap.');
65
65
  }
66
66
 
67
67
  async function goBack(page, timeoutMs) {
@@ -503,6 +503,27 @@ export async function openNetwork(page, network, chainId, addIfMissing, timeoutM
503
503
  throw new Error(`Network ${network} (${chainId}) did not become the selected wallet filter.`);
504
504
  }
505
505
 
506
+ export async function openSettings(page, timeoutMs) {
507
+ const destination = dataTestId('parent-selector-settings-page');
508
+ if (await hasVisibleSelector(page, destination)) {
509
+ return { method: 'visible-ui', href: await currentHref(page), steps: [] };
510
+ }
511
+ const deadline = Date.now() + timeoutMs;
512
+ const menu = dataTestId('account-options-menu-button');
513
+ const settings = dataTestId('global-menu-settings');
514
+ const steps = [];
515
+ if (!(await hasVisibleSelector(page, settings))) {
516
+ await page.waitFor({ selector: menu, expected: 'visible', timeoutMs: Math.max(1, deadline - Date.now()) });
517
+ await activateSemanticControl(page, menu);
518
+ steps.push(menu);
519
+ }
520
+ await page.waitFor({ selector: settings, expected: 'visible', timeoutMs: Math.max(1, deadline - Date.now()) });
521
+ const navigation = await activateSemanticControl(page, settings);
522
+ steps.push(settings);
523
+ await page.waitFor({ selector: destination, expected: 'visible', timeoutMs: Math.max(1, deadline - Date.now()) });
524
+ return { navigation, method: 'visible-ui', href: await currentHref(page), selector: settings, steps };
525
+ }
526
+
506
527
  export async function openSwap(page, timeoutMs) {
507
528
  const sourceAmount = dataTestId('from-amount');
508
529
  const sourceToken = dataTestId('bridge-source-button');
@@ -588,6 +609,10 @@ export async function navigateExtensionUi(input) {
588
609
  );
589
610
  return { action: input.action, ...intent, ...result, proofPath: 'ui-navigation' };
590
611
  }
612
+ if (intent.page === 'settings') {
613
+ const result = await openSettings(page, timeoutMs);
614
+ return { action: input.action, ...intent, ...result, proofPath: 'ui-navigation' };
615
+ }
591
616
  if (intent.page === 'swap') {
592
617
  const result = await openSwap(page, timeoutMs);
593
618
  return { action: input.action, ...intent, ...result, proofPath: 'ui-navigation' };
@@ -1,5 +1,6 @@
1
1
  // Implement Mobile Perps navigation, trading mutations, assertions, and setup or cleanup through the bridge.
2
2
  // Strategy getters used during cleanup can settle tracked collateral.
3
+ import { sideForItem } from '../../shared/perps/item-side.mjs';
3
4
  import { pathToFileURL } from 'node:url';
4
5
  import {
5
6
  MOBILE_BRIDGE_RESULT_ERROR_CODES,
@@ -300,13 +301,6 @@ function symbolForItem(item) {
300
301
  return normalizeMarketSymbol(item?.symbol ?? item?.coin ?? '');
301
302
  }
302
303
 
303
- function sideForItem(item) {
304
- const side = String(item?.side ?? item?.direction ?? '').toLowerCase();
305
- if (side === 'buy' || side === 'b') return 'long';
306
- if (side === 'sell' || side === 'a' || side === 's') return 'short';
307
- return side;
308
- }
309
-
310
304
  function configuredSymbols(input, items) {
311
305
  const selector = input.node?.selector && typeof input.node.selector === 'object' ? input.node.selector : {};
312
306
  const mode = String(input.node?.mode ?? selector.mode ?? 'matching').toLowerCase();
@@ -53,10 +53,29 @@ const IOS_TEXT_TEST_IDS = `(() => {
53
53
  })()`;
54
54
 
55
55
  export async function observeNativeUi(payload, context) {
56
- await hideAccessibilityMaskingHud(payload, context);
57
56
  const refs = Array.isArray(payload?.refs)
58
57
  ? payload.refs.filter((ref) => typeof ref === 'string')
59
58
  : [];
59
+ if (refs.length === 1 && refs[0] === 'ui.hud') {
60
+ // The HUD must stay visible for this capture-bound observation.
61
+ try {
62
+ const hierarchy = await readNativeHierarchy(payload, context, []);
63
+ const expected = String(payload?.node?.intent ?? '').replace(/\s+/gu, ' ').trim();
64
+ const intents = hierarchy.visible.items.flatMap((item) => {
65
+ const label = String(item.label ?? '').replace(/\s+/gu, ' ').trim();
66
+ const intent = hudLabelIntent(label);
67
+ return intent && item.visible !== false && item.bounds?.width > 0 && item.bounds?.height > 0 ? [intent] : [];
68
+ });
69
+ const intent = intents.find((value) => value === expected);
70
+ return { observations: { 'ui.hud': {
71
+ visible: Boolean(expected && intent && hierarchy.visible.truncated !== true),
72
+ intent: intent ?? '', provider: hierarchy.screen.provider,
73
+ } } };
74
+ } catch (error) {
75
+ return { warnings: [{ ref: 'ui.hud', message: error instanceof Error ? error.message : String(error) }] };
76
+ }
77
+ }
78
+ await hideAccessibilityMaskingHud(payload, context);
60
79
  const supported = refs.filter((ref) => ref === 'ui.screen' || ref === 'ui.visible');
61
80
  const warnings = refs
62
81
  .filter((ref) => !supported.includes(ref))
@@ -139,6 +158,10 @@ export async function hideAccessibilityMaskingHud(payload, context) {
139
158
  }
140
159
  }
141
160
 
161
+ function hudLabelIntent(label) {
162
+ return /^(?:RUN|PASS|FAIL|OK)(?: \d+\/\d+)?\s+(.+)$/u.exec(text(label) ?? '')?.[1];
163
+ }
164
+
142
165
  async function readNativeHierarchy(payload, context, textTestIds) {
143
166
  const node = record(payload?.node);
144
167
  const env = {
@@ -149,23 +172,24 @@ async function readNativeHierarchy(payload, context, textTestIds) {
149
172
  env.METAMASK_RECIPE_PROJECT_ROOT = String(context.projectRoot);
150
173
  }
151
174
  const platform = resolvePlatform(node, env);
175
+ const hudIntent = payload?.refs?.length === 1 && payload.refs[0] === 'ui.hud' ? text(node.intent) : undefined;
152
176
  const appLabel = nativeAppLabel(env);
153
177
  const hierarchy = platform === 'android'
154
- ? await readAndroidHierarchy(node, env)
155
- : await readIosHierarchy(node, env, textTestIds);
178
+ ? await readAndroidHierarchy(node, env, hudIntent, context?.signal)
179
+ : await readIosHierarchy(node, env, textTestIds, hudIntent, context?.signal);
156
180
  // No screen name and no labelled item: name the screen after the app.
157
181
  hierarchy.screen.name ??= appLabel;
158
182
  return hierarchy;
159
183
  }
160
184
 
161
- async function readIosHierarchy(node, env, textTestIds) {
185
+ async function readIosHierarchy(node, env, textTestIds, hudIntent, signal) {
162
186
  const idbPath = resolveMobileToolPath('idb');
163
187
  if (!idbPath) {
164
188
  throw new Error(
165
189
  `iOS UI observation requires the idb client.\n Next: ${mobileToolRecovery('idb')}`,
166
190
  );
167
191
  }
168
- const udid = await resolveIosUdid(node, env);
192
+ const udid = await resolveIosUdid(node, env, signal);
169
193
  const { stdout } = await execFileAsync(
170
194
  idbPath,
171
195
  ['ui', 'describe-all', '--udid', udid],
@@ -174,10 +198,11 @@ async function readIosHierarchy(node, env, textTestIds) {
174
198
  timeout: 15_000,
175
199
  maxBuffer: 16 * 1024 * 1024,
176
200
  env,
201
+ signal,
177
202
  },
178
203
  );
179
204
  const parsed = parseJsonOutput(stdout);
180
- const hierarchy = flattenIosHierarchy(parsed, textTestIds);
205
+ const hierarchy = flattenIosHierarchy(parsed, textTestIds, hudIntent);
181
206
  if (hierarchy.nodeCount === 0) {
182
207
  throw new Error('idb returned an empty accessibility hierarchy.');
183
208
  }
@@ -191,15 +216,16 @@ async function readIosHierarchy(node, env, textTestIds) {
191
216
  );
192
217
  }
193
218
 
194
- async function readAndroidHierarchy(node, env) {
219
+ async function readAndroidHierarchy(node, env, hudIntent, signal) {
195
220
  const adbPath = resolveMobileToolPath('adb');
196
221
  if (!adbPath) {
197
222
  throw new Error(
198
223
  `Android UI observation requires Android SDK Platform-Tools.\n Next: ${mobileToolRecovery('adb')}`,
199
224
  );
200
225
  }
201
- const serial = await resolveAndroidSerial(adbPath, node, env);
226
+ const serial = await resolveAndroidSerial(adbPath, node, env, signal);
202
227
  if (isOpaqueRuntime(env)) {
228
+ if (hudIntent) throw new Error('An opaque runtime has no observable HUD.');
203
229
  return readAndroidAgentDeviceHierarchy(serial, env);
204
230
  }
205
231
  const { stdout } = await execFileAsync(
@@ -210,10 +236,12 @@ async function readAndroidHierarchy(node, env) {
210
236
  timeout: 15_000,
211
237
  maxBuffer: 16 * 1024 * 1024,
212
238
  env,
239
+ signal,
213
240
  },
214
241
  );
215
- const hierarchy = parseAndroidHierarchy(stdout);
242
+ const hierarchy = parseAndroidHierarchy(stdout, hudIntent);
216
243
  if (hierarchy.nodeCount === 0) {
244
+ if (hudIntent) throw new Error('The native HUD hierarchy is empty.');
217
245
  return readAndroidAgentDeviceHierarchy(serial, env);
218
246
  }
219
247
  return normalizedHierarchy(
@@ -338,7 +366,7 @@ function normalizedHierarchy(
338
366
  };
339
367
  }
340
368
 
341
- async function resolveIosUdid(node, env) {
369
+ async function resolveIosUdid(node, env, signal) {
342
370
  const requested = text(node.simulator) ?? text(node.ios_simulator) ??
343
371
  text(env.SIM_UDID) ?? text(env.IOS_SIMULATOR) ?? 'booted';
344
372
  if (/^[0-9a-f-]{36}$/iu.test(requested)) return requested;
@@ -350,6 +378,7 @@ async function resolveIosUdid(node, env) {
350
378
  timeout: 5_000,
351
379
  maxBuffer: 4 * 1024 * 1024,
352
380
  env,
381
+ signal,
353
382
  },
354
383
  );
355
384
  const parsed = JSON.parse(stdout);
@@ -368,7 +397,7 @@ async function resolveIosUdid(node, env) {
368
397
  return udid;
369
398
  }
370
399
 
371
- async function resolveAndroidSerial(adbPath, node, env) {
400
+ async function resolveAndroidSerial(adbPath, node, env, signal) {
372
401
  const explicit = text(node.adb_serial) ?? text(node.android_serial) ??
373
402
  text(env.ADB_SERIAL) ?? text(env.ANDROID_SERIAL);
374
403
  if (explicit) return explicit;
@@ -377,6 +406,7 @@ async function resolveAndroidSerial(adbPath, node, env) {
377
406
  timeout: 5_000,
378
407
  maxBuffer: 4 * 1024 * 1024,
379
408
  env,
409
+ signal,
380
410
  });
381
411
  const serials = stdout
382
412
  .split(/\r?\n/u)
@@ -418,7 +448,7 @@ function parseJsonOutput(output) {
418
448
  }
419
449
  }
420
450
 
421
- function flattenIosHierarchy(value, textTestIds = []) {
451
+ function flattenIosHierarchy(value, textTestIds = [], hudIntent) {
422
452
  const texts = (Array.isArray(textTestIds) ? textTestIds : []).map((entry) => ({
423
453
  testId: text(entry?.testId),
424
454
  label: text(entry?.label),
@@ -449,7 +479,7 @@ function flattenIosHierarchy(value, textTestIds = []) {
449
479
  screenName ??= label;
450
480
  screenBounds ??= bounds;
451
481
  }
452
- if (isActionableRole(role) || testId) {
482
+ if (isActionableRole(role) || testId || (hudIntent && hudLabelIntent(label) === hudIntent)) {
453
483
  const normalized = compactItem({
454
484
  role,
455
485
  label,
@@ -473,7 +503,7 @@ function flattenIosHierarchy(value, textTestIds = []) {
473
503
  return { visible, hidden, nodeCount, screenName };
474
504
  }
475
505
 
476
- function parseAndroidHierarchy(output) {
506
+ function parseAndroidHierarchy(output, hudIntent) {
477
507
  const xmlStart = Math.max(output.indexOf('<?xml'), output.indexOf('<hierarchy'));
478
508
  const xml = xmlStart >= 0 ? output.slice(xmlStart) : output;
479
509
  const visible = [];
@@ -514,7 +544,7 @@ function parseAndroidHierarchy(output) {
514
544
  isActionableRole(role) ||
515
545
  attributes.clickable === 'true' ||
516
546
  attributes['long-clickable'] === 'true';
517
- if (isActionable || item.test_id) {
547
+ if (isActionable || item.test_id || (hudIntent && hudLabelIntent(label) === hudIntent)) {
518
548
  if (isVisible) {
519
549
  visible.push(item);
520
550
  } else {