@deeeed/metamask-harness 0.36.0 → 0.38.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 (140) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/adapters/extension/artifact-runtime-state.cjs +138 -0
  3. package/adapters/extension/inject.mjs +2 -0
  4. package/adapters/extension/launch-browser.cjs +24 -0
  5. package/adapters/extension/live.sh +82 -12
  6. package/adapters/extension/readiness.mjs +77 -36
  7. package/adapters/extension/snapshot-dist.sh +88 -3
  8. package/adapters/extension/verify.sh +6 -2
  9. package/adapters/manifest.json +17 -1
  10. package/adapters/shared/log-tui.mjs +1 -1
  11. package/dist/adapters/extension/artifact-integrity.js +38 -0
  12. package/dist/adapters/extension/extension-id.js +23 -4
  13. package/dist/adapters/extension/release-artifact.js +386 -0
  14. package/dist/adapters/extension/runtime-decision.js +164 -20
  15. package/dist/adapters/extension/runtime.js +169 -3
  16. package/dist/adapters/mobile/provision.js +38 -3
  17. package/dist/adapters/mobile/release-artifact-state.js +124 -0
  18. package/dist/adapters/mobile/release-artifact.js +295 -0
  19. package/dist/adapters.js +78 -6
  20. package/dist/app-lifecycle.js +4 -3
  21. package/dist/command-contract.js +16 -0
  22. package/dist/commands/call.js +6 -2
  23. package/dist/commands/launch/extension.js +74 -9
  24. package/dist/commands/launch/index.js +3 -0
  25. package/dist/commands/launch/mobile.js +2 -0
  26. package/dist/commands/provision.js +2 -0
  27. package/dist/commands/run-engine.js +109 -14
  28. package/dist/commands/run.js +4 -2
  29. package/dist/commands/runtime-launch.js +178 -10
  30. package/dist/heal-bounds.js +1 -1
  31. package/dist/live-adapter-contract.js +3 -1
  32. package/dist/metamask-action-validation.js +47 -1
  33. package/dist/mm-harness-cli.js +31 -1
  34. package/dist/recipe-security.js +40 -0
  35. package/dist/run-diagnostics.js +1 -1
  36. package/dist/runner.js +3 -0
  37. package/docs/RELEASE-QA-CAPABILITY-MAP.md +125 -0
  38. package/library/actions/extension/assets/finish_send.mjs +128 -0
  39. package/library/actions/extension/assets/import_custom_token.mjs +227 -0
  40. package/library/actions/extension/assets/prepare_send.mjs +53 -0
  41. package/library/actions/extension/assets/read_details.mjs +57 -0
  42. package/library/actions/extension/assets/read_visible_state.mjs +50 -0
  43. package/library/actions/extension/assets/review_send.mjs +23 -0
  44. package/library/actions/extension/assets/select_network_scope.mjs +30 -0
  45. package/library/actions/extension/assets/send.mjs +104 -0
  46. package/library/actions/extension/assets/set_custom_gas.mjs +56 -0
  47. package/library/actions/extension/assets/set_token_visibility.mjs +129 -0
  48. package/library/actions/extension/assets/verify_sorting.mjs +89 -0
  49. package/library/actions/extension/networks/add_chainlist.mjs +94 -0
  50. package/library/actions/extension/networks/add_custom.mjs +123 -0
  51. package/library/actions/extension/networks/custom_network.mjs +103 -0
  52. package/library/actions/extension/networks/read_visible_state.mjs +50 -0
  53. package/library/actions/extension/networks/remove_custom.mjs +68 -0
  54. package/library/actions/extension/perps/close_orders.mjs +1 -0
  55. package/library/actions/extension/perps/close_visible_position.mjs +133 -0
  56. package/library/actions/extension/perps/compare_provider_market.mjs +109 -0
  57. package/library/actions/extension/perps/edit_margin.mjs +82 -0
  58. package/library/actions/extension/perps/open_balance_action.mjs +58 -0
  59. package/library/actions/extension/perps/open_position_action.mjs +80 -0
  60. package/library/actions/extension/perps/perps.mjs +196 -2
  61. package/library/actions/extension/perps/place_order.mjs +5 -2
  62. package/library/actions/extension/perps/read_eligibility.mjs +34 -0
  63. package/library/actions/extension/perps/read_funds_confirmation.mjs +69 -0
  64. package/library/actions/extension/perps/read_snapshot.mjs +470 -0
  65. package/library/actions/extension/perps/read_visible_state.mjs +98 -0
  66. package/library/actions/extension/perps/select_activity_filter.mjs +44 -0
  67. package/library/actions/extension/perps/select_market_filter.mjs +71 -0
  68. package/library/actions/extension/perps/set_market_favorite.mjs +47 -0
  69. package/library/actions/extension/perps/update_position_tpsl.mjs +106 -0
  70. package/library/actions/extension/platform/cdp.mjs +109 -3
  71. package/library/actions/extension/settings/set_basic_functionality.mjs +58 -0
  72. package/library/actions/extension/swap_bridge/read_visible_state.mjs +71 -0
  73. package/library/actions/extension/swap_bridge/select_assets.mjs +103 -0
  74. package/library/actions/extension/swap_bridge/set_amount.mjs +36 -0
  75. package/library/actions/extension/swap_bridge/set_max_amount.mjs +58 -0
  76. package/library/actions/extension/swap_bridge/set_slippage.mjs +121 -0
  77. package/library/actions/extension/swap_bridge/submit_transaction.mjs +363 -0
  78. package/library/actions/extension/ui/activate_and_observe.mjs +96 -0
  79. package/library/actions/extension/ui/navigate.mjs +264 -8
  80. package/library/actions/extension/wallet/ensure_unlocked.mjs +2 -6
  81. package/library/actions/extension/wallet/import.mjs +13 -46
  82. package/library/actions/extension/wallet/lock.mjs +35 -0
  83. package/library/actions/extension/wallet/secret-input.mjs +98 -0
  84. package/library/actions/extension/wallet/select_account.mjs +71 -7
  85. package/library/actions/mobile/assets/read_visible_state.mjs +37 -0
  86. package/library/actions/mobile/perps/read_visible_state.mjs +31 -0
  87. package/library/actions/mobile/platform/native-session-name.mjs +5 -0
  88. package/library/actions/mobile/platform/observe-ui.mjs +86 -2
  89. package/library/actions/mobile/swap_bridge/native-session.mjs +45 -0
  90. package/library/actions/mobile/swap_bridge/read_visible_state.mjs +31 -0
  91. package/library/actions/mobile/swap_bridge/select_assets.mjs +94 -0
  92. package/library/actions/mobile/swap_bridge/set_amount.mjs +73 -0
  93. package/library/actions/mobile/ui/native-navigation.mjs +265 -0
  94. package/library/actions/mobile/ui/navigate.mjs +10 -1
  95. package/library/actions/mobile/wallet/ensure_unlocked.mjs +17 -0
  96. package/library/actions/mobile/wallet/import.mjs +71 -2
  97. package/library/actions/mobile/wallet/native-ui.mjs +512 -0
  98. package/library/actions/mobile/wallet/read_state.mjs +16 -0
  99. package/library/actions/mobile/wallet/reset.mjs +17 -4
  100. package/library/actions/shared/assets/visible-state.mjs +176 -0
  101. package/library/actions/shared/networks/visible-state.mjs +70 -0
  102. package/library/actions/shared/perps/visible-state.mjs +354 -0
  103. package/library/actions/shared/swap-bridge/transaction.mjs +54 -0
  104. package/library/actions/shared/swap-bridge/visible-state.mjs +97 -0
  105. package/library/actions/shared/ui/locators.mjs +7 -0
  106. package/library/manifests/extension.action-manifest.json +1366 -184
  107. package/library/manifests/mobile.action-manifest.json +235 -1
  108. package/library/recipes/assets/release-custom-gas-send.recipe.json +88 -0
  109. package/library/recipes/assets/release-custom-network-token-persistence.recipe.json +106 -0
  110. package/library/recipes/assets/release-native-max-cancel.recipe.json +70 -0
  111. package/library/recipes/assets/release-native-max-send.recipe.json +78 -0
  112. package/library/recipes/assets/release-own-account-send.recipe.json +80 -0
  113. package/library/recipes/assets/release-token-address-import.recipe.json +47 -0
  114. package/library/recipes/assets/release-token-details.recipe.json +48 -0
  115. package/library/recipes/assets/release-token-hide-readd.recipe.json +55 -0
  116. package/library/recipes/assets/release-token-list.recipe.json +75 -0
  117. package/library/recipes/assets/release-token-persistence.recipe.json +73 -0
  118. package/library/recipes/assets/release-token-search-import.recipe.json +40 -0
  119. package/library/recipes/assets/release-token-sorting.recipe.json +29 -0
  120. package/library/recipes/extension/networks/release-balance-stability.recipe.json +51 -0
  121. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +104 -0
  122. package/library/recipes/extension/perps/release-order-entry.recipe.json +155 -0
  123. package/library/recipes/extension/perps/release-order-validation.recipe.json +220 -0
  124. package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
  125. package/library/recipes/perps/release-activity-filters.recipe.json +51 -0
  126. package/library/recipes/perps/release-funds-flows.recipe.json +76 -0
  127. package/library/recipes/perps/release-live-market-order.recipe.json +122 -0
  128. package/library/recipes/perps/release-market-details.recipe.json +83 -0
  129. package/library/recipes/perps/release-market-search.recipe.json +79 -0
  130. package/library/recipes/perps/release-watchlist.recipe.json +99 -0
  131. package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +90 -0
  132. package/library/recipes/swap-bridge/release-live-max-swap.recipe.json +99 -0
  133. package/library/recipes/swap-bridge/release-live-swap.recipe.json +101 -0
  134. package/library/recipes/swap-bridge/release-max-quote.recipe.json +72 -0
  135. package/library/recipes/swap-bridge/release-quote.recipe.json +72 -0
  136. package/library/recipes/swap-bridge/release-surface.recipe.json +43 -0
  137. package/library/recipes/wallet/import.recipe.json +20 -1
  138. package/library/recipes/wallet/reset-import.recipe.json +20 -1
  139. package/package.json +6 -5
  140. package/scripts/validate-release-report.mjs +319 -0
