@nathapp/nax 0.23.0 → 0.24.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.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Registry Writer Subscriber
3
+ *
4
+ * Creates ~/.nax/runs/<project>-<feature>-<runId>/meta.json on run:started.
5
+ * Provides a persistent record of each run with paths for status and events.
6
+ *
7
+ * Design:
8
+ * - Best-effort: all writes wrapped in try/catch; never throws or blocks
9
+ * - Directory created on first write via mkdir recursive
10
+ * - Written once on run:started, never updated
11
+ * - Returns UnsubscribeFn matching wireHooks/wireEventsWriter pattern
12
+ */
13
+
14
+ import { mkdir, writeFile } from "node:fs/promises";
15
+ import { homedir } from "node:os";
16
+ import { basename, join } from "node:path";
17
+ import { getSafeLogger } from "../../logger";
18
+ import type { PipelineEventBus } from "../event-bus";
19
+ import type { UnsubscribeFn } from "./hooks";
20
+
21
+ export interface MetaJson {
22
+ runId: string;
23
+ project: string;
24
+ feature: string;
25
+ workdir: string;
26
+ statusPath: string;
27
+ eventsDir: string;
28
+ registeredAt: string;
29
+ }
30
+
31
+ /**
32
+ * Wire registry writer to the pipeline event bus.
33
+ *
34
+ * Listens to run:started and writes meta.json to
35
+ * ~/.nax/runs/<project>-<feature>-<runId>/meta.json.
36
+ *
37
+ * @param bus - The pipeline event bus
38
+ * @param feature - Feature name
39
+ * @param runId - Current run ID
40
+ * @param workdir - Working directory (project name derived via basename)
41
+ * @returns Unsubscribe function
42
+ */
43
+ export function wireRegistry(bus: PipelineEventBus, feature: string, runId: string, workdir: string): UnsubscribeFn {
44
+ const logger = getSafeLogger();
45
+ const project = basename(workdir);
46
+ const runDir = join(homedir(), ".nax", "runs", `${project}-${feature}-${runId}`);
47
+ const metaFile = join(runDir, "meta.json");
48
+
49
+ const unsub = bus.on("run:started", (_ev) => {
50
+ (async () => {
51
+ try {
52
+ await mkdir(runDir, { recursive: true });
53
+ const meta: MetaJson = {
54
+ runId,
55
+ project,
56
+ feature,
57
+ workdir,
58
+ statusPath: join(workdir, "nax", "features", feature, "status.json"),
59
+ eventsDir: join(workdir, "nax", "features", feature, "runs"),
60
+ registeredAt: new Date().toISOString(),
61
+ };
62
+ await writeFile(metaFile, JSON.stringify(meta, null, 2));
63
+ } catch (err) {
64
+ logger?.warn("registry-writer", "Failed to write meta.json (non-fatal)", {
65
+ path: metaFile,
66
+ error: String(err),
67
+ });
68
+ }
69
+ })();
70
+ });
71
+
72
+ return unsub;
73
+ }
@@ -16,6 +16,7 @@ import { join } from "node:path";
16
16
 
17
17
  const TEST_WORKSPACE = join(import.meta.dir, "../../..", "tmp", "cli-logs-test");
18
18
  const NAX_BIN = join(import.meta.dir, "..", "..", "..", "bin", "nax.ts");
19
+ const REGISTRY_DIR = join(TEST_WORKSPACE, "registry");
19
20
 
20
21
  function setupTestProject(featureName: string): string {
21
22
  const projectDir = join(TEST_WORKSPACE, `project-${Date.now()}`);
@@ -24,6 +25,7 @@ function setupTestProject(featureName: string): string {
24
25
  const runsDir = join(featureDir, "runs");
25
26
 
26
27
  mkdirSync(runsDir, { recursive: true });
28
+ mkdirSync(REGISTRY_DIR, { recursive: true });
27
29
 
28
30
  writeFileSync(join(naxDir, "config.json"), JSON.stringify({ feature: featureName }));
29
31
 
@@ -62,7 +64,24 @@ function setupTestProject(featureName: string): string {
62
64
  },
63
65
  ];
64
66
 
65
- writeFileSync(join(runsDir, "2026-02-27T12-00-00.jsonl"), logs.map((l) => JSON.stringify(l)).join("\n"));
67
+ const runId = "2026-02-27T12-00-00";
68
+ writeFileSync(join(runsDir, `${runId}.jsonl`), logs.map((l) => JSON.stringify(l)).join("\n"));
69
+
70
+ // Create matching registry entry so --run <runId> resolves via registry
71
+ const entryDir = join(REGISTRY_DIR, `testproject-${featureName}-${runId}`);
72
+ mkdirSync(entryDir, { recursive: true });
73
+ writeFileSync(
74
+ join(entryDir, "meta.json"),
75
+ JSON.stringify({
76
+ runId,
77
+ project: "testproject",
78
+ feature: featureName,
79
+ workdir: projectDir,
80
+ statusPath: join(projectDir, "nax", "features", featureName, "status.json"),
81
+ eventsDir: runsDir,
82
+ registeredAt: "2026-02-27T12:00:00.000Z",
83
+ }),
84
+ );
66
85
 
67
86
  return projectDir;
68
87
  }
