@deeeed/metamask-harness 0.40.1 → 0.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/adapters/manifest.json +25 -1
  3. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +5 -4
  4. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +24 -5
  5. package/adapters/mobile/coalesce-metro-log.cjs +24 -0
  6. package/adapters/mobile/launch-metro.cjs +9 -8
  7. package/adapters/mobile/metro-config.cjs +94 -0
  8. package/adapters/mobile/metro-log-generation.cjs +106 -0
  9. package/adapters/mobile/start-metro.sh +30 -8
  10. package/adapters/mobile/stop-metro.sh +5 -6
  11. package/adapters/shared/reap-checkout-metros.sh +17 -0
  12. package/dist/adapters/mobile/runtime-decision.js +0 -21
  13. package/dist/commands/device-target.js +3 -0
  14. package/dist/commands/launch/index.js +25 -5
  15. package/dist/commands/run-engine.js +4 -14
  16. package/dist/heal-bounds.js +1 -0
  17. package/dist/live-adapter-contract.js +2 -1
  18. package/dist/mm-harness-cli.js +1 -1
  19. package/docs/QA.md +2 -0
  20. package/docs/RECIPES.md +25 -19
  21. package/library/actions/mobile/perps/capture_performance.mjs +2 -2
  22. package/library/actions/mobile/perps/performance-capture.mjs +375 -17
  23. package/library/actions/mobile/perps/perps.mjs +56 -10
  24. package/library/actions/mobile/platform/bridge.mjs +59 -1
  25. package/library/actions/mobile/wallet/ensure_unlocked.mjs +60 -26
  26. package/library/actions/mobile/wallet/import.mjs +2 -0
  27. package/library/actions/mobile/wallet/select_account.mjs +21 -2
  28. package/library/manifests/mobile.action-manifest.json +47 -6
  29. package/library/recipes/mobile/perps/performance.recipe.json +680 -26
  30. package/package.json +1 -1
  31. package/library/recipes/mobile/perps/performance.background-resume.recipe.json +0 -56
  32. package/library/recipes/mobile/perps/performance.cold-start.recipe.json +0 -56
  33. package/library/recipes/mobile/perps/performance.homepage.android-background-reconnect.recipe.json +0 -159
  34. package/library/recipes/mobile/perps/performance.homepage.android-background-short.recipe.json +0 -157
  35. package/library/recipes/mobile/perps/performance.homepage.android-cold-disk-cache.recipe.json +0 -165
  36. package/library/recipes/mobile/perps/performance.homepage.android-cold-no-cache.recipe.json +0 -139
  37. package/library/recipes/mobile/perps/performance.homepage.android-network-recovery.recipe.json +0 -149
  38. package/library/recipes/mobile/perps/performance.homepage.cold-position-sample.recipe.json +0 -120
  39. package/library/recipes/mobile/perps/performance.homepage.ios-background-reconnect.recipe.json +0 -111
  40. package/library/recipes/mobile/perps/performance.homepage.ios-background-short.recipe.json +0 -108
  41. package/library/recipes/mobile/perps/performance.homepage.ios-cold-disk-cache.recipe.json +0 -122
  42. package/library/recipes/mobile/perps/performance.homepage.ios-cold-no-cache.recipe.json +0 -95
  43. package/library/recipes/mobile/perps/performance.warm-start.recipe.json +0 -49
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.41.1 - 2026-08-19
6
+
7
+ ### Fixed
8
+
9
+ - Rotate each harness-owned Metro start into a run-scoped evidence generation, bound retained archives, coalesce repetitive bundle progress, and keep bundle-error recovery state inside one launch invocation.
10
+
11
+ ## 0.41.0 - 2026-08-19
12
+
13
+ ### Added
14
+
15
+ - Add one parameterized, platform-neutral Mobile Perps performance recipe covering cold, cached, resume, reconnect, account-switch, and network-switch lifecycles with native visible-content proof and production loading/WebSocket evidence.
16
+
17
+ ### Changed
18
+
19
+ - Replace the duplicated Android, iOS, cold, warm, and background Perps performance recipes with the single lifecycle recipe.
20
+
21
+ ### Fixed
22
+
23
+ - Keep explicitly pinned iOS and Android bridge targets isolated when both platforms are active, and classify bounded CDP target failures as infrastructure failures.
24
+ - Make Mobile wallet unlock and account selection deterministic across app restarts, propagate recipe timeouts through Perps state setup, and preserve missing performance counts and offsets as unknown instead of zero.
25
+ - Keep the entire checkout-local `temp` tree outside Metro watching during canonical `yarn watch` launches, and leave the Mobile recipe HUD visible by default for performance lifecycles.
26
+
5
27
  ## 0.40.1 - 2026-08-16
6
28
 
7
29
  ### Fixed
@@ -39,9 +39,33 @@
39
39
  "entry": "adapters/mobile/launch-metro.cjs",
