@deeeed/metamask-harness 0.55.0 → 0.56.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.56.0 - 2026-09-18
6
+
7
+ ### Changed
8
+
9
+ - `prepare` says what it is doing while it does it. It now opens with the whole chain — `doctor`, `status`, `launch`, `fixtures`, `verify`, or just `doctor`, `status`, `verify` on Core — one line each, so the wait is bounded before the first step runs. The running step counts up in place (`… launch: running 1:23`), each finished one keeps how long it took (`✓ launch: pass (0:47)`), and the slow steps carry a dim note saying what they are waiting on: `can take minutes on Mobile: Metro bundle and app start` for `launch`, `under a minute` for `fixtures`. Where the checkout has a previous `sandbox.json`, each step also shows what it cost last time (`last run 6:52`) — the only honest estimate the harness has. Before this, a Mobile prepare printed `✓ status: pass` and then nothing for the minutes the launch step ran, which reads as a hang. The clock on screen is the wall clock: one timestamp taken at the spawn feeds both the ticker and the `durationMs` in the record, and the repaint holds the event loop awake so a step that stays quiet for minutes — `verify` waiting on its bridge probe — cannot leave the count frozen behind the wall. Piped output is a plain log — the list, then each step as it lands, then the finished list. `--json` is untouched: stdout stays a single document.
10
+ - `sandbox.json` records `durationMs` on every readiness step (`0` for a skipped one), so a run that felt slow can be read back step by step instead of retimed by hand.
11
+
12
+ ## 0.55.1 - 2026-09-18
13
+
14
+ ### Fixed
15
+
16
+ - `doctor` probes the registry when the running build is newer than the cached latest, instead of reporting "ahead of registry" for up to an hour after a publish.
17
+
5
18
  ## 0.55.0 - 2026-09-18
6
19
 
7
20
  ### Added
@@ -193,6 +193,7 @@ function writeDepsNote(resolved) {
193
193
  stream.write(
194
194
  `
195
195
  ${color("warn", "mm-harness note (deps):", { stream })} ${color("dim", body, { stream })}
196
+
196
197
  `
197
198
  );
198
199
  }
@@ -1,14 +1,16 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import {
5
5
  READINESS_RECORD
6
6
  } from "@farmslot/protocol";
7
+ import { color, stripAnsi } from "../cli-color.js";
7
8
  import { runnerProvenance } from "../doctor.js";
8
9
  import { detectAdapter } from "../harness.js";
9
10
  import { harnessExecutable, manifestPath, recipeRuntimeDir } from "../paths.js";
10
11
  import { optionFlag, optionString, parseArgs, targetPath, usageError } from "./parse-args.js";
11
12
  import { EXIT } from "./shared.js";
13
+ import { formatDuration } from "./status-watch.js";
12
14
  const USAGE = "mm-harness prepare [--target <checkout>] [--platform mobile|extension|core] [--mobile-platform ios|android] [--clear-metro] [--artifacts-dir <dir>] [--json]";
13
15
  const REPORT_DIR = "prepare";
14
16
  const SKIPPED_AFTER_FAILURE = "previous step failed";
@@ -26,31 +28,175 @@ function reportedError(stdout) {
26
28
  return typeof message === "string" && message ? message : void 0;
27
29
  }
28
30
  function skippedStep(id, command, reason) {
29
- return { id, command, status: "skipped", exitCode: 0, reason };
31
+ return { id, command, status: "skipped", exitCode: 0, reason, durationMs: 0 };
30
32
  }
