@deeeed/metamask-harness 0.41.0 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
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
+
5
11
  ## 0.41.0 - 2026-08-19
6
12
 
7
13
  ### Added
@@ -39,9 +39,25 @@
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
+ },
45
61
  {
46
62
  "id": "mobile/metro-config",
47
63
  "entry": "adapters/mobile/metro-config.cjs",
@@ -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,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
+ }
@@ -68,6 +68,8 @@ mkdir -p "$LOG_DIR"
68
68
  LOG_FILE="$LOG_DIR/metro.log"
69
69
  PID_FILE="$LOG_DIR/metro.pid"
70
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"
71
73
 
72
74
  # --- helpers ------------------------------------------------------------------
73
75
 
@@ -172,23 +174,21 @@ write_metro_runner() {
172
174
  #!/usr/bin/env bash
173
175
  set -uo pipefail
174
176
  cd "$(printf '%q' "$TARGET")"
175
- : > "$(printf '%q' "$LOG_FILE")"
176
177
  export EXPO_NO_TYPESCRIPT_SETUP=1
177
178
  export WATCHER_PORT="$(printf '%q' "$PORT")" METRO_PORT="$(printf '%q' "$PORT")"
178
179
  $worker_env_line
179
- 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")"
180
181
  EOF
181
182
  chmod +x "$runner"
182
183
  }
183
184
 
184
185
  start_metro_tmux() {
186
+ local runner="$1"
185
187
  command -v tmux >/dev/null 2>&1 || return 1
186
- local session window runner
188
+ local session window
187
189
  session="$(resolve_run_tmux_session "$LOG_DIR")"
188
190
  { [ -n "$session" ] && tmux has-session -t "=$session" 2>/dev/null; } || return 1
189
191
  window="metro-${PORT}"
190
- runner="$LOG_DIR/run-metro-${PORT}.sh"
191
- write_metro_runner "$runner"
192
192
  tmux kill-window -t "${session}:${window}" >/dev/null 2>&1 || true
193
193
  tmux new-window -d -t "$session" -n "$window" "exec $(printf '%q' "$runner")" || return 1
194
194
  printf '%s:%s\n' "$session" "$window" > "$LOG_DIR/metro.tmux"
@@ -249,6 +249,14 @@ else
249
249
  fi
250
250
  printf '(Full log: %s — mm-harness logs | mm-harness logs --full)\n' "$LOG_FILE" >&2
251
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
+
252
260
  write_metro_build_env || {
253
261
  printf 'start-metro: could not stage the scoped Metro environment\n' >&2
254
262
  exit 1
@@ -256,13 +264,14 @@ write_metro_build_env || {
256
264
 
257
265
  STARTED_METRO_PID=""
258
266
  STARTED_METRO_TMUX=""
259
- 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
260
270
  STARTED_METRO_TMUX="$(cat "$LOG_DIR/metro.tmux" 2>/dev/null || true)"
261
271
  else
262
272
  # Metro runs detached, writing to the log. The tmux window is a read-only tail.
263
273
  (
264
274
  cd "$TARGET"
265
- : > "$LOG_FILE"
266
275
  export EXPO_NO_TYPESCRIPT_SETUP=1
267
276
  export WATCHER_PORT="${PORT}" METRO_PORT="${PORT}"
268
277
  if [ -n "$METRO_WORKERS" ]; then
@@ -276,6 +285,7 @@ else
276
285
  --log "$LOG_FILE"
277
286
  --pid-file "$PID_FILE"
278
287
  --build-env "$METRO_BUILD_ENV_FILE"
288
+ --runner "$METRO_RUNNER"
279
289
  )
280
290
  [ -z "$METRO_WORKERS" ] || launcher_args+=(--workers "$METRO_WORKERS")
281
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",
@@ -53,6 +53,21 @@ const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
53
53
  "jsonStream"
54
54
  ]);
55
55
  const DEFAULT_EXTENSION_DAPP_URL = "https://metamask.github.io/test-dapp/";
56
+ function metroLaunchEvidence(adapter, target) {
57
+ if (adapter !== "mobile") return void 0;
58
+ const evidencePath = recipeRuntimePath(target, "metro-generation.json");
59
+ let descriptor;
60
+ try {
61
+ descriptor = fs.openSync(evidencePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
62
+ const stat = fs.fstatSync(descriptor);
63
+ if (!stat.isFile() || stat.size > 256 * 1024) return void 0;
64
+ return JSON.parse(fs.readFileSync(descriptor, "utf8"));
65
+ } catch {
66
+ return void 0;
67
+ } finally {
68
+ if (descriptor !== void 0) fs.closeSync(descriptor);
69
+ }
70
+ }
56
71
  async function handleLaunch(argv) {
57
72
  const { options } = parseFlags(argv, LAUNCH_BOOLEANS);
58
73
  const json = flag(options, "json");
@@ -627,14 +642,15 @@ async function finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget,
627
642
  platform: mobileTarget ?? (adapter === "extension" ? displayMode : null),
628
643
  tier,
629
644
  recovered: state.recovered,
630
- mutations: state.mutations
645
+ mutations: state.mutations,
646
+ metro: metroLaunchEvidence(adapter, target)
631
647
  });
632
648
  }
