@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.
- package/bin/nax.ts +20 -2
- package/nax/features/central-run-registry/prd.json +105 -0
- package/package.json +2 -2
- package/src/commands/index.ts +1 -0
- package/src/commands/logs.ts +87 -17
- package/src/commands/runs.ts +220 -0
- package/src/execution/sequential-executor.ts +4 -0
- package/src/pipeline/subscribers/events-writer.ts +121 -0
- package/src/pipeline/subscribers/registry.ts +73 -0
- package/test/integration/cli/cli-logs.test.ts +40 -17
- package/test/unit/commands/logs.test.ts +63 -22
- package/test/unit/commands/runs.test.ts +303 -0
- package/test/unit/pipeline/subscribers/events-writer.test.ts +227 -0
- package/test/unit/pipeline/subscribers/registry.test.ts +149 -0
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
160
|
-
|
|
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",
|
|
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",
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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 (
|
|
263
|
-
test("displays
|
|
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("
|
|
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
|
-
|
|
302
|
+
// Should match "2026-02-26T09-00-00"
|
|
303
|
+
await expect(logsCommand(options)).resolves.toBeUndefined();
|
|
280
304
|
});
|
|
281
305
|
|
|
282
|
-
test("
|
|
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
|
-
|
|
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
|
+
});
|