31
- const STEP_MARK = { pass: "\u2713", fail: "\u2717", skipped: "\u2013" };
32
- function stepLine(step) {
33
- return `${STEP_MARK[step.status]} ${step.id}: ${step.status}${step.reason ? ` \u2014 ${step.reason}` : ""}`;
33
+ const STEP_MARK = {
34
+ pending: "\xB7",
35
+ running: "\u2026",
36
+ pass: "\u2713",
37
+ fail: "\u2717",
38
+ skipped: "\u2013"
39
+ };
40
+ const STEP_STYLE = {
41
+ pending: "dim",
42
+ running: "accent",
43
+ pass: "ok",
44
+ fail: "err",
45
+ skipped: "dim"
46
+ };
47
+ const STEP_HINT = {
48
+ launch: "can take minutes on Mobile: Metro bundle and app start",
49
+ fixtures: "under a minute"
50
+ };
51
+ function stepChain(platform) {
52
+ return platform === "core" ? ["doctor", "status", "verify"] : ["doctor", "status", "launch", "fixtures", "verify"];
34
53
  }
35
- function runStep(executable, target, artifactsDir, id, args, { json }) {
36
- const command = [executable, ...args].join(" ");
37
- const result = spawnSync(executable, [...args], {
38
- cwd: target,
39
- encoding: "utf8",
40
- // The update nudge is an interactive courtesy; it has no place in the
41
- // stream a recorded readiness step captures.
42
- env: { ...process.env, MM_HARNESS_NO_UPDATE_CHECK: "1" }
54
+ function lastRunDurations(recordPath) {
55
+ let parsed;
56
+ try {
57
+ parsed = JSON.parse(fs.readFileSync(recordPath, "utf8"));
58
+ } catch {
59
+ return {};
60
+ }
61
+ const steps = parsed?.steps;
62
+ if (!Array.isArray(steps)) return {};
63
+ const durations = {};
64
+ for (const step of steps) {
65
+ if (typeof step?.id === "string" && typeof step.durationMs === "number" && step.durationMs > 0) {
66
+ durations[step.id] = step.durationMs;
67
+ }
68
+ }
69
+ return durations;
70
+ }
71
+ function stepHints(chain, lastRun) {
72
+ const hints = {};
73
+ for (const id of chain) {
74
+ const previous = lastRun[id];
75
+ const parts = [];
76
+ if (STEP_HINT[id]) parts.push(STEP_HINT[id]);
77
+ if (previous !== void 0) parts.push(`last run ${formatDuration(previous)}`);
78
+ if (parts.length > 0) hints[id] = parts.join(", ");
79
+ }
80
+ return hints;
81
+ }
82
+ function stepRowLine(row, width) {
83
+ const out = (style, text) => color(style, text, { stream: process.stdout });
84
+ const running = row.state === "pending" || row.state === "running";
85
+ const elapsed = row.state === "running" && row.startedAt !== void 0 ? ` ${out("dim", formatDuration(Date.now() - row.startedAt))}` : "";
86
+ const note = running ? row.hint : row.durationMs ? formatDuration(row.durationMs) : void 0;
87
+ const mark = out(STEP_STYLE[row.state], STEP_MARK[row.state]);
88
+ return `${mark} ${out(running ? "bold" : "dim", row.id.padEnd(width))}: ${out(STEP_STYLE[row.state], row.state)}${elapsed}${row.reason ? ` \u2014 ${row.reason}` : ""}${note ? ` ${out("dim", `(${note})`)}` : ""}`;
89
+ }
90
+ function createStepView(chain, hints) {
91
+ const rows = new Map(
92
+ chain.map((id) => [id, { id, state: "pending", hint: hints[id] }])
93
+ );
94
+ const width = Math.max(...chain.map((id) => id.length));
95
+ const tty = Boolean(process.stdout.isTTY);
96
+ const block = () => {
97
+ const terminalColumns = process.stdout.columns;
98
+ const columns = tty && typeof terminalColumns === "number" && terminalColumns > 0 ? terminalColumns : Number.POSITIVE_INFINITY;
99
+ return [...rows.values()].map((row) => {
100
+ const line = stepRowLine(row, width);
101
+ return stripAnsi(line).length <= columns ? line : stripAnsi(line).slice(0, columns);
102
+ });
103
+ };
104
+ let painted = 0;
105
+ let lastFrame = "";
106
+ let ended = false;
107
+ let timer;
108
+ const repaint = () => {
109
+ const lines = block();
110
+ const frame = lines.join("\n");
111
+ if (frame === lastFrame) return;
112
+ lastFrame = frame;
113
+ const up = painted > 0 ? `\x1B[${painted}A` : "";
114
+ painted = lines.length;
115
+ process.stdout.write(`${up}${lines.map((line) => `\x1B[2K${line}`).join("\n")}
116
+ `);
117
+ };
118
+ const writeBlock = () => {
119
+ process.stdout.write(`${block().join("\n")}
120
+ `);
121
+ };
122
+ return {
123
+ begin() {
124
+ if (tty) repaint();
125
+ else writeBlock();
126
+ },
127
+ start(id, startedAt) {
128
+ const row = rows.get(id);
129
+ if (!row) return;
130
+ row.state = "running";
131
+ row.startedAt = startedAt;
132
+ if (!tty) return;
133
+ repaint();
134
+ timer = setInterval(repaint, 250);
135
+ },
136
+ finish(step) {
137
+ clearInterval(timer);
138
+ timer = void 0;
139
+ const row = rows.get(step.id);
140
+ if (!row) return;
141
+ row.state = step.status;
142
+ row.startedAt = void 0;
143
+ row.reason = step.reason;
144
+ row.durationMs = step.durationMs;
145
+ if (tty) repaint();
146
+ else process.stdout.write(`${stepRowLine(row, width)}
147
+ `);
148
+ },
149
+ // Idempotent: the caller ends the view where the final block belongs in the
150
+ // output, and a `finally` ends it again on the paths that never got there.
151
+ // The ticker holds the event loop awake, so this must run either way.
152
+ end() {
153
+ if (ended) return;
154
+ ended = true;
155
+ clearInterval(timer);
156
+ timer = void 0;
157
+ if (!tty) writeBlock();
158
+ }
159
+ };
160
+ }
161
+ function spawnStep(executable, args, target) {
162
+ return new Promise((resolve) => {
163
+ const child = spawn(executable, [...args], {
164
+ cwd: target,
165
+ stdio: ["ignore", "pipe", "pipe"],
166
+ // The update nudge is an interactive courtesy; it has no place in the
167
+ // stream a recorded readiness step captures.
168
+ env: { ...process.env, MM_HARNESS_NO_UPDATE_CHECK: "1" }
169
+ });
170
+ let stdout = "";
171
+ let stderr = "";
172
+ child.stdout?.setEncoding("utf8");
173
+ child.stderr?.setEncoding("utf8");
174
+ child.stdout?.on("data", (chunk) => {
175
+ stdout += chunk;
176
+ });
177
+ child.stderr?.on("data", (chunk) => {
178
+ stderr += chunk;
179
+ });
180
+ child.on("error", (error) => resolve({ error, status: null, stdout, stderr }));
181
+ child.on("close", (status) => resolve({ status, stdout, stderr }));
43
182
  });
183
+ }
184
+ async function runStep(executable, target, artifactsDir, id, args, { json, onSpawn }) {
185
+ const command = [executable, ...args].join(" ");
186
+ const startedAt = Date.now();
187
+ onSpawn?.(startedAt);
188
+ const result = await spawnStep(executable, args, target);
189
+ const durationMs = Date.now() - startedAt;
44
190
  if (result.error) {
45
- return { id, command, status: "fail", exitCode: 1, reason: result.error.message };
191
+ return { id, command, status: "fail", exitCode: 1, reason: result.error.message, durationMs };
46
192
  }
47
193
  const report = reportPathFor(id);
48
194
  const file = path.join(artifactsDir, report);
49
195
  fs.mkdirSync(path.dirname(file), { recursive: true });
50
- fs.writeFileSync(file, result.stdout ?? "");
196
+ fs.writeFileSync(file, result.stdout);
51
197
  const exitCode = result.status ?? 1;
52
198
  if (exitCode === 0) {
53
- return { id, command, status: "pass", exitCode, reportPath: report };
199
+ return { id, command, status: "pass", exitCode, reportPath: report, durationMs };
54
200
  }
55
201
  if (!json && result.stderr) process.stderr.write(result.stderr);
56
202
  return {
@@ -59,7 +205,8 @@ function runStep(executable, target, artifactsDir, id, args, { json }) {
59
205
  status: "fail",
60
206
  exitCode,
61
207
  reportPath: report,
62
- reason: reportedError(result.stdout) ?? `exited ${exitCode}`
208
+ reason: reportedError(result.stdout) ?? `exited ${exitCode}`,
209
+ durationMs
63
210
  };
64
211
  }
65
212
  function readDevices(artifactsDir) {
@@ -172,41 +319,56 @@ async function handlePrepare(argv) {
172
319
  Next: ${USAGE}`);
173
320
  }
174
321
  let mobilePlatform = mobilePlatformOption(optionString(options, "mobilePlatform"));
322
+ const recordPath = path.join(artifactsDir, READINESS_RECORD);
323
+ const chain = stepChain(platform);
324
+ const view = json ? void 0 : createStepView(chain, stepHints(chain, lastRunDurations(recordPath)));
325
+ view?.begin();
175
326
  const steps = [];
176
327
  const failed = () => steps.some((step) => step.status === "fail");
177
328
  const addStep = (step) => {
178
329
  steps.push(step);
179
- if (!json) console.log(stepLine(step));
330
+ view?.finish(step);
180
331
  return step;
181
332
  };
182
- const run = (id, args) => addStep(
183
- failed() ? skippedStep(id, [executable, ...args].join(" "), SKIPPED_AFTER_FAILURE) : runStep(executable, target, artifactsDir, id, args, { json })
184
- );
185
- run("doctor", ["doctor", "--fix", "--json", "--adapter", platform, ...targetArgs]);
186
- run("status", ["status", "--json", ...targetArgs]);
187
- if (platform === "mobile" && !mobilePlatform && !failed()) {
188
- mobilePlatform = mobilePlatformFromConfiguredTarget(process.env, readRuntimeContextTarget(target)) ?? mobilePlatformFromDevices(readDevices(artifactsDir));
189
- if (!mobilePlatform) return ambiguousMobileTarget(json);
190
- }
191
- if (platform === "core") {
192
- const reason = "core is headless: no app surface to launch or seed";
193
- addStep(skippedStep("launch", `${executable} launch`, reason));
194
- addStep(skippedStep("fixtures", `${executable} fixtures set`, reason));
195
- } else if (platform === "mobile") {
196
- const device = mobilePlatform ? [mobilePlatform] : [];
197
- run("launch", ["launch", ...device, ...clearMetro ? ["--clear-metro"] : [], "--verify", "--json", ...targetArgs]);
198
- run("fixtures", [
199
- "fixtures",
200
- "set",
201
- ...mobilePlatform ? ["--platform", mobilePlatform] : [],
202
- "--json",
203
- ...targetArgs
204
- ]);
205
- } else {
206
- run("launch", ["launch", "--verify", "--json", ...targetArgs]);
207
- run("fixtures", ["fixtures", "set", "--json", ...targetArgs]);
333
+ const run = async (id, args) => {
334
+ if (failed()) {
335
+ return addStep(skippedStep(id, [executable, ...args].join(" "), SKIPPED_AFTER_FAILURE));
336
+ }
337
+ const step = await runStep(executable, target, artifactsDir, id, args, {
338
+ json,
339
+ onSpawn: (startedAt) => view?.start(id, startedAt)
340
+ });
341
+ return addStep(step);
342
+ };
343
+ try {
344
+ await run("doctor", ["doctor", "--fix", "--json", "--adapter", platform, ...targetArgs]);
345
+ await run("status", ["status", "--json", ...targetArgs]);
346
+ if (platform === "mobile" && !mobilePlatform && !failed()) {
347
+ mobilePlatform = mobilePlatformFromConfiguredTarget(process.env, readRuntimeContextTarget(target)) ?? mobilePlatformFromDevices(readDevices(artifactsDir));
348
+ if (!mobilePlatform) return ambiguousMobileTarget(json);
349
+ }
350
+ if (platform === "core") {
351
+ const reason = "core is headless: no app surface to launch or seed";
352
+ addStep(skippedStep("launch", `${executable} launch`, reason));
353
+ addStep(skippedStep("fixtures", `${executable} fixtures set`, reason));
354
+ } else if (platform === "mobile") {
355
+ const device = mobilePlatform ? [mobilePlatform] : [];
356
+ await run("launch", ["launch", ...device, ...clearMetro ? ["--clear-metro"] : [], "--verify", "--json", ...targetArgs]);
357
+ await run("fixtures", [
358
+ "fixtures",
359
+ "set",
360
+ ...mobilePlatform ? ["--platform", mobilePlatform] : [],
361
+ "--json",
362
+ ...targetArgs
363
+ ]);
364
+ } else {
365
+ await run("launch", ["launch", "--verify", "--json", ...targetArgs]);
366
+ await run("fixtures", ["fixtures", "set", "--json", ...targetArgs]);
367
+ }
368
+ await run("verify", ["verify", "--json", "--adapter", platform, ...targetArgs]);
369
+ } finally {
370
+ view?.end();
208
371
  }
209
- run("verify", ["verify", "--json", "--adapter", platform, ...targetArgs]);
210
372
  const provenance = runnerProvenance(manifestPath(platform));
211
373
  const record = {
212
374
  schemaVersion: 1,
@@ -222,7 +384,6 @@ async function handlePrepare(argv) {
222
384
  ready: steps.every((step) => step.status !== "fail"),
223
385
  recordedAt: (/* @__PURE__ */ new Date()).toISOString()
224
386
  };
225
- const recordPath = path.join(artifactsDir, READINESS_RECORD);
226
387
  fs.mkdirSync(artifactsDir, { recursive: true });
227
388
  fs.writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}
228
389
  `);
@@ -206,7 +206,7 @@ function updateStatus(now = Date.now()) {
206
206
  if (nudgeDisabled()) return { current, checked: false };
207
207
  const file = cacheFile();
208
208
  const cache = readCache(file);
209
- const latest = cache?.latest && now - cache.lastCheck < DOCTOR_CACHE_FRESH_MS ? cache.latest : probeLatest(file, now);
209
+ const latest = cache?.latest && now - cache.lastCheck < DOCTOR_CACHE_FRESH_MS && !isNewer(current, cache.latest) ? cache.latest : probeLatest(file, now);
210
210
  if (!latest) return { current, checked: false };
211
211
  return { current, checked: true, latest, updateAvailable: isNewer(latest, current) };
212
212
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"