@deeeed/metamask-harness 0.36.0 → 0.37.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 (51) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/adapters/extension/artifact-runtime-state.cjs +128 -0
  3. package/adapters/extension/inject.mjs +1 -0
  4. package/adapters/extension/launch-browser.cjs +22 -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 +9 -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 +161 -20
  15. package/dist/adapters/extension/runtime.js +127 -0
  16. package/dist/adapters/mobile/release-artifact-state.js +124 -0
  17. package/dist/adapters/mobile/release-artifact.js +295 -0
  18. package/dist/adapters.js +11 -4
  19. package/dist/command-contract.js +16 -0
  20. package/dist/commands/call.js +2 -1
  21. package/dist/commands/launch/extension.js +55 -6
  22. package/dist/commands/launch/mobile.js +2 -0
  23. package/dist/commands/provision.js +2 -0
  24. package/dist/commands/run-engine.js +89 -5
  25. package/dist/commands/run.js +3 -1
  26. package/dist/commands/runtime-launch.js +178 -10
  27. package/dist/heal-bounds.js +1 -1
  28. package/dist/live-adapter-contract.js +3 -1
  29. package/dist/metamask-action-validation.js +47 -1
  30. package/dist/mm-harness-cli.js +31 -1
  31. package/dist/run-diagnostics.js +1 -1
  32. package/docs/RELEASE-QA-CAPABILITY-MAP.md +150 -0
  33. package/library/actions/extension/perps/perps.mjs +2 -0
  34. package/library/actions/extension/perps/read_snapshot.mjs +470 -0
  35. package/library/actions/extension/platform/cdp.mjs +6 -3
  36. package/library/actions/extension/wallet/import.mjs +13 -46
  37. package/library/actions/extension/wallet/secret-input.mjs +98 -0
  38. package/library/actions/mobile/platform/observe-ui.mjs +84 -2
  39. package/library/actions/mobile/ui/native-navigation.mjs +225 -0
  40. package/library/actions/mobile/ui/navigate.mjs +7 -0
  41. package/library/actions/mobile/wallet/import.mjs +71 -2
  42. package/library/actions/mobile/wallet/native-ui.mjs +493 -0
  43. package/library/actions/mobile/wallet/read_state.mjs +16 -0
  44. package/library/actions/mobile/wallet/reset.mjs +17 -4
  45. package/library/actions/shared/ui/locators.mjs +7 -0
  46. package/library/manifests/extension.action-manifest.json +116 -0
  47. package/library/manifests/mobile.action-manifest.json +16 -0
  48. package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
  49. package/library/recipes/wallet/import.recipe.json +20 -1
  50. package/library/recipes/wallet/reset-import.recipe.json +20 -1
  51. package/package.json +1 -1
@@ -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
+ };
package/dist/adapters.js CHANGED
@@ -12,10 +12,15 @@ import { resolveMobileToolPath } from "../library/actions/mobile/platform/tool-p
12
12
  import { resolveWalletImportCredentials, validateWalletImportOptions } from "../library/actions/shared/wallet/import-source.mjs";
13
13
  const execFileAsync = promisify(execFile);
14
14
  const NATIVE_PROVIDER_UI_ACTIONS = /* @__PURE__ */ new Set([
15
+ "ui.press",
16
+ "ui.set_input",
17
+ "ui.scroll",
15
18
  "ui.swipe",
16
19
  "ui.pan",
17
20
  "ui.drag",
18
21
  "ui.long_press",
22
+ "ui.wait_for",
23
+ "ui.screenshot",
19
24
  "ui.capture_surface"
20
25
  ]);
21
26
  function sleep(ms) {
@@ -30,6 +35,7 @@ function simpleAdapter(action, executor) {
30
35
  };
31
36
  }