40
40
  "kind": "node",
41
41
  "purpose": "Launch Metro detached from the invoking process group so the runtime survives command cleanup.",
42
- "inputs": "--target --port --log --pid-file [--workers] [--clear]",
42
+ "inputs": "--target --port --log --pid-file --build-env --runner [--workers] [--clear]",
43
43
  "outputs": "Metro PID/launch metadata and redirected log; exit 0/1/2"
44
44
  },
45
+ {
46
+ "id": "mobile/metro-log-generation",
47
+ "entry": "adapters/mobile/metro-log-generation.cjs",
48
+ "kind": "module",
49
+ "purpose": "Rotate one Metro generation, retain bounded archives, and publish launch evidence.",
50
+ "inputs": "runtime directory, Metro port, rotation reason; env MM_HARNESS_METRO_LOG_ARCHIVE_COUNT, MM_HARNESS_METRO_LOG_ARCHIVE_BYTES",
51
+ "outputs": "metro.log, archived logs, metro-generation.json"
52
+ },
53
+ {
54
+ "id": "mobile/coalesce-metro-log",
55
+ "entry": "adapters/mobile/coalesce-metro-log.cjs",
56
+ "kind": "module",
57
+ "purpose": "Coalesce repetitive Metro percentage progress while preserving diagnostics.",
58
+ "inputs": "Metro output on stdin",
59
+ "outputs": "errors, completion, and timestamped meaningful progress on stdout"
60
+ },
61
+ {
62
+ "id": "mobile/metro-config",
63
+ "entry": "adapters/mobile/metro-config.cjs",
64
+ "kind": "module",
65
+ "purpose": "Preserve the product Metro config while excluding the checkout-local temp tree owned by harness evidence and runtime state.",
66
+ "inputs": "base Metro config; env MM_HARNESS_METRO_PROJECT_ROOT, MM_HARNESS_METRO_BASE_CONFIG",
67
+ "outputs": "merged Metro config with resolver.blockList for <projectRoot>/temp"
68
+ },
45
69
  {
46
70
  "id": "mobile/stop-metro",
47
71
  "entry": "adapters/mobile/stop-metro.sh",
@@ -30,12 +30,13 @@ const {
30
30
  const { cdpEval, cdpEvalAsync } = require('./lib/cdp-eval.cjs');
31
31
  const { buildArmSnippet, buildCollectSnippet } = require('./lib/issue-capture.cjs');
32
32
 
33
- function parseHomepagePerformanceConsoleEvent(params) {
34
- const marker = '[HomepagePerf] ';
33
+ function parsePerformanceConsoleEvent(params) {
34
+ const markers = ['[PerpsPerf] ', '[HomepagePerf] '];
35
35
  for (const arg of params?.args || []) {
36
36
  const text = typeof arg?.value === 'string' ? arg.value : '';
37
+ const marker = markers.find((candidate) => text.includes(candidate));
38
+ if (!marker) continue;
37
39
  const markerIndex = text.indexOf(marker);
38
- if (markerIndex < 0) continue;
39
40
  try {
40
41
  return JSON.parse(text.slice(markerIndex + marker.length));
41
42
  } catch {}
@@ -490,7 +491,7 @@ const COMMANDS = {
490
491
  let removeConsoleListener = null;
491
492
  if (visibleEventStage) {
492
493
  removeConsoleListener = client.on('Runtime.consoleAPICalled', (params) => {
493
- const payload = parseHomepagePerformanceConsoleEvent(params);
494
+ const payload = parsePerformanceConsoleEvent(params);
494
495
  if (payload?.stage === visibleEventStage) visibleEvents.push(payload);
495
496
  });
496
497
  await client.send('Runtime.enable');
@@ -145,6 +145,9 @@ async function discoverTarget(port) {
145
145
  const adbSerial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || '';
146
146
  const androidPinned = Boolean(androidTargetName || androidDevice);
147
147
  const simName = loadSimulatorName();
148
+ const iosPinned =
149
+ process.env.MM_HARNESS_EXPLICIT_PLATFORM === 'ios' ||
150
+ Boolean(process.env.SIM_UDID);
148
151
  const matchesAndroidPin = (target) => androidTargetName
149
152
  ? target.deviceName !== simName &&
150
153
  (target.deviceName === androidTargetName ||
@@ -166,6 +169,18 @@ async function discoverTarget(port) {
166
169
  }
167
170
  return false;
168
171
  }
172
+ if (iosPinned && simName) {
173
+ const pinnedCandidates = runtimeCandidates.filter(
174
+ (candidate) => candidate.deviceName === simName,
175
+ );
176
+ for (const candidate of pinnedCandidates) {
177
+ if (await probeTarget(candidate.webSocketDebuggerUrl)) {
178
+ acceptedPinnedCandidate = candidate;
179
+ return true;
180
+ }
181
+ }
182
+ return false;
183
+ }
169
184
  return true;
170
185
  });
171
186
  } catch (e) {
@@ -193,12 +208,16 @@ async function discoverTarget(port) {
193
208
  // context, and letting it win here sends a pinned android action to the iOS
194
209
  // target. No-match keeps the full candidate set (ambient sim configs tolerate a
195
210
  // sim that is not currently attached).
196
- if (simName && !androidPinned && candidates.length > 1) {
211
+ if (simName && !androidPinned) {
197
212
  const deviceFiltered = candidates.filter(
198
213
  (t) => t.deviceName === simName,
199
214
  );
200
215
  if (deviceFiltered.length > 0) {
201
216
  candidates = deviceFiltered;
217
+ } else if (iosPinned) {
218
+ throw coded(new Error(
219
+ `Pinned iOS simulator '${simName}' did not match any Metro target.`,
220
+ ), BRIDGE_ERROR_CODES.NO_TARGET);
202
221
  }
203
222
  }
204
223
 
@@ -306,15 +325,15 @@ async function discoverTarget(port) {
306
325
  }
307
326
  }
308
327
 
309
- if (androidPinned) {
328
+ if (androidPinned || iosPinned) {
310
329
  const candidateList = candidates
311
330
  .map((target) => ` deviceName=${JSON.stringify(target.deviceName || '')} ws=${target.webSocketDebuggerUrl || ''}`)
312
331
  .join('\n');
313
332
  throw coded(new Error(
314
- `Pinned Android device has no responding __AGENTIC__ JS runtime after ${FETCH_RETRIES} discovery attempt(s).\n` +
315
- ` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` +
333
+ `Pinned ${androidPinned ? 'Android device' : `iOS simulator '${simName || process.env.SIM_UDID || '(unknown)'}'`} has no responding __AGENTIC__ JS runtime after ${FETCH_RETRIES} discovery attempt(s).\n` +
334
+ (androidPinned ? ` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` : '') +
316
335
  ` Matching non-agentic targets:\n${candidateList}\n` +
317
- ` The native C++ page is not a valid recipe target; wait for Android Hermes to re-register or relaunch the Android app.`,
336
+ ` The native C++ page is not a valid recipe target; wait for Hermes to re-register or relaunch the pinned app.`,
318
337
  ), BRIDGE_ERROR_CODES.NO_TARGET);
319
338
  }
320
339
 
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const readline = require('node:readline');
5
+
6
+ const progressPattern = /^\s*(?:iOS|Android).*?(\d{1,3}(?:\.\d+)?)%/u;
7
+ let lastPercent = null;
8
+ let lastProgressAt = 0;
9
+
10
+ const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
11
+ lines.on('line', (line) => {
12
+ const progress = progressPattern.exec(line);
13
+ if (!progress) {
14
+ process.stdout.write(`${line}\n`);
15
+ return;
16
+ }
17
+ const percent = Math.floor(Number(progress[1]));
18
+ const now = Date.now();
19
+ if (percent !== lastPercent || now - lastProgressAt >= 15_000) {
20
+ process.stdout.write(`${line} [metro-progress ${new Date(now).toISOString()}]\n`);
21
+ lastPercent = percent;
22
+ lastProgressAt = now;
23
+ }
24
+ });
@@ -26,33 +26,34 @@ function parseArgs(argv) {
26
26
  async function main() {
27
27
  const args = parseArgs(process.argv.slice(2));
28
28
  if (args.help) {
29
- console.log('Usage: launch-metro.cjs --target <path> --port <port> --log <path> --pid-file <path> --build-env <path> [--workers <n>] [--clear]');
29
+ console.log('Usage: launch-metro.cjs --target <path> --port <port> --log <path> --pid-file <path> --build-env <path> --runner <path> [--workers <n>] [--clear]');
30
30
  return;
31
31
  }
32
- for (const required of ['target', 'port', 'log', 'pid-file', 'build-env']) {
32
+ for (const required of ['target', 'port', 'log', 'pid-file', 'build-env', 'runner']) {
33
33
  if (!args[required]) throw new Error(`--${required} is required`);
34
34
  }
35
35
  const target = path.resolve(args.target);
36
36
  const log = path.resolve(args.log);
37
37
  const pidFile = path.resolve(args['pid-file']);
38
+ const runner = path.resolve(args.runner);
38
39
  fs.mkdirSync(path.dirname(log), { recursive: true });
39
40
  fs.mkdirSync(path.dirname(pidFile), { recursive: true });
40
- const logFd = fs.openSync(log, 'a');
41
- const childArgs = ['watch'];
41
+ const runnerStat = fs.lstatSync(runner);
42
+ if (!runnerStat.isFile()) throw new Error('--runner must be a regular file');
43
+ fs.accessSync(runner, fs.constants.X_OK);
42
44
  const env = { ...process.env };
43
45
  env.BASH_ENV = args['build-env'];
44
46
  if (args.workers) env.METRO_MAX_WORKERS = String(args.workers);
45
- const child = spawn('yarn', childArgs, {
47
+ const child = spawn(runner, [], {
46
48
  cwd: target,
47
49
  detached: true,
48
50
  env,
49
- stdio: ['ignore', logFd, logFd],
51
+ stdio: 'ignore',
50
52
  });
51
53
  await new Promise((resolve, reject) => {
52
54
  child.once('spawn', resolve);
53
55
  child.once('error', reject);
54
56
  });
55
- fs.closeSync(logFd);
56
57
  fs.writeFileSync(pidFile, `${String(child.pid)}\n`);
57
58
  fs.writeFileSync(
58
59
  path.join(path.dirname(pidFile), 'metro-launch.json'),
@@ -64,7 +65,7 @@ async function main() {
64
65
  target,
65
66
  port: Number(args.port),
66
67
  clear: Boolean(args.clear),
67
- command: `yarn ${childArgs[0]}`,
68
+ command: runner,
68
69
  }, null, 2)}\n`,
69
70
  );
70
71
  child.unref();
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const { createRequire } = require('node:module');
5
+ const path = require('node:path');
6
+ const { pathToFileURL } = require('node:url');
7
+
8
+ function escapeRegExp(value) {
9
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
10
+ }
11
+
12
+ function appendBlockList(blockList, pattern) {
13
+ if (Array.isArray(blockList)) return [...blockList, pattern];
14
+ return blockList instanceof RegExp ? [blockList, pattern] : [pattern];
15
+ }
16
+
17
+ function blockListFlags(blockList) {
18
+ const first = Array.isArray(blockList) ? blockList[0] : blockList;
19
+ return first instanceof RegExp && first.ignoreCase ? 'i' : '';
20
+ }
21
+
22
+ function resolveExpoConfigLoader(projectRoot) {
23
+ const requireFromProject = createRequire(path.join(projectRoot, 'package.json'));
24
+ let requireUtilsPath;
25
+ try {
26
+ requireUtilsPath = requireFromProject.resolve('@expo/require-utils');
27
+ } catch (error) {
28
+ if (error?.code !== 'MODULE_NOT_FOUND') throw error;
29
+ return null;
30
+ }
31
+ const { loadModuleSync } = requireFromProject(requireUtilsPath);
32
+ return typeof loadModuleSync === 'function' ? loadModuleSync : null;
33
+ }
34
+
35
+ async function loadProductConfig(configPath, baseConfig, projectRoot) {
36
+ let loaded;
37
+ const expoConfigLoader = resolveExpoConfigLoader(projectRoot);
38
+ if (expoConfigLoader) {
39
+ loaded = expoConfigLoader(configPath);
40
+ } else {
41
+ try {
42
+ loaded = require(configPath);
43
+ } catch (error) {
44
+ if (error?.code !== 'ERR_REQUIRE_ESM') throw error;
45
+ loaded = await import(pathToFileURL(configPath).href);
46
+ }
47
+ }
48
+
49
+ const moduleValue = await loaded;
50
+ const exported =
51
+ moduleValue?.__esModule || moduleValue?.[Symbol.toStringTag] === 'Module'
52
+ ? moduleValue.default
53
+ : moduleValue;
54
+ const config = await exported;
55
+ return typeof config === 'function' ? config(baseConfig) : config;
56
+ }
57
+
58
+ module.exports = async function harnessMetroConfig(baseConfig) {
59
+ const projectRoot = path.resolve(
60
+ process.env.MM_HARNESS_METRO_PROJECT_ROOT || process.cwd(),
61
+ );
62
+ const productConfigPath = path.resolve(
63
+ process.env.MM_HARNESS_METRO_BASE_CONFIG ||
64
+ path.join(projectRoot, 'metro.config.js'),
65
+ );
66
+ if (productConfigPath === __filename) {
67
+ throw new Error(
68
+ 'MM_HARNESS_METRO_BASE_CONFIG must reference the product Metro config, not the harness wrapper.',
69
+ );
70
+ }
71
+ let productConfig = baseConfig;
72
+ if (fs.existsSync(productConfigPath)) {
73
+ productConfig = await loadProductConfig(
74
+ productConfigPath,
75
+ baseConfig,
76
+ projectRoot,
77
+ );
78
+ }
79
+
80
+ const tempRoot = path.join(projectRoot, 'temp');
81
+ const existingBlockList =
82
+ productConfig.resolver?.blockList ?? baseConfig.resolver?.blockList;
83
+ const tempPattern = new RegExp(
84
+ `^${escapeRegExp(tempRoot)}(?:${escapeRegExp(path.sep)}|$)`,
85
+ blockListFlags(existingBlockList),
86
+ );
87
+ return {
88
+ ...productConfig,
89
+ resolver: {
90
+ ...productConfig.resolver,
91
+ blockList: appendBlockList(existingBlockList, tempPattern),
92
+ },
93
+ };
94
+ };
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const crypto = require('node:crypto');
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+
8
+ const generationIdPattern = /^[A-Za-z0-9-]+$/u;
9
+
10
+ function archiveGenerationId(name) {
11
+ if (!name.startsWith('metro.') || !name.endsWith('.log')) return null;
12
+ const generationId = name.slice('metro.'.length, -'.log'.length);
13
+ return generationIdPattern.test(generationId) ? generationId : null;
14
+ }
15
+
16
+ function positiveInteger(name, fallback) {
17
+ const raw = process.env[name];
18
+ if (raw === undefined || raw === '') return fallback;
19
+ if (!/^[1-9][0-9]*$/u.test(raw)) throw new Error(`${name} must be a positive integer`);
20
+ return Number(raw);
21
+ }
22
+
23
+ function main() {
24
+ const runtimeArg = process.argv[2];
25
+ const port = process.argv[3];
26
+ const reason = process.argv[4];
27
+ if (!runtimeArg || !/^[1-9][0-9]*$/u.test(port ?? '') || !reason) {
28
+ throw new Error('usage: metro-log-generation.cjs <runtime-dir> <port> <reason>');
29
+ }
30
+ const runtimeDir = fs.realpathSync(runtimeArg);
31
+ const activeLog = path.join(runtimeDir, 'metro.log');
32
+ const evidenceFile = path.join(runtimeDir, 'metro-generation.json');
33
+ const maxArchives = positiveInteger('MM_HARNESS_METRO_LOG_ARCHIVE_COUNT', 4);
34
+ const maxArchiveBytes = positiveInteger('MM_HARNESS_METRO_LOG_ARCHIVE_BYTES', 8 * 1024 * 1024);
35
+ const generationId = `${new Date().toISOString().replace(/[-:.]/gu, '')}-${port}-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
36
+ if (!generationIdPattern.test(generationId)) throw new Error('generated Metro log identity is invalid');
37
+ let rotatedLog = null;
38
+
39
+ rotatedLog = path.join(runtimeDir, `metro.${generationId}.log`);
40
+ try {
41
+ fs.renameSync(activeLog, rotatedLog);
42
+ const stat = fs.lstatSync(rotatedLog);
43
+ if (!stat.isFile()) {
44
+ fs.renameSync(rotatedLog, activeLog);
45
+ throw new Error('metro.log must be a regular file');
46
+ }
47
+ } catch (error) {
48
+ if (error && error.code === 'ENOENT') rotatedLog = null;
49
+ else throw error;
50
+ }
51
+ try {
52
+ fs.closeSync(fs.openSync(activeLog, 'wx', 0o600));
53
+ } catch (error) {
54
+ if (error && error.code === 'EEXIST') throw new Error('another Metro generation recreated metro.log; refusing to truncate it');
55
+ throw error;
56
+ }
57
+
58
+ const archives = fs.readdirSync(runtimeDir)
59
+ .filter((name) => archiveGenerationId(name) !== null)
60
+ .map((name) => {
61
+ const file = path.join(runtimeDir, name);
62
+ const stat = fs.lstatSync(file);
63
+ if (!stat.isFile()) return null;
64
+ return { file, size: stat.size, mtimeMs: stat.mtimeMs };
65
+ })
66
+ .filter(Boolean)
67
+ .sort((left, right) => right.mtimeMs - left.mtimeMs || right.file.localeCompare(left.file));
68
+
69
+ let retainedBytes = 0;
70
+ let retainedCount = 0;
71
+ const retained = [];
72
+ const removed = [];
73
+ for (const archive of archives) {
74
+ if (retainedCount < maxArchives && retainedBytes + archive.size <= maxArchiveBytes) {
75
+ retained.push(archive.file);
76
+ retainedCount += 1;
77
+ retainedBytes += archive.size;
78
+ continue;
79
+ }
80
+ fs.unlinkSync(archive.file);
81
+ removed.push({ path: archive.file, size: archive.size, reason: retainedCount >= maxArchives ? 'count-limit' : 'size-limit' });
82
+ }
83
+
84
+ const evidence = {
85
+ schemaVersion: 1,
86
+ generationId,
87
+ port: Number(port),
88
+ startedAt: new Date().toISOString(),
89
+ currentLog: activeLog,
90
+ rotatedLog: rotatedLog && fs.existsSync(rotatedLog) ? rotatedLog : null,
91
+ archivedLogs: retained,
92
+ rotationReason: reason,
93
+ retention: { maxArchives, maxArchiveBytes, retainedBytes, removed },
94
+ };
95
+ const temporary = `${evidenceFile}.${process.pid}.tmp`;
96
+ fs.writeFileSync(temporary, `${JSON.stringify(evidence, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
97
+ fs.renameSync(temporary, evidenceFile);
98
+ process.stdout.write(`${JSON.stringify(evidence)}\n`);
99
+ }
100
+
101
+ try {
102
+ main();
103
+ } catch (error) {
104
+ console.error(`metro-log-generation: ${error instanceof Error ? error.message : String(error)}`);
105
+ process.exit(1);
106
+ }
@@ -50,7 +50,16 @@ fi
50
50
  # only ever inspect or signal a Metro listening on $PORT.
51
51
  # shellcheck disable=SC1091
52
52
  . "$SCRIPT_DIR/lib/metro-listener.sh"
53
- MOBILE_METRO_REQUIRED_ENV="${MOBILE_METRO_REQUIRED_ENV:-MM_INFURA_PROJECT_ID METAMASK_BUILD_TYPE METAMASK_ENVIRONMENT}"
53
+ HARNESS_METRO_CONFIG="$SCRIPT_DIR/metro-config.cjs"
54
+ if [ "${EXPO_OVERRIDE_METRO_CONFIG:-}" = "$HARNESS_METRO_CONFIG" ]; then
55
+ PRODUCT_METRO_CONFIG="${MM_HARNESS_METRO_BASE_CONFIG:-$TARGET/metro.config.js}"
56
+ else
57
+ PRODUCT_METRO_CONFIG="${EXPO_OVERRIDE_METRO_CONFIG:-$TARGET/metro.config.js}"
58
+ fi
59
+ export MM_HARNESS_METRO_PROJECT_ROOT="$TARGET"
60
+ export MM_HARNESS_METRO_BASE_CONFIG="$PRODUCT_METRO_CONFIG"
61
+ export EXPO_OVERRIDE_METRO_CONFIG="$HARNESS_METRO_CONFIG"
62
+ MOBILE_METRO_REQUIRED_ENV="${MOBILE_METRO_REQUIRED_ENV:-MM_INFURA_PROJECT_ID METAMASK_BUILD_TYPE METAMASK_ENVIRONMENT} MM_HARNESS_METRO_PROJECT_ROOT MM_HARNESS_METRO_BASE_CONFIG EXPO_OVERRIDE_METRO_CONFIG"
54
63
 
55
64
  # RECIPE_RUNTIME_DIR (relative, validated) makes runtime state per-run so jobs
56
65
  # sharing one checkout do not collide on metro.log / metro.pid / metro.tmux.
@@ -59,6 +68,8 @@ mkdir -p "$LOG_DIR"
59
68
  LOG_FILE="$LOG_DIR/metro.log"
60
69
  PID_FILE="$LOG_DIR/metro.pid"
61
70
  METRO_BUILD_ENV_FILE="$LOG_DIR/metro-build-env.sh"
71
+ METRO_LOG_GENERATION="$SCRIPT_DIR/metro-log-generation.cjs"
72
+ METRO_LOG_COALESCER="$SCRIPT_DIR/coalesce-metro-log.cjs"
62
73
 
63
74
  # --- helpers ------------------------------------------------------------------
64
75
 
@@ -88,6 +99,9 @@ write_metro_build_env() {
88
99
  done
89
100
  )" || { rm -f "$temporary"; return 1; }
90
101
  eval "$product_values"
102
+ required_MM_HARNESS_METRO_PROJECT_ROOT="$MM_HARNESS_METRO_PROJECT_ROOT"
103
+ required_MM_HARNESS_METRO_BASE_CONFIG="$MM_HARNESS_METRO_BASE_CONFIG"
104
+ required_EXPO_OVERRIDE_METRO_CONFIG="$EXPO_OVERRIDE_METRO_CONFIG"
91
105
  [ "$CLEAR" = true ] && clear_value=1
92
106
  {
93
107
  printf 'export MM_HARNESS_METRO_WATCHER_PORT=%q\n' "$PORT"
@@ -160,23 +174,21 @@ write_metro_runner() {
160
174
  #!/usr/bin/env bash
161
175
  set -uo pipefail
162
176
  cd "$(printf '%q' "$TARGET")"
163
- : > "$(printf '%q' "$LOG_FILE")"
164
177
  export EXPO_NO_TYPESCRIPT_SETUP=1
165
178
  export WATCHER_PORT="$(printf '%q' "$PORT")" METRO_PORT="$(printf '%q' "$PORT")"
166
179
  $worker_env_line
167
- BASH_ENV=$(printf '%q' "$METRO_BUILD_ENV_FILE") command yarn $(printf '%q' "$watcher_script") 2>&1 | tee -a "$(printf '%q' "$LOG_FILE")"
180
+ BASH_ENV=$(printf '%q' "$METRO_BUILD_ENV_FILE") command yarn $(printf '%q' "$watcher_script") 2>&1 | node $(printf '%q' "$METRO_LOG_COALESCER") | tee -a "$(printf '%q' "$LOG_FILE")"
168
181
  EOF
169
182
  chmod +x "$runner"
170
183
  }
171
184
 
172
185
  start_metro_tmux() {
186
+ local runner="$1"
173
187
  command -v tmux >/dev/null 2>&1 || return 1
174
- local session window runner
188
+ local session window
175
189
  session="$(resolve_run_tmux_session "$LOG_DIR")"
176
190
  { [ -n "$session" ] && tmux has-session -t "=$session" 2>/dev/null; } || return 1
177
191
  window="metro-${PORT}"
178
- runner="$LOG_DIR/run-metro-${PORT}.sh"
179
- write_metro_runner "$runner"
180
192
  tmux kill-window -t "${session}:${window}" >/dev/null 2>&1 || true
181
193
  tmux new-window -d -t "$session" -n "$window" "exec $(printf '%q' "$runner")" || return 1
182
194
  printf '%s:%s\n' "$session" "$window" > "$LOG_DIR/metro.tmux"
@@ -237,6 +249,14 @@ else
237
249
  fi
238
250
  printf '(Full log: %s — mm-harness logs | mm-harness logs --full)\n' "$LOG_FILE" >&2
239
251
 
252
+ [ -f "$METRO_LOG_GENERATION" ] && [ -f "$METRO_LOG_COALESCER" ] || {
253
+ printf 'start-metro: Metro log generation helpers are missing; reinstall the runner\n' >&2
254
+ exit 1
255
+ }
256
+ ROTATION_REASON="metro-start"
257
+ [ "$CLEAR" = true ] && ROTATION_REASON="clear-cache-restart"
258
+ node "$METRO_LOG_GENERATION" "$LOG_DIR" "$PORT" "$ROTATION_REASON" >/dev/null || exit 1
259
+
240
260
  write_metro_build_env || {
241
261
  printf 'start-metro: could not stage the scoped Metro environment\n' >&2
242
262
  exit 1
@@ -244,13 +264,14 @@ write_metro_build_env || {
244
264
 
245
265
  STARTED_METRO_PID=""
246
266
  STARTED_METRO_TMUX=""
247
- if start_metro_tmux; then
267
+ METRO_RUNNER="$LOG_DIR/run-metro-${PORT}.sh"
268
+ write_metro_runner "$METRO_RUNNER"
269
+ if start_metro_tmux "$METRO_RUNNER"; then
248
270
  STARTED_METRO_TMUX="$(cat "$LOG_DIR/metro.tmux" 2>/dev/null || true)"
249
271
  else
250
272
  # Metro runs detached, writing to the log. The tmux window is a read-only tail.
251
273
  (
252
274
  cd "$TARGET"
253
- : > "$LOG_FILE"
254
275
  export EXPO_NO_TYPESCRIPT_SETUP=1
255
276
  export WATCHER_PORT="${PORT}" METRO_PORT="${PORT}"
256
277
  if [ -n "$METRO_WORKERS" ]; then
@@ -264,6 +285,7 @@ else
264
285
  --log "$LOG_FILE"
265
286
  --pid-file "$PID_FILE"
266
287
  --build-env "$METRO_BUILD_ENV_FILE"
288
+ --runner "$METRO_RUNNER"
267
289
  )
268
290
  [ -z "$METRO_WORKERS" ] || launcher_args+=(--workers "$METRO_WORKERS")
269
291
  [ "$CLEAR" = true ] && launcher_args+=(--clear)
@@ -83,15 +83,14 @@ if [ -n "$fwd_pid" ]; then
83
83
  esac
84
84
  fi
85
85
  rm -f "$FWD_PID_FILE"
86
- pkill -f "console-forwarder.cjs --port .* --out $TARGET/" 2>/dev/null
86
+ pkill -f "console-forwarder.cjs --port $PORT --out $TARGET/" 2>/dev/null
87
87
 
88
- # Port-scoped stop above misses bundlers a prior launch left on a DIFFERENT port
89
- # (port drift / missing pid file). Sweep every Metro bound to this checkout so
90
- # stop fully cleans up the leak that stacked bundlers across relaunches.
91
- # Guarded: the reap lib may be absent in a minimal install; skip rather than abort.
88
+ # Reap a detached bundler for this exact port when its listener disappeared
89
+ # before the normal stop path. Other ports in the same checkout are separate
90
+ # runtime generations and must remain untouched.
92
91
  if [ -f "$SCRIPT_DIR/../shared/reap-checkout-metros.sh" ]; then
93
92
  . "$SCRIPT_DIR/../shared/reap-checkout-metros.sh"
94
- reap_checkout_metros "$TARGET" || true
93
+ reap_checkout_metros_on_port "$TARGET" "$PORT" || true
95
94
  fi
96
95
 
97
96
  # Close the read-only log-tail window start-metro opened, if it is still there.
@@ -40,6 +40,23 @@ LIST
40
40
  return 0
41
41
  }
42
42
 
43
+ reap_checkout_metros_on_port() {
44
+ local repo="$1" port="$2" reaped="" pid args
45
+ [ -n "$repo" ] && [ -n "$port" ] || return 0
46
+ while IFS= read -r pid; do
47
+ [ -n "$pid" ] || continue
48
+ args="$(ps -o args= -p "$pid" 2>/dev/null || true)"
49
+ case " $args " in *" --port $port "*|*" --port=$port "*) ;; *) continue ;; esac
50
+ kill "$pid" 2>/dev/null && reaped="$reaped $pid"
51
+ done <<LIST
52
+ $(_checkout_metro_pids "$repo")
53
+ LIST
54
+ if [ -n "$reaped" ]; then
55
+ printf 'Reaped leaked Metro bundler(s) for checkout port %s:%s\n' "$port" "$reaped" >&2
56
+ fi
57
+ return 0
58
+ }
59
+
43
60
  detect_checkout_metros() {
44
61
  local repo="$1" live_port="${2:-}" pid args
45
62
  [ -n "$repo" ] || return 0
@@ -2,13 +2,11 @@ import { execFileSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import {
5
- clearDecisionState,
6
5
  depsCheck,
7
6
  recordDepsBaseline
8
7
  } from "@farmslot/recipe-harness/runtime/deps-readiness";
9
8
  import {
10
9
  analyzeBundleLog,
11
- evaluatePersistentBundleError,
12
10
  supersededErrorCapture
13
11
  } from "@farmslot/recipe-harness/runtime/log-analysis";
14
12
  import { probeMetroPackager } from "@farmslot/recipe-harness/runtime/metro-probe";
@@ -160,9 +158,6 @@ async function computeMobileReadiness(resolved, options, fast) {
160
158
  }
161
159
  const metroLog = metroLogCheck(resolved, options.metroLog);
162
160
  const metro = options.watcherPort ? await probeMetroPackager(options.watcherPort) : { status: "skipped" };
163
- if (metroLog.status === "ok") {
164
- clearDecisionState(resolved, "bundle-error-state.json");
165
- }
166
161
  const checks = { deps, metroLog, metro };
167
162
  if (deps.status === "missing") {
168
163
  return {
@@ -229,22 +224,6 @@ async function computeMobileReadiness(resolved, options, fast) {
229
224
  const depsSatisfied = deps.status === "current";
230
225
  if (depsSatisfied && (metroLog.reason === "stale-bundle-error" || metroLog.reason === "bundle-error")) {
231
226
  const excerpt = metroLog.excerpt ?? "";
232
- const persistent = evaluatePersistentBundleError(resolved, excerpt);
233
- if (persistent.blocked) {
234
- return {
235
- schemaVersion: 1,
236
- adapter: "mobile",
237
- target: resolved,
238
- decision: "blocked",
239
- reasonCode: "bundle-error-persistent",
240
- reasons: [
241
- "Metro bundle keeps failing with the same error after a cache-cleared relaunch was already suggested; fix the bundle error in app code before retrying recipe up.",
242
- ...excerpt ? [excerpt] : []
243
- ],
244
- checks,
245
- actions: []
246
- };
247
- }
248
227
  return {
249
228
  schemaVersion: 1,
250
229
  adapter: "mobile",
@@ -26,6 +26,7 @@ function resolveAndroidModel(serial) {
26
26
  }
27
27
  function setAndroidDeviceEnv(id, fallbackName) {
28
28
  process.env.PLATFORM = "android";
29
+ process.env.MM_HARNESS_EXPLICIT_PLATFORM = "android";
29
30
  process.env.ADB_SERIAL = id;
30
31
  process.env.ANDROID_SERIAL = id;
31
32
  process.env.ANDROID_DEVICE = id;
@@ -33,9 +34,11 @@ function setAndroidDeviceEnv(id, fallbackName) {
33
34
  if (model) process.env.ANDROID_TARGET_DEVICE_NAME = model;
34
35
  else delete process.env.ANDROID_TARGET_DEVICE_NAME;
35
36
  process.env.IOS_SIMULATOR = "";
37
+ process.env.SIM_UDID = "";
36
38
  }
37
39
  function setIosDeviceEnv(id, fallbackName) {
38
40
  process.env.PLATFORM = "ios";
41
+ process.env.MM_HARNESS_EXPLICIT_PLATFORM = "ios";
39
42
  process.env.IOS_SIMULATOR = fallbackName?.trim() || id;
40
43
  process.env.SIM_UDID = id;
41
44
  process.env.ADB_SERIAL = "";