@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,227 @@
1
+ import { readFile, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
5
+ import { PipelineEventBus } from "../../../../src/pipeline/event-bus";
6
+ import { wireEventsWriter } from "../../../../src/pipeline/subscribers/events-writer";
7
+
8
+ // Minimal UserStory stub for event payloads
9
+ const stubStory = { id: "US-001", title: "Test story" } as never;
10
+
11
+ describe("wireEventsWriter", () => {
12
+ let workdir: string;
13
+ let eventsFile: string;
14
+
15
+ beforeEach(() => {
16
+ // Use a temp workdir with a known basename so we can locate the events file
17
+ workdir = join(tmpdir(), `nax-evtest-${Date.now()}`);
18
+ const project = workdir.split("/").pop()!;
19
+ eventsFile = join(process.env.HOME ?? tmpdir(), ".nax", "events", project, "events.jsonl");
20
+ });
21
+
22
+ afterEach(async () => {
23
+ // Clean up the events file written under ~/.nax/events/<project>/
24
+ const project = workdir.split("/").pop()!;
25
+ const dir = join(process.env.HOME ?? tmpdir(), ".nax", "events", project);
26
+ await rm(dir, { recursive: true, force: true });
27
+ mock.restore();
28
+ });
29
+
30
+ async function readLines(): Promise<object[]> {
31
+ // Small delay to let async fire-and-forget writes finish
32
+ await Bun.sleep(50);
33
+ const text = await readFile(eventsFile, "utf8");
34
+ return text
35
+ .trim()
36
+ .split("\n")
37
+ .filter(Boolean)
38
+ .map((l) => JSON.parse(l));
39
+ }
40
+
41
+ test("returns an UnsubscribeFn", () => {
42
+ const bus = new PipelineEventBus();
43
+ const unsub = wireEventsWriter(bus, "my-feature", "run-001", workdir);
44
+ expect(typeof unsub).toBe("function");
45
+ unsub();
46
+ });
47
+
48
+ test("creates events.jsonl and writes run:started line", async () => {
49
+ const bus = new PipelineEventBus();
50
+ wireEventsWriter(bus, "feat-a", "run-abc", workdir);
51
+
52
+ bus.emit({ type: "run:started", feature: "feat-a", totalStories: 3, workdir });
53
+
54
+ const lines = await readLines();
55
+ expect(lines.length).toBe(1);
56
+ const line = lines[0] as Record<string, unknown>;
57
+ expect(line.event).toBe("run:started");
58
+ expect(line.runId).toBe("run-abc");
59
+ expect(line.feature).toBe("feat-a");
60
+ expect(typeof line.project).toBe("string");
61
+ expect(typeof line.ts).toBe("string");
62
+ // ts must be valid ISO8601
63
+ expect(() => new Date(line.ts as string).toISOString()).not.toThrow();
64
+ });
65
+
66
+ test("each line has required fields: ts, event, runId, feature, project", async () => {
67
+ const bus = new PipelineEventBus();
68
+ wireEventsWriter(bus, "feat-b", "run-xyz", workdir);
69
+
70
+ bus.emit({ type: "run:started", feature: "feat-b", totalStories: 1, workdir });
71
+
72
+ const lines = await readLines();
73
+ const line = lines[0] as Record<string, unknown>;
74
+ for (const field of ["ts", "event", "runId", "feature", "project"]) {
75
+ expect(line[field]).toBeDefined();
76
+ }
77
+ });
78
+
79
+ test("run:completed writes event=on-complete", async () => {
80
+ const bus = new PipelineEventBus();
81
+ wireEventsWriter(bus, "feat-c", "run-001", workdir);
82
+
83
+ bus.emit({
84
+ type: "run:completed",
85
+ totalStories: 1,
86
+ passedStories: 1,
87
+ failedStories: 0,
88
+ durationMs: 100,
89
+ totalCost: 0.01,
90
+ });
91
+
92
+ const lines = await readLines();
93
+ const line = lines[0] as Record<string, unknown>;
94
+ expect(line.event).toBe("on-complete");
95
+ });
96
+
97
+ test("story:started includes storyId", async () => {
98
+ const bus = new PipelineEventBus();
99
+ wireEventsWriter(bus, "feat-d", "run-001", workdir);
100
+
101
+ bus.emit({
102
+ type: "story:started",
103
+ storyId: "US-042",
104
+ story: stubStory,
105
+ workdir,
106
+ });
107
+
108
+ const lines = await readLines();
109
+ const line = lines[0] as Record<string, unknown>;
110
+ expect(line.event).toBe("story:started");
111
+ expect(line.storyId).toBe("US-042");
112
+ });
113
+
114
+ test("story:completed includes storyId", async () => {
115
+ const bus = new PipelineEventBus();
116
+ wireEventsWriter(bus, "feat-d", "run-001", workdir);
117
+
118
+ bus.emit({
119
+ type: "story:completed",
120
+ storyId: "US-042",
121
+ story: stubStory,
122
+ passed: true,
123
+ durationMs: 500,
124
+ });
125
+
126
+ const lines = await readLines();
127
+ const line = lines[0] as Record<string, unknown>;
128
+ expect(line.event).toBe("story:completed");
129
+ expect(line.storyId).toBe("US-042");
130
+ });
131
+
132
+ test("story:failed includes storyId", async () => {
133
+ const bus = new PipelineEventBus();
134
+ wireEventsWriter(bus, "feat-d", "run-001", workdir);
135
+
136
+ bus.emit({
137
+ type: "story:failed",
138
+ storyId: "US-042",
139
+ story: stubStory,
140
+ reason: "tests failed",
141
+ countsTowardEscalation: true,
142
+ });
143
+
144
+ const lines = await readLines();
145
+ const line = lines[0] as Record<string, unknown>;
146
+ expect(line.event).toBe("story:failed");
147
+ expect(line.storyId).toBe("US-042");
148
+ });
149
+
150
+ test("run:paused is written", async () => {
151
+ const bus = new PipelineEventBus();
152
+ wireEventsWriter(bus, "feat-e", "run-001", workdir);
153
+
154
+ bus.emit({ type: "run:paused", reason: "cost limit", cost: 5.0 });
155
+
156
+ const lines = await readLines();
157
+ const line = lines[0] as Record<string, unknown>;
158
+ expect(line.event).toBe("run:paused");
159
+ });
160
+
161
+ test("multiple events produce multiple JSONL lines", async () => {
162
+ const bus = new PipelineEventBus();
163
+ wireEventsWriter(bus, "feat-f", "run-multi", workdir);
164
+
165
+ bus.emit({ type: "run:started", feature: "feat-f", totalStories: 2, workdir });
166
+ bus.emit({ type: "story:started", storyId: "US-001", story: stubStory, workdir });
167
+ bus.emit({ type: "story:completed", storyId: "US-001", story: stubStory, passed: true, durationMs: 100 });
168
+ bus.emit({
169
+ type: "run:completed",
170
+ totalStories: 1,
171
+ passedStories: 1,
172
+ failedStories: 0,
173
+ durationMs: 200,
174
+ });
175
+
176
+ const lines = await readLines();
177
+ expect(lines.length).toBe(4);
178
+ });
179
+
180
+ test("write failure does not throw or crash", async () => {
181
+ const bus = new PipelineEventBus();
182
+ // Use a workdir whose project name would conflict with a file (simulate write error)
183
+ // by pointing to an invalid path — we patch mkdir to throw
184
+ const originalMkdir = (await import("node:fs/promises")).mkdir;
185
+
186
+ let callCount = 0;
187
+ const _fsMod = await import("node:fs/promises");
188
+ // Inject a failing write by pointing to a path that won't be writable
189
+ // We rely on the subscriber catching the error gracefully
190
+ const badWorkdir = "/dev/null/nonexistent-project";
191
+ const badBus = new PipelineEventBus();
192
+ wireEventsWriter(badBus, "feat-err", "run-err", badWorkdir);
193
+
194
+ // Should not throw
195
+ expect(() => {
196
+ badBus.emit({ type: "run:started", feature: "feat-err", totalStories: 0, workdir: badWorkdir });
197
+ }).not.toThrow();
198
+
199
+ // Give async time to attempt write and swallow error
200
+ await Bun.sleep(50);
201
+ // No assertion on file existence — the point is no crash/throw
202
+ });
203
+
204
+ test("unsubscribe stops further writes", async () => {
205
+ const bus = new PipelineEventBus();
206
+ const unsub = wireEventsWriter(bus, "feat-g", "run-unsub", workdir);
207
+
208
+ bus.emit({ type: "run:started", feature: "feat-g", totalStories: 1, workdir });
209
+ await Bun.sleep(50);
210
+
211
+ unsub();
212
+
213
+ bus.emit({
214
+ type: "run:completed",
215
+ totalStories: 1,
216
+ passedStories: 1,
217
+ failedStories: 0,
218
+ durationMs: 100,
219
+ });
220
+
221
+ await Bun.sleep(50);
222
+ const lines = await readLines();
223
+ // Only the first event should be written
224
+ expect(lines.length).toBe(1);
225
+ expect((lines[0] as Record<string, unknown>).event).toBe("run:started");
226
+ });
227
+ });
@@ -0,0 +1,149 @@
1
+ import { readFile, rm } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
5
+ import { PipelineEventBus } from "../../../../src/pipeline/event-bus";
6
+ import { type MetaJson, wireRegistry } from "../../../../src/pipeline/subscribers/registry";
7
+
8
+ describe("wireRegistry", () => {
9
+ let workdir: string;
10
+ let feature: string;
11
+ let runId: string;
12
+ let runDir: string;
13
+ let metaFile: string;
14
+
15
+ beforeEach(() => {
16
+ workdir = join("/tmp", `nax-regtest-${Date.now()}`);
17
+ feature = "auth-system";
18
+ runId = `run-${Date.now()}`;
19
+ const project = basename(workdir);
20
+ runDir = join(homedir(), ".nax", "runs", `${project}-${feature}-${runId}`);
21
+ metaFile = join(runDir, "meta.json");
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await rm(runDir, { recursive: true, force: true });
26
+ mock.restore();
27
+ });
28
+
29
+ test("returns an UnsubscribeFn", () => {
30
+ const bus = new PipelineEventBus();
31
+ const unsub = wireRegistry(bus, feature, runId, workdir);
32
+ expect(typeof unsub).toBe("function");
33
+ unsub();
34
+ });
35
+
36
+ test("creates meta.json on run:started", async () => {
37
+ const bus = new PipelineEventBus();
38
+ wireRegistry(bus, feature, runId, workdir);
39
+
40
+ bus.emit({ type: "run:started", feature, totalStories: 3, workdir });
41
+
42
+ await Bun.sleep(50);
43
+ const text = await readFile(metaFile, "utf8");
44
+ const meta = JSON.parse(text) as MetaJson;
45
+ expect(meta).toBeDefined();
46
+ });
47
+
48
+ test("meta.json contains all required fields", async () => {
49
+ const bus = new PipelineEventBus();
50
+ wireRegistry(bus, feature, runId, workdir);
51
+
52
+ bus.emit({ type: "run:started", feature, totalStories: 1, workdir });
53
+
54
+ await Bun.sleep(50);
55
+ const meta = JSON.parse(await readFile(metaFile, "utf8")) as MetaJson;
56
+
57
+ expect(meta.runId).toBe(runId);
58
+ expect(meta.project).toBe(basename(workdir));
59
+ expect(meta.feature).toBe(feature);
60
+ expect(meta.workdir).toBe(workdir);
61
+ expect(typeof meta.statusPath).toBe("string");
62
+ expect(typeof meta.eventsDir).toBe("string");
63
+ expect(typeof meta.registeredAt).toBe("string");
64
+ });
65
+
66
+ test("statusPath points to <workdir>/nax/features/<feature>/status.json", async () => {
67
+ const bus = new PipelineEventBus();
68
+ wireRegistry(bus, feature, runId, workdir);
69
+
70
+ bus.emit({ type: "run:started", feature, totalStories: 1, workdir });
71
+
72
+ await Bun.sleep(50);
73
+ const meta = JSON.parse(await readFile(metaFile, "utf8")) as MetaJson;
74
+
75
+ expect(meta.statusPath).toBe(join(workdir, "nax", "features", feature, "status.json"));
76
+ });
77
+
78
+ test("eventsDir points to <workdir>/nax/features/<feature>/runs", async () => {
79
+ const bus = new PipelineEventBus();
80
+ wireRegistry(bus, feature, runId, workdir);
81
+
82
+ bus.emit({ type: "run:started", feature, totalStories: 1, workdir });
83
+
84
+ await Bun.sleep(50);
85
+ const meta = JSON.parse(await readFile(metaFile, "utf8")) as MetaJson;
86
+
87
+ expect(meta.eventsDir).toBe(join(workdir, "nax", "features", feature, "runs"));
88
+ });
89
+
90
+ test("registeredAt is valid ISO8601", async () => {
91
+ const bus = new PipelineEventBus();
92
+ wireRegistry(bus, feature, runId, workdir);
93
+
94
+ bus.emit({ type: "run:started", feature, totalStories: 1, workdir });
95
+
96
+ await Bun.sleep(50);
97
+ const meta = JSON.parse(await readFile(metaFile, "utf8")) as MetaJson;
98
+
99
+ expect(() => new Date(meta.registeredAt).toISOString()).not.toThrow();
100
+ });
101
+
102
+ test("write failure does not throw or crash", async () => {
103
+ const bus = new PipelineEventBus();
104
+ // Point to an unwritable path to trigger write failure
105
+ const badWorkdir = "/dev/null/nonexistent-project";
106
+ wireRegistry(bus, "feat-err", "run-err", badWorkdir);
107
+
108
+ expect(() => {
109
+ bus.emit({ type: "run:started", feature: "feat-err", totalStories: 0, workdir: badWorkdir });
110
+ }).not.toThrow();
111
+
112
+ await Bun.sleep(50);
113
+ // No crash — test passes if we reach here
114
+ });
115
+
116
+ test("MetaJson interface is exported", () => {
117
+ // Verify that MetaJson can be used as a type annotation
118
+ const meta: MetaJson = {
119
+ runId: "r1",
120
+ project: "proj",
121
+ feature: "feat",
122
+ workdir: "/tmp/proj",
123
+ statusPath: "/tmp/proj/nax/features/feat/status.json",
124
+ eventsDir: "/tmp/proj/nax/features/feat/runs",
125
+ registeredAt: new Date().toISOString(),
126
+ };
127
+ expect(meta.runId).toBe("r1");
128
+ });
129
+
130
+ test("unsubscribe stops further writes", async () => {
131
+ const bus = new PipelineEventBus();
132
+ const unsub = wireRegistry(bus, feature, runId, workdir);
133
+
134
+ unsub();
135
+
136
+ bus.emit({ type: "run:started", feature, totalStories: 1, workdir });
137
+
138
+ await Bun.sleep(50);
139
+ // File should not exist since we unsubscribed before event
140
+ let exists = false;
141
+ try {
142
+ await readFile(metaFile, "utf8");
143
+ exists = true;
144
+ } catch {
145
+ exists = false;
146
+ }
147
+ expect(exists).toBe(false);
148
+ });
149
+ });