32
37
  const LIVE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set([
38
+ "metamask.perps.read_snapshot",
33
39
  "metamask.perps.read_positions",
34
40
  "metamask.perps.ensure_positions",
35
41
  "metamask.perps.assert_positions",
@@ -47,8 +53,7 @@ const LIVE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set([
47
53
  "metamask.perps.capture_performance"
48
54
  ]);
49
55
  const CORE_ONLY_PERPS_ACTIONS = /* @__PURE__ */ new Set([
50
- "metamask.perps.read_account",
51
- "metamask.perps.read_snapshot"
56
+ "metamask.perps.read_account"
52
57
  ]);
53
58
  const LIVE_ONLY_WALLET_ACTIONS = /* @__PURE__ */ new Set([
54
59
  "metamask.wallet.setup",
@@ -221,13 +226,14 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
221
226
  "metamask.perps.start_state",
222
227
  "metamask.perps.teardown_state",
223
228
  "metamask.perps.capture_performance",
229
+ ...platform === "extension" || platform === "core" ? ["metamask.perps.read_snapshot"] : [],
224
230
  ...platform === "mobile" ? [
225
231
  "metamask.perps.clear_performance_caches",
226
232
  "metamask.perps.measure_homepage_visible",
227
233
  "metamask.perps.prepare_local_snapshot_endpoint"
228
234
  ] : [],
229
235
  // read_account is core-only: only the headless core adapter implements it.
230
- ...platform === "core" ? ["metamask.perps.read_account", "metamask.perps.read_snapshot"] : []
236
+ ...platform === "core" ? ["metamask.perps.read_account"] : []
231
237
  ];
232
238
  const bundled = new Set(bundledActions);
233
239
  const actions = [.../* @__PURE__ */ new Set([...bundledActions, ...declaredCustomActions])];
@@ -662,6 +668,7 @@ async function handleMobileHud(payload, context) {
662
668
  }
663
669
  }
664
670
  async function hideMobileHudOnTeardown(projectRoot, env = {}) {
671
+ if (env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1" || process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1") return;
665
672
  if (process.env.METAMASK_RECIPE_AUTO_HUD === "0" || process.env.METAMASK_RECIPE_AUTO_HUD === "false") return;
666
673
  const input = {
667
674
  node: { bridge_timeout_ms: 8e3, cdp_timeout_ms: 5e3 },
@@ -803,7 +810,7 @@ function createMetaMaskUiTransport(platform, harness, preparedLiveAdapters) {
803
810
  if (live) return live.output;
804
811
  throw new Error(`ui.navigate requires library/actions/${platform}/ui/navigate.mjs.`);
805
812
  }
806
- if (platform === "mobile" && NATIVE_PROVIDER_UI_ACTIONS.has(action)) {
813
+ if (platform === "mobile" && NATIVE_PROVIDER_UI_ACTIONS.has(action) && (context.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1" || process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1" || ["ui.swipe", "ui.pan", "ui.drag", "ui.long_press", "ui.capture_surface"].includes(action))) {
807
814
  return executeNativeProviderAction(action, node, context, harness.createNativeUiTransport);
808
815
  }
809
816
  return base.execute(action, action === "ui.wait_for" ? normalizeUiWaitNode(node) : node, context);
@@ -239,6 +239,22 @@ const PUBLIC_COMMAND_CONTRACTS = {
239
239
  }),
240
240
  positionals: [{ label: "platform", choices: ["ios", "android"] }]
241
241
  },
242
+ "runtime-launch": {
243
+ options: options(HELP, JSON, TARGET, ADAPTER, DEVICE, MOBILE_PLATFORM, {
244
+ "--cdp-port": value(),
245
+ "--chrome-user-data-dir": value(),
246
+ "--artifacts-dir": value(),
247
+ "--start-watch": bool(),
248
+ "--build-lavamoat": bool(),
249
+ "--remote-flag": value(),
250
+ "--artifact-file": value(),
251
+ "--artifact-version": value(),
252
+ "--artifact-build-id": value(),
253
+ "--artifact-package-id": value(),
254
+ "--artifact-sha256": value(),
255
+ "--artifact-cache": value()
256
+ })
257
+ },
242
258
  logs: {
243
259
  options: options(HELP, JSON, TARGET, ADAPTER, ADAPTER_OR_MOBILE_PLATFORM, {
244
260
  "--full": bool(),
@@ -40,6 +40,7 @@ import {
40
40
  synthesizeOneNodeRecipe,
41
41
  validateRecipeAdapterAware
42
42
  } from "./run-engine.js";
43
+ import { readMobileReleaseArtifactState } from "../adapters/mobile/release-artifact-state.js";
43
44
  import { acquireCheckoutLock } from "../checkout-lock.js";
44
45
  import { formatRunDiagnosticsForHuman, readRunDiagnosticsDocument } from "../run-diagnostics.js";
45
46
  import { recipeTrustFailure } from "../recipe-security.js";
@@ -267,7 +268,7 @@ async function handleCall(argv) {
267
268
  }
268
269
  try {
269
270
  const prepared = await prepareHeal(adapter, target, options, json, {
270
- skipMobileSourceFreshness: adapter === "mobile" && resolvedAction === "app.lifecycle"
271
+ skipMobileSourceFreshness: adapter === "mobile" && (resolvedAction === "app.lifecycle" || readMobileReleaseArtifactState(target) !== null)
271
272
  });
272
273
  if (typeof prepared === "number") return prepared;
273
274
  const { state, heal } = prepared;
@@ -21,7 +21,11 @@ import {
21
21
  extensionProductConfigBlock,
22
22
  extensionProductConfigFingerprint
23
23
  } from "../../adapters/extension/product-config.js";
24
- import { isExtensionDistStale } from "../../adapters/extension/runtime-decision.js";
24
+ import {
25
+ isExtensionDistStale,
26
+ releaseArtifactState,
27
+ runtimeDistCheck
28
+ } from "../../adapters/extension/runtime-decision.js";
25
29
  import { checkExtensionRuntimeHealth } from "../../adapters/extension/runtime.js";
26
30
  import { ensureHarnessFresh } from "../../adapters/harness-freshness.js";
27
31
  import { isExtensionWatcherLive, stopExtensionWatcher } from "../../adapters/slot-ports.js";
@@ -55,7 +59,18 @@ async function launchExtension(target, tier, wantWatch, displayMode = "fullscree
55
59
  if (process.env.CHROME_USER_DATA_DIR) {
56
60
  process.env.CHROME_USER_DATA_DIR = path.resolve(process.env.CHROME_USER_DATA_DIR);
57
61
  }
58
- const reusable = !wantWatch && tier !== "build" && await extensionRuntimeReusable(target);
62
+ const artifact = releaseArtifactState(target);
63
+ const artifactSnapshot = runtimeDistCheck(target, artifact);
64
+ const artifactLaunchable = !wantWatch && tier !== "build" && artifact.status === "valid" && artifactSnapshot.status === "fresh";
65
+ const reusable = !wantWatch && tier !== "build" && await extensionRuntimeReusable(target, artifact);
66
+ if (artifact.status !== "none" && !reusable && !artifactLaunchable) {
67
+ const detail = artifact.status === "invalid" ? `identity is invalid: ${artifact.reason ?? "unknown reason"}` : "runtime is not healthy and reusable";
68
+ return {
69
+ status: EXIT.infra,
70
+ output: `RELEASE_ARTIFACT_NOT_REUSABLE: The loaded release artifact ${detail}.
71
+ Next: rerun runtime-launch with the same artifact source.`
72
+ };
73
+ }
59
74
  if (!reusable) {
60
75
  const block = extensionProductConfigBlock(target);
61
76
  if (block) {
@@ -80,14 +95,23 @@ Next: ${block.userAction}`
80
95
  if (reusable) {
81
96
  return extensionReattach(target, displayMode);
82
97
  }
98
+ if (artifactLaunchable) {
99
+ return extensionLaunchReleaseArtifact(target, artifact);
100
+ }
83
101
  return extensionLaunchDevelopment(target);
84
102
  }
85
- async function extensionRuntimeReusable(target) {
103
+ async function extensionRuntimeReusable(target, artifact = releaseArtifactState(target)) {
86
104
  const cdpPort = process.env.CDP_PORT;
87
105
  if (!cdpPort) return false;
88
- if (isExtensionDistStale(target)) return false;
89
- if (isExtensionWatcherLive(target) && !harnessWatcherMatchesProductConfig(target)) return false;
90
- if (!extensionCompiledScriptsMatchProductConfig(target, expectedRuntimeDist(target))) return false;
106
+ const runtimeSnapshot = runtimeDistCheck(target, artifact);
107
+ const releaseArtifact = runtimeSnapshot.source === "release-artifact";
108
+ if (releaseArtifact) {
109
+ if (runtimeSnapshot.status !== "fresh") return false;
110
+ } else {
111
+ if (isExtensionDistStale(target)) return false;
112
+ if (isExtensionWatcherLive(target) && !harnessWatcherMatchesProductConfig(target)) return false;
113
+ if (!extensionCompiledScriptsMatchProductConfig(target, expectedRuntimeDist(target))) return false;
114
+ }
91
115
  for (let attempt = 0; attempt < 3; attempt += 1) {
92
116
  const reachable = await cdpVersionReachable(cdpPort);
93
117
  const nonceOwned = reachable && await cdpOwnedByRuntimeNonce(cdpPort, target);
@@ -344,6 +368,31 @@ async function extensionLaunchDevelopment(target) {
344
368
  ));
345
369
  return spawnScriptStreaming(liveScript, liveArgs, target);
346
370
  }
371
+ async function extensionLaunchReleaseArtifact(target, artifact) {
372
+ if (!artifact.sourceDir || !artifact.provenancePath) {
373
+ return {
374
+ status: EXIT.infra,
375
+ output: "RELEASE_ARTIFACT_NOT_REUSABLE: The loaded release artifact identity is incomplete.\nNext: rerun runtime-launch with the same artifact source."
376
+ };
377
+ }
378
+ const liveScript = recipeHarnessPath(target, "extension", "scripts", "live.sh");
379
+ const liveArgs = [
380
+ "--target",
381
+ target,
382
+ "--external-artifact",
383
+ "--dist-dir",
384
+ artifact.sourceDir,
385
+ "--artifact-provenance",
386
+ artifact.provenancePath,
387
+ "--preserve-profile"
388
+ ];
389
+ if (process.env.CDP_PORT) liveArgs.push("--cdp-port", process.env.CDP_PORT);
390
+ if (process.env.EXTENSION_START_URL) liveArgs.push("--start-url", process.env.EXTENSION_START_URL);
391
+ console.error(colorHumanMessage(
392
+ `\u2192 extension release relaunch \u2014 verified artifact ${artifact.manifestVersion ?? artifact.expectedVersion ?? ""} \xB7 CDP :${process.env.CDP_PORT ?? "default"}`
393
+ ));
394
+ return spawnScriptStreaming(liveScript, liveArgs, target);
395
+ }
347
396
  function harnessWatcherMatchesProductConfig(target) {
348
397
  const runtimeDir = path.join(target, recipeRuntimeDir());
349
398
  const harnessPidFile = path.join(runtimeDir, "recipe-harness-webpack.pid");
@@ -4,8 +4,10 @@ import {
4
4
  recordMobileSourceBaseline
5
5
  } from "../../adapters/mobile/source-freshness.js";
6
6
  import { ensureHarnessFresh } from "../../adapters/harness-freshness.js";
7
+ import { clearMobileReleaseArtifactState } from "../../adapters/mobile/release-artifact-state.js";
7
8
  import { EXIT } from "../shared.js";
8
9
  async function launchMobile(target, mobileTarget, tier, json, restartApp = false, clearMetro = false) {
10
+ clearMobileReleaseArtifactState(target);
9
11
  await ensureHarnessFresh(target, "mobile");
10
12
  const platform = mobileTarget ?? "ios";
11
13
  const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : void 0;
@@ -1,6 +1,7 @@
1
1
  import { detectAdapter } from "../harness.js";
2
2
  import { assertAdapter } from "../paths.js";
3
3
  import { getAdapterSurface } from "../adapters/surface.js";
4
+ import { clearMobileReleaseArtifactState } from "../adapters/mobile/release-artifact-state.js";
4
5
  import { ADAPTER_DETECT_NEXT, usageOut, writeInteractiveProgress } from "./shared.js";
5
6
  import {
6
7
  applyWatcherPortOption,
@@ -28,6 +29,7 @@ async function handleProvision({ positional, options, rawArgv }) {
28
29
  const platform = optionString(options, "platform") ?? optionString(options, "devicePlatform") ?? (positional[0] === "runway" ? positional[1] : positional[0]) ?? "ios";
29
30
  if (adapter === "mobile") {
30
31
  writeInteractiveProgress(json, `\u2192 provision mobile ${platform} \u2014 resolving simulator and Runway artifact`);
32
+ if (!optionFlag(options, "resolveOnly")) clearMobileReleaseArtifactState(target);
31
33
  }
32
34
  const result = await surface.runwayProvision.run(target, {
33
35
  json,