633
649
  return exitCode;
634
650
  }
635
- return launchPass(jsonOutput, stream, adapter, mobileTarget, tier, displayMode, state);
651
+ return launchPass(jsonOutput, stream, adapter, mobileTarget, tier, displayMode, target, state);
636
652
  }
637
- function launchPass(json, stream, adapter, mobileTarget, tier, displayMode, state) {
653
+ function launchPass(json, stream, adapter, mobileTarget, tier, displayMode, target, state) {
638
654
  if (stream.enabled) {
639
655
  for (const mutation of state.mutations) stream.mutation(mutation);
640
656
  for (const recovery of state.recovered) stream.recovery(recovery);
@@ -643,7 +659,8 @@ function launchPass(json, stream, adapter, mobileTarget, tier, displayMode, stat
643
659
  platform: mobileTarget ?? (adapter === "extension" ? displayMode : null),
644
660
  tier,
645
661
  recovered: state.recovered,
646
- mutations: state.mutations
662
+ mutations: state.mutations,
663
+ metro: metroLaunchEvidence(adapter, target)
647
664
  });
648
665
  } else if (json) {
649
666
  console.log(
@@ -658,6 +675,7 @@ function launchPass(json, stream, adapter, mobileTarget, tier, displayMode, stat
658
675
  phase: "launch",
659
676
  recovered: state.recovered,
660
677
  mutations: state.mutations,
678
+ metro: metroLaunchEvidence(adapter, target),
661
679
  exitCode: EXIT.ok
662
680
  },
663
681
  null,
@@ -706,7 +724,8 @@ function launchFail(json, stream, adapter, mobileTarget, tier, state, target, fa
706
724
  recovered: state.recovered,
707
725
  mutations: state.mutations,
708
726
  recoverable: failure.recoverable,
709
- attemptedRecoveries: state.attemptedRecoveries
727
+ attemptedRecoveries: state.attemptedRecoveries,
728
+ metro: metroLaunchEvidence(adapter, target)
710
729
  });
711
730
  } else if (json) {
712
731
  console.log(
@@ -723,6 +742,7 @@ function launchFail(json, stream, adapter, mobileTarget, tier, state, target, fa
723
742
  mutations: state.mutations,
724
743
  recoverable: failure.recoverable,
725
744
  attemptedRecoveries: state.attemptedRecoveries,
745
+ metro: metroLaunchEvidence(adapter, target),
726
746
  exitCode: failure.exitCode,
727
747
  error: {
728
748
  code: failure.code,
package/docs/QA.md CHANGED
@@ -124,6 +124,8 @@ mm-harness debug
124
124
  - [ ] Status matches verified product routes: `Login`/`LockScreen` are locked;
125
125
  `WalletView` is unlocked.
126
126
  - [ ] App and Metro logs are separate.
127
+ - [ ] A restarted Metro rotates the previous `metro.log`, records its generation and retention actions in launch JSON, and classifies bundle failures only from the current log.
128
+ - [ ] `mm-harness logs --full` preserves errors, completion, and meaningful timestamped bundle progress without repeated same-percentage module counts.
127
129
  - [ ] A JS edit rebuilds through Metro and appears after reload without a native
128
130
  rebuild; revert restores a clean tree.
129
131
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.41.0",
3
+ "version": "0.41.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"