@@ -83,11 +83,15 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
83
83
  (target2) => target2.type === "page" && String(target2.url ?? "").startsWith("chrome-extension://") && String(target2.url ?? "").includes("/sidepanel.html") && Boolean(target2.webSocketDebuggerUrl)
84
84
  );
85
85
  const extensionTargets = pageMode === "home-or-sidepanel" && homeTargets.length === 0 ? sidepanelTargets : homeTargets;
86
- if (extensionTargets.length !== 1) {
87
- const expected = pageMode === "home-or-sidepanel" ? "exactly one MetaMask extension home page target, or one sidepanel target when home is intentionally closed" : "exactly one MetaMask extension home page target";
86
+ if (extensionTargets.length === 0) {
87
+ const expected = pageMode === "home-or-sidepanel" ? "a MetaMask extension home page target, or one sidepanel target when home is intentionally closed" : "a MetaMask extension home page target";
88
88
  findings.push(`Expected ${expected}, found ${extensionTargets.length}.`);
89
89
  }
90
- const target = extensionTargets[0];
90
+ const targetSelection = await selectResponsiveExtensionTarget(extensionTargets, cdpCallTimeoutMs);
91
+ const target = targetSelection.target;
92
+ if (extensionTargets.length > 1) {
93
+ warnings.push(`Found ${extensionTargets.length} MetaMask Extension page targets; health selected ${target?.url ?? "none"} after probing each candidate.`);
94
+ }
91
95
  if (!target?.webSocketDebuggerUrl) {
92
96
  return failReport(cdpPort, findings, {
93
97
  targetUrls: targets.map((entry) => entry.url).filter(Boolean)
@@ -147,6 +151,12 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
147
151
  };
148
152
  }
149
153
  const runtime = await evaluateHealth(session, cdpCallTimeoutMs);
154
+ if (runtime.hasSubmitRequest !== true) {
155
+ const providerProbe = await probeUiEthereumProvider(session, cdpCallTimeoutMs);
156
+ runtime.evmRpcProbeOk = providerProbe.ok;
157
+ runtime.evmRpcProbeError = providerProbe.error;
158
+ runtime.evmRpcProbeSource = providerProbe.source;
159
+ }
150
160
  if (runtime.href && !String(runtime.href).startsWith("chrome-extension://")) {
151
161
  findings.push(`Extension page href is not an extension URL: ${runtime.href}`);
152
162
  }
@@ -199,6 +209,41 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
199
209
  session?.close();
200
210
  }
201
211
  }