@@ -75,11 +94,15 @@ function cleanup(dir: string) {
75
94
 
76
95
  const CMD_TIMEOUT_MS = 15_000; // 15s per command — fast-fail instead of waiting full 60s
77
96
 
78
- function runNaxCommand(args: string[], cwd?: string): Promise<{ stdout: string; stderr: string; exitCode: number }> {
97
+ function runNaxCommand(
98
+ args: string[],
99
+ cwd?: string,
100
+ extraEnv?: Record<string, string>,
101
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
79
102
  return new Promise((resolve, reject) => {
80
103
  const proc = spawn("bun", ["run", NAX_BIN, ...args], {
81
104
  cwd: cwd || process.cwd(),
82
- env: process.env,
105
+ env: { ...process.env, ...extraEnv },
83
106
  });
84
107
 
85
108
  let stdout = "";
@@ -156,22 +179,24 @@ describe("nax logs CLI integration", () => {
156
179
  });
157
180
 
158
181
  describe("--run flag", () => {
159
- test("nax logs --run <timestamp> selects specific run", async () => {
160
- const result = await runNaxCommand(["logs", "--run", "2026-02-27T12-00-00", "-d", projectDir]);
182
+ const registryEnv = { NAX_RUNS_DIR: REGISTRY_DIR };
183
+
184
+ test("nax logs --run <runId> displays logs from matching registry entry", async () => {
185
+ const result = await runNaxCommand(["logs", "--run", "2026-02-27T12-00-00"], undefined, registryEnv);
161
186
 
162
187
  expect(result.exitCode).toBe(0);
163
188
  expect(result.stdout).toContain("run-001");
164
189
  });
165
190
 
166
191
  test("nax logs -r is shorthand for --run", async () => {
167
- const result = await runNaxCommand(["logs", "-r", "2026-02-27T12-00-00", "-d", projectDir]);
192
+ const result = await runNaxCommand(["logs", "-r", "2026-02-27T12-00-00"], undefined, registryEnv);
168
193
 
169
194
  expect(result.exitCode).toBe(0);
170
195
  expect(result.stdout).toContain("run-001");
171
196
  });
172
197
 
173
- test("nax logs --run fails when run not found", async () => {
174
- const result = await runNaxCommand(["logs", "--run", "2026-01-01T00-00-00", "-d", projectDir]);
198
+ test("nax logs --run fails when run not found in registry", async () => {
199
+ const result = await runNaxCommand(["logs", "--run", "2026-01-01T00-00-00"], undefined, registryEnv);
175
200
 
176
201
  expect(result.exitCode).not.toBe(0);
177
202
  expect(result.stderr).toContain("not found");
@@ -256,6 +281,7 @@ describe("nax logs CLI integration", () => {
256
281
  // We verify the process starts and produces stdout output.
257
282
  const proc = spawn("bun", ["run", NAX_BIN, "logs", "--follow", "-d", projectDir], {
258
283
  cwd: process.cwd(),
284
+ env: process.env,
259
285
  });
260
286
 
261
287
  let started = false;
@@ -275,6 +301,7 @@ describe("nax logs CLI integration", () => {
275
301
  test("nax logs -f is shorthand for --follow", async () => {
276
302
  const proc = spawn("bun", ["run", NAX_BIN, "logs", "-f", "-d", projectDir], {
277
303
  cwd: process.cwd(),
304
+ env: process.env,
278
305
  });
279
306
 
280
307
  let started = false;
@@ -306,15 +333,11 @@ describe("nax logs CLI integration", () => {
306
333
  });
307
334
 
308
335
  test("--run + --story", async () => {
309
- const result = await runNaxCommand([
310
- "logs",
311
- "--run",
312
- "2026-02-27T12-00-00",
313
- "--story",
314
- "US-001",
315
- "-d",
316
- projectDir,
317
- ]);
336
+ const result = await runNaxCommand(
337
+ ["logs", "--run", "2026-02-27T12-00-00", "--story", "US-001"],
338
+ undefined,
339
+ { NAX_RUNS_DIR: REGISTRY_DIR },
340
+ );
318
341
 
319
342
  expect(result.exitCode).toBe(0);
320
343
  expect(result.stdout).toContain("US-001");
@@ -16,7 +16,7 @@
16
16
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
17
17
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
- import { type LogsOptions, logsCommand } from "../../../src/commands/logs";
19
+ import { type LogsOptions, _deps, logsCommand } from "../../../src/commands/logs";
20
20
 
21
21
  const TEST_WORKSPACE = join(import.meta.dir, "..", "..", "tmp", "logs-test");
22
22
 
@@ -110,13 +110,43 @@ function cleanup(projectDir: string) {
110
110
 
111
111
  describe("logsCommand", () => {
112
112
  let projectDir: string;
113
+ let registryDir: string;
114
+ let originalGetRunsDir: () => string;
113
115
 
114
116
  beforeEach(() => {
115
117
  projectDir = setupTestProject("test-feature");
118
+
119
+ // Set up a temp registry dir and override _deps
120
+ registryDir = join(TEST_WORKSPACE, `registry-${Date.now()}`);
121
+ mkdirSync(registryDir, { recursive: true });
122
+ originalGetRunsDir = _deps.getRunsDir;
123
+ _deps.getRunsDir = () => registryDir;
124
+
125
+ // Create registry entries pointing to the test runs
126
+ const runsDir = join(projectDir, "nax", "features", "test-feature", "runs");
127
+
128
+ for (const runId of ["2026-02-27T10-00-00", "2026-02-26T09-00-00"]) {
129
+ const entryDir = join(registryDir, `testproject-test-feature-${runId}`);
130
+ mkdirSync(entryDir, { recursive: true });
131
+ writeFileSync(
132
+ join(entryDir, "meta.json"),
133
+ JSON.stringify({
134
+ runId,
135
+ project: "testproject",
136
+ feature: "test-feature",
137
+ workdir: projectDir,
138
+ statusPath: join(projectDir, "nax", "features", "test-feature", "status.json"),
139
+ eventsDir: runsDir,
140
+ registeredAt: "2026-02-27T10:00:00.000Z",
141
+ }),
142
+ );
143
+ }
116
144
  });
117
145
 
118
146
  afterEach(() => {
147
+ _deps.getRunsDir = originalGetRunsDir;
119
148
  cleanup(projectDir);
149
+ cleanup(registryDir);
120
150
  });
121
151
 
122
152
  describe("default behavior (latest run formatted)", () => {
@@ -259,33 +289,46 @@ describe("logsCommand", () => {
259
289
  });
260
290
  });
261
291
 
262
- describe("--run (specific run selection)", () => {
263
- test("displays specific run by timestamp", async () => {
264
- const options: LogsOptions = {
265
- dir: projectDir,
266
- run: "2026-02-26T09-00-00",
267
- };
292
+ describe("--run (registry-based run selection)", () => {
293
+ test("displays run resolved from central registry by exact runId", async () => {
294
+ const options: LogsOptions = { run: "2026-02-26T09-00-00" };
268
295
 
269
- // Should display the older run
270
296
  await expect(logsCommand(options)).resolves.toBeUndefined();
271
297
  });
272
298
 
273
- test("throws when specified run does not exist", async () => {
274
- const options: LogsOptions = {
275
- dir: projectDir,
276
- run: "2026-01-01T00-00-00",
277
- };
299
+ test("displays run resolved from central registry by prefix match", async () => {
300
+ const options: LogsOptions = { run: "2026-02-26" };
278
301
 
279
- await expect(logsCommand(options)).rejects.toThrow(/run not found/i);
302
+ // Should match "2026-02-26T09-00-00"
303
+ await expect(logsCommand(options)).resolves.toBeUndefined();
280
304
  });
281
305
 
282
- test("works with partial timestamp matching", async () => {
283
- const options: LogsOptions = {
284
- dir: projectDir,
285
- run: "2026-02-26",
286
- };
306
+ test("throws with clear error when runId not found in registry", async () => {
307
+ const options: LogsOptions = { run: "2026-01-01T00-00-00" };
287
308
 
288
- // Should match "2026-02-26T09-00-00"
309
+ await expect(logsCommand(options)).rejects.toThrow(/run not found in registry/i);
310
+ });
311
+
312
+ test("shows unavailable message when eventsDir does not exist", async () => {
313
+ // Add a registry entry pointing to a non-existent eventsDir
314
+ const entryDir = join(registryDir, "proj-feat-ghost-run");
315
+ mkdirSync(entryDir, { recursive: true });
316
+ writeFileSync(
317
+ join(entryDir, "meta.json"),
318
+ JSON.stringify({
319
+ runId: "ghost-run",
320
+ project: "proj",
321
+ feature: "feat",
322
+ workdir: "/nonexistent",
323
+ statusPath: "/nonexistent/nax/features/feat/status.json",
324
+ eventsDir: "/nonexistent/nax/features/feat/runs",
325
+ registeredAt: "2026-01-01T00:00:00.000Z",
326
+ }),
327
+ );
328
+
329
+ const options: LogsOptions = { run: "ghost-run" };
330
+
331
+ // Should resolve without throwing — prints unavailable message
289
332
  await expect(logsCommand(options)).resolves.toBeUndefined();
290
333
  });
291
334
  });
@@ -350,7 +393,6 @@ describe("logsCommand", () => {
350
393
 
351
394
  test("--run + --story + --level", async () => {
352
395
  const options: LogsOptions = {
353
- dir: projectDir,
354
396
  run: "2026-02-27T10-00-00",
355
397
  story: "US-001",
356
398
  level: "info",
@@ -361,7 +403,6 @@ describe("logsCommand", () => {
361
403
 
362
404
  test("all filters combined", async () => {
363
405
  const options: LogsOptions = {
364
- dir: projectDir,
365
406
  run: "2026-02-27T10-00-00",
366
407
  story: "US-001",
367
408
  level: "info",
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Unit tests for src/commands/runs.ts — runsCommand
3
+ *
4
+ * Tests all acceptance criteria:
5
+ * - Displays table sorted newest-first
6
+ * - --project filter
7
+ * - --last limit
8
+ * - --status filter
9
+ * - Missing statusPath shows '[unavailable]'
10
+ * - Empty registry shows 'No runs found'
11
+ */
12
+
13
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
14
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { _deps, runsCommand } from "../../../src/commands/runs";
18
+ import type { MetaJson } from "../../../src/pipeline/subscribers/registry";
19
+ import type { NaxStatusFile } from "../../../src/execution/status-file";
20
+
21
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
22
+
23
+ function makeTmpRunsDir(): string {
24
+ return mkdtempSync(join(tmpdir(), "nax-runs-test-"));
25
+ }
26
+
27
+ function makeStatusFile(overrides: Partial<NaxStatusFile["run"]> = {}): NaxStatusFile {
28
+ return {
29
+ version: 1,
30
+ run: {
31
+ id: "run-2026-01-01T00-00-00",
32
+ feature: "feat",
33
+ startedAt: "2026-01-01T00:00:00.000Z",
34
+ status: "completed",
35
+ dryRun: false,
36
+ pid: 1234,
37
+ ...overrides,
38
+ },
39
+ progress: { total: 4, passed: 3, failed: 1, paused: 0, blocked: 0, pending: 0 },
40
+ cost: { spent: 0.12, limit: null },
41
+ current: null,
42
+ iterations: 5,
43
+ updatedAt: "2026-01-01T00:10:00.000Z",
44
+ durationMs: 600000,
45
+ };
46
+ }
47
+
48
+ function writeRun(
49
+ runsDir: string,
50
+ opts: {
51
+ runId: string;
52
+ project: string;
53
+ feature: string;
54
+ registeredAt: string;
55
+ statusFile?: NaxStatusFile | null;
56
+ },
57
+ ): { metaPath: string; statusPath: string } {
58
+ const runDir = join(runsDir, `${opts.project}-${opts.feature}-${opts.runId}`);
59
+ mkdirSync(runDir, { recursive: true });
60
+
61
+ const statusPath = join(runDir, "status.json");
62
+
63
+ const meta: MetaJson = {
64
+ runId: opts.runId,
65
+ project: opts.project,
66
+ feature: opts.feature,
67
+ workdir: "/tmp/fake-workdir",
68
+ statusPath,
69
+ eventsDir: "/tmp/fake-workdir/nax/features/feat/runs",
70
+ registeredAt: opts.registeredAt,
71
+ };
72
+
73
+ writeFileSync(join(runDir, "meta.json"), JSON.stringify(meta, null, 2));
74
+
75
+ if (opts.statusFile !== null) {
76
+ const sf = opts.statusFile ?? makeStatusFile();
77
+ writeFileSync(statusPath, JSON.stringify(sf, null, 2));
78
+ }
79
+
80
+ return { metaPath: join(runDir, "meta.json"), statusPath };
81
+ }
82
+
83
+ // ─── Test state ───────────────────────────────────────────────────────────────
84
+
85
+ let tmpDir: string;
86
+ let capturedOutput: string[];
87
+ const originalLog = console.log;
88
+ const originalGetRunsDir = _deps.getRunsDir;
89
+
90
+ beforeEach(() => {
91
+ tmpDir = makeTmpRunsDir();
92
+ capturedOutput = [];
93
+ console.log = (...args: unknown[]) => {
94
+ capturedOutput.push(args.map(String).join(" "));
95
+ };
96
+ _deps.getRunsDir = () => tmpDir;
97
+ });
98
+
99
+ afterEach(() => {
100
+ console.log = originalLog;
101
+ _deps.getRunsDir = originalGetRunsDir;
102
+ rmSync(tmpDir, { recursive: true, force: true });
103
+ });
104
+
105
+ // ─── Tests ────────────────────────────────────────────────────────────────────
106
+
107
+ describe("runsCommand", () => {
108
+ describe("empty registry", () => {
109
+ test("shows 'No runs found' when registry dir does not exist", async () => {
110
+ _deps.getRunsDir = () => join(tmpDir, "nonexistent");
111
+ await runsCommand();
112
+ expect(capturedOutput.join("\n")).toContain("No runs found");
113
+ });
114
+
115
+ test("shows 'No runs found' when registry dir is empty", async () => {
116
+ await runsCommand();
117
+ expect(capturedOutput.join("\n")).toContain("No runs found");
118
+ });
119
+ });
120
+
121
+ describe("table display", () => {
122
+ test("displays runs sorted newest-first", async () => {
123
+ writeRun(tmpDir, {
124
+ runId: "run-A",
125
+ project: "proj",
126
+ feature: "feat",
127
+ registeredAt: "2026-01-01T10:00:00.000Z",
128
+ statusFile: makeStatusFile({ status: "completed" }),
129
+ });
130
+ writeRun(tmpDir, {
131
+ runId: "run-B",
132
+ project: "proj",
133
+ feature: "feat",
134
+ registeredAt: "2026-01-02T10:00:00.000Z",
135
+ statusFile: makeStatusFile({ status: "completed" }),
136
+ });
137
+
138
+ await runsCommand();
139
+
140
+ const output = capturedOutput.join("\n");
141
+ const posA = output.indexOf("run-A");
142
+ const posB = output.indexOf("run-B");
143
+ expect(posB).toBeLessThan(posA); // run-B (newer) should appear before run-A
144
+ });
145
+
146
+ test("shows RUN ID, PROJECT, FEATURE, STATUS, STORIES, DURATION, DATE columns", async () => {
147
+ writeRun(tmpDir, {
148
+ runId: "run-X",
149
+ project: "myproj",
150
+ feature: "my-feat",
151
+ registeredAt: "2026-01-01T00:00:00.000Z",
152
+ statusFile: makeStatusFile(),
153
+ });
154
+
155
+ await runsCommand();
156
+
157
+ const output = capturedOutput.join("\n");
158
+ expect(output).toContain("RUN ID");
159
+ expect(output).toContain("PROJECT");
160
+ expect(output).toContain("FEATURE");
161
+ expect(output).toContain("STATUS");
162
+ expect(output).toContain("STORIES");
163
+ expect(output).toContain("DURATION");
164
+ expect(output).toContain("DATE");
165
+ });
166
+
167
+ test("shows run data in table row", async () => {
168
+ writeRun(tmpDir, {
169
+ runId: "run-X",
170
+ project: "myproj",
171
+ feature: "my-feat",
172
+ registeredAt: "2026-01-01T00:00:00.000Z",
173
+ statusFile: makeStatusFile({ status: "completed" }),
174
+ });
175
+
176
+ await runsCommand();
177
+
178
+ const output = capturedOutput.join("\n");
179
+ expect(output).toContain("run-X");
180
+ expect(output).toContain("myproj");
181
+ expect(output).toContain("my-feat");
182
+ expect(output).toContain("3/4");
183
+ });
184
+ });
185
+
186
+ describe("--project filter", () => {
187
+ test("filters runs by project name", async () => {
188
+ writeRun(tmpDir, {
189
+ runId: "run-alpha",
190
+ project: "alpha",
191
+ feature: "feat",
192
+ registeredAt: "2026-01-01T00:00:00.000Z",
193
+ statusFile: makeStatusFile(),
194
+ });
195
+ writeRun(tmpDir, {
196
+ runId: "run-beta",
197
+ project: "beta",
198
+ feature: "feat",
199
+ registeredAt: "2026-01-01T00:00:00.000Z",
200
+ statusFile: makeStatusFile(),
201
+ });
202
+
203
+ await runsCommand({ project: "alpha" });
204
+
205
+ const output = capturedOutput.join("\n");
206
+ expect(output).toContain("run-alpha");
207
+ expect(output).not.toContain("run-beta");
208
+ });
209
+
210
+ test("shows 'No runs found' when project has no runs", async () => {
211
+ writeRun(tmpDir, {
212
+ runId: "run-alpha",
213
+ project: "alpha",
214
+ feature: "feat",
215
+ registeredAt: "2026-01-01T00:00:00.000Z",
216
+ statusFile: makeStatusFile(),
217
+ });
218
+
219
+ await runsCommand({ project: "nonexistent" });
220
+
221
+ expect(capturedOutput.join("\n")).toContain("No runs found");
222
+ });
223
+ });
224
+
225
+ describe("--last limit", () => {
226
+ test("limits output to N most recent runs", async () => {
227
+ for (let i = 1; i <= 5; i++) {
228
+ writeRun(tmpDir, {
229
+ runId: `run-${String(i).padStart(3, "0")}`,
230
+ project: "proj",
231
+ feature: "feat",
232
+ registeredAt: `2026-01-0${i}T00:00:00.000Z`,
233
+ statusFile: makeStatusFile(),
234
+ });
235
+ }
236
+
237
+ await runsCommand({ last: 2 });
238
+
239
+ const output = capturedOutput.join("\n");
240
+ // Only the 2 newest should appear
241
+ expect(output).toContain("run-005");
242
+ expect(output).toContain("run-004");
243
+ expect(output).not.toContain("run-001");
244
+ expect(output).not.toContain("run-002");
245
+ expect(output).not.toContain("run-003");
246
+ });
247
+ });
248
+
249
+ describe("--status filter", () => {
250
+ test("filters runs by status", async () => {
251
+ writeRun(tmpDir, {
252
+ runId: "run-done",
253
+ project: "proj",
254
+ feature: "feat",
255
+ registeredAt: "2026-01-01T00:00:00.000Z",
256
+ statusFile: makeStatusFile({ status: "completed" }),
257
+ });
258
+ writeRun(tmpDir, {
259
+ runId: "run-fail",
260
+ project: "proj",
261
+ feature: "feat",
262
+ registeredAt: "2026-01-02T00:00:00.000Z",
263
+ statusFile: makeStatusFile({ status: "failed" }),
264
+ });
265
+
266
+ await runsCommand({ status: "failed" });
267
+
268
+ const output = capturedOutput.join("\n");
269
+ expect(output).toContain("run-fail");
270
+ expect(output).not.toContain("run-done");
271
+ });
272
+ });
273
+
274
+ describe("missing statusPath", () => {
275
+ test("shows [unavailable] when status file does not exist", async () => {
276
+ writeRun(tmpDir, {
277
+ runId: "run-nostat",
278
+ project: "proj",
279
+ feature: "feat",
280
+ registeredAt: "2026-01-01T00:00:00.000Z",
281
+ statusFile: null, // no status file written
282
+ });
283
+
284
+ await runsCommand();
285
+
286
+ const output = capturedOutput.join("\n");
287
+ expect(output).toContain("[unavailable]");
288
+ expect(output).toContain("run-nostat");
289
+ });
290
+
291
+ test("does not throw when status file is missing", async () => {
292
+ writeRun(tmpDir, {
293
+ runId: "run-nostat",
294
+ project: "proj",
295
+ feature: "feat",
296
+ registeredAt: "2026-01-01T00:00:00.000Z",
297
+ statusFile: null,
298
+ });
299
+
300
+ await expect(runsCommand()).resolves.toBeUndefined();
301
+ });
302
+ });
303
+ });