212
+ async function selectResponsiveExtensionTarget(targets, timeoutMs) {
213
+ if (targets.length <= 1) return { target: targets[0] };
214
+ const probeTimeoutMs = Math.min(1e3, timeoutMs);
215
+ const ranked = [];
216
+ for (const [index, target] of targets.entries()) {
217
+ let session;
218
+ try {
219
+ session = await CdpSession.connect(target.webSocketDebuggerUrl, { timeoutMs: probeTimeoutMs });
220
+ await boundedCdpCall(session, "Runtime.enable", {}, probeTimeoutMs);
221
+ await boundedCdpCall(session, "Page.enable", {}, probeTimeoutMs);
222
+ const compositor = await probeCdpCompositorInteractivity(session, probeTimeoutMs);
223
+ const result = await boundedCdpCall(session, "Runtime.evaluate", {
224
+ expression: `(() => ({
225
+ readyState: String(document.readyState || ''),
226
+ href: String(location.href || ''),
227
+ text: String(document.body?.innerText || '').slice(0, 5000),
228
+ }))()`,
229
+ returnByValue: true
230
+ }, probeTimeoutMs);
231
+ const value = result.result?.value ?? {};
232
+ const text = String(value.text ?? "");
233
+ const href = String(value.href ?? target.url ?? "");
234
+ const blocked = text.includes("Background connection unresponsive") || text.includes("MetaMask had trouble starting") || text.includes("Unable to connect to Ethereum");
235
+ const onboarding = href.includes("/onboarding/");
236
+ const score = (compositor.status === "ready" ? 100 : compositor.status === "not-interactive" ? 10 : 0) + (value.readyState === "complete" ? 10 : 0) + (blocked ? 0 : 5) + (onboarding ? 0 : 2);
237
+ ranked.push({ target, score, index });
238
+ } catch {
239
+ ranked.push({ target, score: -1, index });
240
+ } finally {
241
+ session?.close();
242
+ }
243
+ }
244
+ ranked.sort((left, right) => right.score - left.score || left.index - right.index);
245
+ return { target: ranked[0]?.target };
246
+ }
202
247
  function failReport(cdpPort, findings, details, extensionPageTargets = 0) {
203
248
  return {
204
249
  status: "FAIL",
@@ -316,6 +361,127 @@ async function evaluateHealth(session, timeoutMs) {
316
361
  }
317
362
  return result.result?.value ?? {};
318
363
  }
364
+ async function probeUiEthereumProvider(session, timeoutMs) {
365
+ const bridgeKey = "mm-harness.runtime.evm-provider-probe";
366
+ const preload = await boundedCdpCall(
367
+ session,
368
+ "Page.addScriptToEvaluateOnNewDocument",
369
+ {
370
+ source: `(() => {
371
+ const bridgeKey = Symbol.for(${JSON.stringify(bridgeKey)});
372
+ let provider;
373
+ Object.defineProperty(globalThis, 'ethereumProvider', {
374
+ configurable: true,
375
+ get() { return provider; },
376
+ set(value) { provider = value; },
377
+ });
378
+ Object.defineProperty(globalThis, bridgeKey, {
379
+ configurable: true,
380
+ value: {
381
+ async probe() {
382
+ if (!provider || typeof provider.request !== 'function') {
383
+ return { ok: false, error: 'Ethereum provider is unavailable' };
384
+ }
385
+ try {
386
+ const code = await provider.request({
387
+ method: 'eth_getCode',
388
+ params: ['0x0000000000000000000000000000000000000000', 'latest'],
389
+ });
390
+ const ok = typeof code === 'string' && /^0x[0-9a-f]*$/iu.test(code);
391
+ return {
392
+ ok,
393
+ error: ok ? null : 'Ethereum provider returned invalid contract code',
394
+ };
395
+ } catch (error) {
396
+ return { ok: false, error: String(error?.message || error) };
397
+ }
398
+ },
399
+ cleanup() {
400
+ const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'ethereumProvider');
401
+ if (descriptor?.configurable === true) {
402
+ Object.defineProperty(globalThis, 'ethereumProvider', {
403
+ configurable: true,
404
+ writable: true,
405
+ value: provider,
406
+ });
407
+ }
408
+ return delete globalThis[bridgeKey];
409
+ },
410
+ },
411
+ });
412
+ })();`
413
+ },
414
+ timeoutMs
415
+ );
416
+ if (typeof preload?.identifier !== "string" || preload.identifier.length === 0) {
417
+ return {
418
+ ok: false,
419
+ error: "Ethereum provider preload was not installed",
420
+ source: "ui-ethereum-provider"
421
+ };
422
+ }
423
+ let outcome = {
424
+ ok: false,
425
+ error: "Ethereum provider is unavailable",
426
+ source: "ui-ethereum-provider"
427
+ };
428
+ const cleanupErrors = [];
429
+ try {
430
+ await boundedCdpCall(session, "Page.reload", { ignoreCache: false }, timeoutMs);
431
+ const deadline = Date.now() + timeoutMs;
432
+ while (Date.now() <= deadline) {
433
+ try {
434
+ const result = await boundedCdpCall(session, "Runtime.evaluate", {
435
+ expression: `globalThis[Symbol.for(${JSON.stringify(bridgeKey)})]?.probe()`,
436
+ awaitPromise: true,
437
+ returnByValue: true
438
+ }, Math.max(1, deadline - Date.now()));
439
+ const value = result.result?.value;
440
+ if (value?.ok === true) {
441
+ outcome = { ok: true, error: null, source: "ui-ethereum-provider" };
442
+ break;
443
+ }
444
+ if (typeof value?.error === "string") outcome.error = value.error;
445
+ } catch (error) {
446
+ outcome.error = messageOf(error);
447
+ }
448
+ if (outcome.ok) break;
449
+ await sleep(100);
450
+ }
451
+ } catch (error) {
452
+ outcome = { ok: false, error: messageOf(error), source: "ui-ethereum-provider" };
453
+ } finally {
454
+ try {
455
+ const cleanup = await boundedCdpCall(session, "Runtime.evaluate", {
456
+ expression: `(() => {
457
+ const key = Symbol.for(${JSON.stringify(bridgeKey)});
458
+ const bridge = globalThis[key];
459
+ if (!bridge) return true;
460
+ return bridge.cleanup() === true && !(key in globalThis);
461
+ })()`,
462
+ returnByValue: true
463
+ }, timeoutMs);
464
+ if (cleanup.result?.value !== true) cleanupErrors.push("page bridge removal was not confirmed");
465
+ } catch (error) {
466
+ cleanupErrors.push(messageOf(error));
467
+ }
468
+ try {
469
+ await boundedCdpCall(
470
+ session,
471
+ "Page.removeScriptToEvaluateOnNewDocument",
472
+ { identifier: preload.identifier },
473
+ timeoutMs
474
+ );
475
+ } catch (error) {
476
+ cleanupErrors.push(messageOf(error));
477
+ }
478
+ }
479
+ return cleanupErrors.length > 0 ? {
480
+ ok: false,
481
+ error: `Ethereum provider probe cleanup failed: ${cleanupErrors.join("; ")}`,
482
+ source: "ui-ethereum-provider"
483
+ } : outcome;
484
+ }
319
485
  function extensionBackgroundProbeTimeoutMs(cdpCallTimeoutMs) {
320
486
  return Math.max(1, Math.floor(cdpCallTimeoutMs * 0.8));
321
487
  }
@@ -14,6 +14,8 @@ const RUNWAY_IOS_METADATA = {
14
14
  };
15
15
  class SimulatorTargetError extends Error {
16
16
  }
17
+ class SimulatorUnresponsiveError extends Error {
18
+ }
17
19
  function resolvePoolIdentity(target) {
18
20
  const out = resolveSlotPortsByRepo(target);
19
21
  const id = {};
@@ -75,7 +77,7 @@ async function provisionRunwayMobile(target, options) {
75
77
  const sim = ensureSimulator(simulator, runtime, deviceType);
76
78
  let resolved;
77
79
  let reason = options.force ? "forced" : "fresh-install";
78
- if (!options.force && appInstalled(sim.udid ?? sim.name)) {
80
+ if (!options.force && appInstalledWithRecovery(sim.udid ?? sim.name, options)) {
79
81
  const installed = readInstalledBaselineArtifact(resolvedTarget, sim, options.runtimeDir);
80
82
  let requestedRunId = options.run;
81
83
  if (installed && !requestedRunId) {
@@ -445,6 +447,24 @@ function bootSimulator(device, options) {
445
447
  log(options, `runway: bootstatus wait for ${device} did not confirm; continuing to install`);
446
448
  }
447
449
  }
450
+ function restartSimulator(device, options) {
451
+ log(options, `runway: simulator ${device} did not answer the app probe; restarting it once`);
452
+ execFileSync("xcrun", ["simctl", "shutdown", device], {
453
+ stdio: ["ignore", "ignore", "ignore"],
454
+ timeout: 3e4
455
+ });
456
+ try {
457
+ execFileSync("xcrun", ["simctl", "boot", device], {
458
+ stdio: ["ignore", "ignore", "ignore"],
459
+ timeout: 3e4
460
+ });
461
+ } catch {
462
+ }
463
+ execFileSync("xcrun", ["simctl", "bootstatus", device, "-b"], {
464
+ stdio: ["ignore", "ignore", "ignore"],
465
+ timeout: 12e4
466
+ });
467
+ }
448
468
  function preapproveDeepLinkScheme(device, bundleId, options) {
449
469
  const scheme = process.env.IOS_DEV_CLIENT_SCHEME ?? "expo-metamask";
450
470
  try {
@@ -572,12 +592,27 @@ function readInstalledBaselineArtifact(target, sim, runtimeDir) {
572
592
  }
573
593
  function appInstalled(device) {
574
594
  try {
575
- execFileSync("xcrun", ["simctl", "get_app_container", device, RUNWAY_IOS_METADATA.bundleId, "app"], { stdio: ["ignore", "ignore", "ignore"] });
595
+ execFileSync("xcrun", ["simctl", "get_app_container", device, RUNWAY_IOS_METADATA.bundleId, "app"], {
596
+ stdio: ["ignore", "ignore", "ignore"],
597
+ timeout: 15e3
598
+ });
576
599
  return true;
577
- } catch {
600
+ } catch (error) {
601
+ if (error instanceof Error && "code" in error && error.code === "ETIMEDOUT") {
602
+ throw new SimulatorUnresponsiveError(`simulator ${device} timed out while checking the installed app`);
603
+ }
578
604
  return false;
579
605
  }
580
606
  }
607
+ function appInstalledWithRecovery(device, options) {
608
+ try {
609
+ return appInstalled(device);
610
+ } catch (error) {
611
+ if (!(error instanceof SimulatorUnresponsiveError)) throw error;
612
+ restartSimulator(device, options);
613
+ return appInstalled(device);
614
+ }
615
+ }
581
616
  function writeRunwayBaseline(target, slot, platform, resolved, artifact, simulator, alreadyInstalled, runtimeDir, preservedArtifact) {
582
617
  const file = runwayBaselinePath(target, runtimeDir);
583
618
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -0,0 +1,124 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { recipeRuntimePath } from "../../paths.js";
5
+ import {
6
+ assertAndroidReleaseArtifactRuntime
7
+ } from "./release-artifact.js";
8
+ function mobileReleaseArtifactStatePath(target) {
9
+ return recipeRuntimePath(target, "mobile-release-artifact.json");
10
+ }
11
+ function writeMobileReleaseArtifactState(target, report) {
12
+ const state = {
13
+ schemaVersion: 1,
14
+ adapter: "mobile",
15
+ platform: "android",
16
+ packageId: report.packageId,
17
+ versionName: report.versionName,
18
+ versionCode: report.versionCode,
19
+ sha256: report.sha256,
20
+ deviceSerial: report.deviceSerial,
21
+ installedAt: (/* @__PURE__ */ new Date()).toISOString()
22
+ };
23
+ const file = mobileReleaseArtifactStatePath(target);
24
+ const directory = ensureSafeRuntimeDirectory(target, file);
25
+ const existing = fs.lstatSync(file, { throwIfNoEntry: false });
26
+ if (existing && (!existing.isFile() || existing.isSymbolicLink())) throw invalidState(file);
27
+ const temporary = path.join(
28
+ directory,
29
+ `.mobile-release-artifact.${process.pid}.${randomBytes(8).toString("hex")}.tmp`
30
+ );
31
+ let descriptor;
32
+ try {
33
+ descriptor = fs.openSync(temporary, "wx", 384);
34
+ fs.writeFileSync(descriptor, `${JSON.stringify(state, null, 2)}
35
+ `, "utf8");
36
+ fs.fsyncSync(descriptor);
37
+ fs.closeSync(descriptor);
38
+ descriptor = void 0;
39
+ fs.renameSync(temporary, file);
40
+ } finally {
41
+ if (descriptor !== void 0) fs.closeSync(descriptor);
42
+ const temporaryStat = fs.lstatSync(temporary, { throwIfNoEntry: false });
43
+ if (temporaryStat) fs.unlinkSync(temporary);
44
+ }
45
+ return state;
46
+ }
47
+ function requireCurrentMobileReleaseArtifactState(target, verify = assertAndroidReleaseArtifactRuntime) {
48
+ const state = readMobileReleaseArtifactState(target);
49
+ if (state) verify(state);
50
+ return state;
51
+ }
52
+ function clearMobileReleaseArtifactState(target) {
53
+ const file = mobileReleaseArtifactStatePath(target);
54
+ const existing = fs.lstatSync(file, { throwIfNoEntry: false });
55
+ if (!existing) return;
56
+ ensureSafeRuntimeDirectory(target, file);
57
+ if (!existing.isFile() || existing.isSymbolicLink()) throw invalidState(file);
58
+ fs.unlinkSync(file);
59
+ }
60
+ function readMobileReleaseArtifactState(target) {
61
+ const file = mobileReleaseArtifactStatePath(target);
62
+ let stat;
63
+ try {
64
+ stat = fs.lstatSync(file);
65
+ } catch (error) {
66
+ if (isMissing(error)) return null;
67
+ throw invalidState(file);
68
+ }
69
+ try {
70
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 16 * 1024) {
71
+ throw invalidState(file);
72
+ }
73
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
74
+ if (value.schemaVersion !== 1 || value.adapter !== "mobile" || value.platform !== "android" || !text(value.packageId) || !text(value.versionName) || !text(value.versionCode) || !/^[a-f0-9]{64}$/u.test(value.sha256 ?? "") || !text(value.deviceSerial) || !text(value.installedAt)) throw invalidState(file);
75
+ return value;
76
+ } catch (error) {
77
+ if (error instanceof Error && error.message.startsWith("The Mobile release artifact state is invalid")) {
78
+ throw error;
79
+ }
80
+ throw invalidState(file);
81
+ }
82
+ }
83
+ function text(value) {
84
+ return typeof value === "string" && value.trim().length > 0;
85
+ }
86
+ function isMissing(error) {
87
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
88
+ }
89
+ function invalidState(file) {
90
+ return new Error(`The Mobile release artifact state is invalid: ${file}. Next: rerun runtime-launch with the same artifact source.`);
91
+ }
92
+ function ensureSafeRuntimeDirectory(target, file) {
93
+ const root = path.resolve(target);
94
+ const directory = path.dirname(path.resolve(file));
95
+ const relative = path.relative(root, directory);
96
+ if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
97
+ throw invalidState(file);
98
+ }
99
+ let current = root;
100
+ const rootStat = fs.lstatSync(root, { throwIfNoEntry: false });
101
+ if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) throw invalidState(file);
102
+ for (const segment of relative.split(path.sep)) {
103
+ current = path.join(current, segment);
104
+ const stat = fs.lstatSync(current, { throwIfNoEntry: false });
105
+ if (stat) {
106
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw invalidState(file);
107
+ } else {
108
+ fs.mkdirSync(current, { mode: 448 });
109
+ }
110
+ }
111
+ const realRoot = fs.realpathSync(root);
112
+ const realDirectory = fs.realpathSync(directory);
113
+ if (realDirectory !== realRoot && !realDirectory.startsWith(`${realRoot}${path.sep}`)) {
114
+ throw invalidState(file);
115
+ }
116
+ return directory;
117
+ }
118
+ export {
119
+ clearMobileReleaseArtifactState,
120
+ mobileReleaseArtifactStatePath,
121
+ readMobileReleaseArtifactState,
122
+ requireCurrentMobileReleaseArtifactState,
123
+ writeMobileReleaseArtifactState
124
+ };
@@ -0,0 +1,295 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import { promises as fsp } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { spawnSync } from "node:child_process";
7
+ async function installAndroidReleaseArtifact(options, deps = defaultDeps) {
8
+ validateInputs(options);
9
+ const sourcePath = path.resolve(options.file);
10
+ const stagingDirectory = await fsp.mkdtemp(path.join(os.tmpdir(), "mm-harness-android-artifact-"));
11
+ try {
12
+ const stagedPath = path.join(stagingDirectory, "release.apk");
13
+ const { sha256, bytes } = await stageRegularFile(sourcePath, stagedPath);
14
+ if (sha256 !== options.expectedSha256) {
15
+ throw new Error(`Android artifact SHA-256 ${sha256} does not match expected ${options.expectedSha256}.`);
16
+ }
17
+ const { packageId, versionName, versionCode } = readAndroidPackageIdentity(deps, stagedPath);
18
+ if (packageId !== options.packageId) {
19
+ throw new Error(`Android artifact package ${packageId || "missing"} does not match expected ${options.packageId}.`);
20
+ }
21
+ if (versionName !== options.expectedVersion) {
22
+ throw new Error(`Android artifact version ${versionName || "missing"} does not match expected ${options.expectedVersion}.`);
23
+ }
24
+ if (versionCode !== options.expectedBuildId) {
25
+ throw new Error(`Android artifact build ${versionCode || "missing"} does not match expected ${options.expectedBuildId}.`);
26
+ }
27
+ const adb = resolveAdb(deps);
28
+ const state = runChecked(deps, adb, ["-s", options.deviceSerial, "get-state"]).trim();
29
+ if (state !== "device") {
30
+ throw new Error(`Android device ${options.deviceSerial} is not ready.`);
31
+ }
32
+ runChecked(deps, adb, ["-s", options.deviceSerial, "install", "-r", "-d", stagedPath]);
33
+ assertInstalledAndroidReleaseArtifact({
34
+ packageId,
35
+ versionName,
36
+ versionCode,
37
+ sha256,
38
+ deviceSerial: options.deviceSerial
39
+ }, deps, adb);
40
+ runChecked(deps, adb, [
41
+ "-s",
42
+ options.deviceSerial,
43
+ "shell",
44
+ "monkey",
45
+ "-p",
46
+ packageId,
47
+ "-c",
48
+ "android.intent.category.LAUNCHER",
49
+ "1"
50
+ ]);
51
+ let pid = "";
52
+ for (let attempt = 0; attempt < 20; attempt += 1) {
53
+ const result = deps.run(adb, ["-s", options.deviceSerial, "shell", "pidof", packageId]);
54
+ if (result.status === 0 && result.stdout.trim()) {
55
+ pid = result.stdout.trim();
56
+ break;
57
+ }
58
+ await deps.sleep(250);
59
+ }
60
+ if (!pid) throw new Error(`Android package ${packageId} did not start on ${options.deviceSerial}.`);
61
+ return {
62
+ schemaVersion: 1,
63
+ status: "pass",
64
+ adapter: "mobile",
65
+ platform: "android",
66
+ source: { kind: "local", path: sourcePath },
67
+ sha256,
68
+ bytes,
69
+ packageId,
70
+ versionName,
71
+ versionCode,
72
+ deviceSerial: options.deviceSerial,
73
+ installed: true,
74
+ launched: true
75
+ };
76
+ } finally {
77
+ await fsp.rm(stagingDirectory, { recursive: true, force: true });
78
+ }
79
+ }
80
+ function assertAndroidReleaseArtifactRuntime(identity, deps = defaultDeps) {
81
+ validateInputs({
82
+ file: "runtime.apk",
83
+ expectedVersion: identity.versionName,
84
+ expectedBuildId: identity.versionCode,
85
+ expectedSha256: identity.sha256,
86
+ packageId: identity.packageId,
87
+ deviceSerial: identity.deviceSerial
88
+ });
89
+ const adb = resolveAdb(deps);
90
+ assertInstalledAndroidReleaseArtifact(identity, deps, adb);
91
+ }
92
+ async function relaunchAndroidReleaseArtifactRuntime(identity, deps = defaultDeps) {
93
+ assertAndroidReleaseArtifactRuntime(identity, deps);
94
+ const adb = resolveAdb(deps);
95
+ runChecked(deps, adb, [
96
+ "-s",
97
+ identity.deviceSerial,
98
+ "shell",
99
+ "monkey",
100
+ "-p",
101
+ identity.packageId,
102
+ "-c",
103
+ "android.intent.category.LAUNCHER",
104
+ "1"
105
+ ]);
106
+ for (let attempt = 0; attempt < 20; attempt += 1) {
107
+ const result = deps.run(adb, ["-s", identity.deviceSerial, "shell", "pidof", identity.packageId]);
108
+ if (result.status === 0 && result.stdout.trim()) return;
109
+ await deps.sleep(250);
110
+ }
111
+ throw new Error(`Android package ${identity.packageId} did not start on ${identity.deviceSerial}.`);
112
+ }
113
+ function validateInputs(options) {
114
+ if (!/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9._-]+)?$/u.test(options.expectedVersion)) {
115
+ throw new Error("--artifact-version must be an Android version name such as X.Y.Z.");
116
+ }
117
+ if (!/^\d+$/u.test(options.expectedBuildId)) {
118
+ throw new Error("--artifact-build-id must be the numeric Android version code.");
119
+ }
120
+ if (!/^[a-f0-9]{64}$/u.test(options.expectedSha256)) {
121
+ throw new Error("--artifact-sha256 must be 64 lowercase hexadecimal characters.");
122
+ }
123
+ if (!/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/u.test(options.packageId)) {
124
+ throw new Error("--artifact-package-id must be an Android application ID.");
125
+ }
126
+ if (!/^[A-Za-z0-9._:-]+$/u.test(options.deviceSerial)) {
127
+ throw new Error("Android artifact launch requires an explicit device serial.");
128
+ }
129
+ }
130
+ function assertInstalledAndroidReleaseArtifact(identity, deps, adb) {
131
+ const state = runChecked(deps, adb, ["-s", identity.deviceSerial, "get-state"]).trim();
132
+ if (state !== "device") throw new Error(`Android device ${identity.deviceSerial} is not ready.`);
133
+ const paths = runChecked(deps, adb, [
134
+ "-s",
135
+ identity.deviceSerial,
136
+ "shell",
137
+ "pm",
138
+ "path",
139
+ identity.packageId
140
+ ]).split(/\r?\n/u).filter(Boolean);
141
+ if (paths.length !== 1 || !paths[0].startsWith("package:/") || !paths[0].endsWith("/base.apk")) {
142
+ throw new Error(`Android package ${identity.packageId} is not a single installed APK on ${identity.deviceSerial}.`);
143
+ }
144
+ const installedPath = paths[0].slice("package:".length);
145
+ if (!/^\/data\/app\/[A-Za-z0-9._~+=/-]+\/base\.apk$/u.test(installedPath)) {
146
+ throw new Error(`Android package ${identity.packageId} returned an unsafe installed path.`);
147
+ }
148
+ const installedSha256 = runChecked(deps, adb, [
149
+ "-s",
150
+ identity.deviceSerial,
151
+ "shell",
152
+ "sha256sum",
153
+ installedPath
154
+ ]).trim().split(/\s+/u)[0];
155
+ if (installedSha256 !== identity.sha256) {
156
+ throw new Error(`Android package ${identity.packageId} no longer matches the trusted release artifact SHA-256.`);
157
+ }
158
+ const installed = runChecked(deps, adb, [
159
+ "-s",
160
+ identity.deviceSerial,
161
+ "shell",
162
+ "dumpsys",
163
+ "package",
164
+ identity.packageId
165
+ ]);
166
+ if (!new RegExp(`^\\s*versionName=${escapeRegExp(identity.versionName)}\\s*$`, "mu").test(installed) || !new RegExp(`^\\s*versionCode=${escapeRegExp(identity.versionCode)}(?:\\s|$)`, "mu").test(installed)) {
167
+ throw new Error(`Android package ${identity.packageId} installed with an unexpected version on ${identity.deviceSerial}.`);
168
+ }
169
+ }
170
+ function escapeRegExp(value) {
171
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
172
+ }
173
+ async function stageRegularFile(file, destination) {
174
+ const descriptor = await fsp.open(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
175
+ const output = await fsp.open(destination, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 384);
176
+ try {
177
+ const stat = await descriptor.stat();
178
+ if (!stat.isFile() || stat.size <= 0 || stat.size > 2 * 1024 * 1024 * 1024) {
179
+ throw new Error(`Android artifact must be a non-empty regular file smaller than 2 GiB: ${file}`);
180
+ }
181
+ const digest = createHash("sha256");
182
+ const stream = descriptor.createReadStream({ autoClose: false });
183
+ for await (const chunk of stream) {
184
+ digest.update(chunk);
185
+ await output.writeFile(chunk);
186
+ }
187
+ await output.sync();
188
+ return { sha256: digest.digest("hex"), bytes: stat.size };
189
+ } finally {
190
+ await output.close();
191
+ await descriptor.close();
192
+ }
193
+ }
194
+ function resolveAdb(deps) {
195
+ const configured = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
196
+ const candidates = [
197
+ configured ? path.join(configured, "platform-tools", "adb") : "",
198
+ path.join(os.homedir(), "Library", "Android", "sdk", "platform-tools", "adb"),
199
+ "adb"
200
+ ].filter(Boolean);
201
+ return firstRunnable(deps, candidates, ["version"], "adb");
202
+ }
203
+ function readAndroidPackageIdentity(deps, file) {
204
+ const analyzer = resolveApkAnalyzer(deps);
205
+ if (analyzer) {
206
+ const packageId2 = deps.run(analyzer, ["manifest", "application-id", file]);
207
+ const versionName2 = deps.run(analyzer, ["manifest", "version-name", file]);
208
+ const versionCode2 = deps.run(analyzer, ["manifest", "version-code", file]);
209
+ if (packageId2.status === 0 && packageId2.stdout.trim() && versionName2.status === 0 && versionName2.stdout.trim() && versionCode2.status === 0 && versionCode2.stdout.trim()) {
210
+ return {
211
+ packageId: packageId2.stdout.trim(),
212
+ versionName: versionName2.stdout.trim(),
213
+ versionCode: versionCode2.stdout.trim()
214
+ };
215
+ }
216
+ }
217
+ const aapt2 = resolveAapt2(deps);
218
+ const badging = runChecked(deps, aapt2, ["dump", "badging", file]);
219
+ const line = badging.split(/\r?\n/u).find((entry) => entry.startsWith("package: ")) ?? "";
220
+ const packageId = /\bname='([^']+)'/u.exec(line)?.[1] ?? "";
221
+ const versionCode = /\bversionCode='([^']+)'/u.exec(line)?.[1] ?? "";
222
+ const versionName = /\bversionName='([^']+)'/u.exec(line)?.[1] ?? "";
223
+ if (!packageId || !versionCode || !versionName) {
224
+ throw new Error("aapt2 could not read the Android artifact package identity.");
225
+ }
226
+ return { packageId, versionName, versionCode };
227
+ }
228
+ function resolveApkAnalyzer(deps) {
229
+ const configured = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
230
+ const candidates = [
231
+ configured ? path.join(configured, "cmdline-tools", "latest", "bin", "apkanalyzer") : "",
232
+ path.join(os.homedir(), "Library", "Android", "sdk", "cmdline-tools", "latest", "bin", "apkanalyzer")
233
+ ].filter(Boolean);
234
+ for (const candidate of candidates) {
235
+ try {
236
+ fs.accessSync(candidate, fs.constants.X_OK);
237
+ if (fs.statSync(candidate).isFile()) return candidate;
238
+ } catch {
239
+ continue;
240
+ }
241
+ }
242
+ const probe = deps.run("apkanalyzer", []);
243
+ if (`${probe.stdout}
244
+ ${probe.stderr}`.includes("manifest application-id")) return "apkanalyzer";
245
+ return null;
246
+ }
247
+ function resolveAapt2(deps) {
248
+ const configured = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
249
+ const buildToolsRoot = configured ? path.join(configured, "build-tools") : path.join(os.homedir(), "Library", "Android", "sdk", "build-tools");
250
+ let candidates = [];
251
+ try {
252
+ candidates = fs.readdirSync(buildToolsRoot).sort((left, right) => right.localeCompare(left, void 0, { numeric: true })).map((version) => path.join(buildToolsRoot, version, "aapt2"));
253
+ } catch {
254
+ candidates = [];
255
+ }
256
+ candidates.push("aapt2");
257
+ return firstRunnable(deps, candidates, ["version"], "apkanalyzer or aapt2");
258
+ }
259
+ function firstRunnable(deps, candidates, args, label) {
260
+ for (const candidate of candidates) {
261
+ if (deps.run(candidate, args).status === 0) return candidate;
262
+ }
263
+ throw new Error(`${label} is required to validate and install an Android release artifact.`);
264
+ }
265
+ function runChecked(deps, command, args) {
266
+ const result = deps.run(command, args);
267
+ if (result.status !== 0) {
268
+ const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`;
269
+ throw new Error(`${path.basename(command)} failed: ${detail}`);
270
+ }
271
+ return result.stdout;
272
+ }
273
+ const defaultDeps = {
274
+ run(command, args) {
275
+ const result = spawnSync(command, args, {
276
+ encoding: "utf8",
277
+ stdio: ["ignore", "pipe", "pipe"],
278
+ timeout: 12e4,
279
+ maxBuffer: 4 * 1024 * 1024
280
+ });
281
+ return {
282
+ status: result.status ?? 1,
283
+ stdout: result.stdout || "",
284
+ stderr: result.stderr || result.error?.message || ""
285
+ };
286
+ },
287
+ sleep(ms) {
288
+ return new Promise((resolve) => setTimeout(resolve, ms));
289
+ }
290
+ };
291
+ export {
292
+ assertAndroidReleaseArtifactRuntime,
293
+ installAndroidReleaseArtifact,
294
+ relaunchAndroidReleaseArtifactRuntime
295
+ };