@nathapp/nax 0.18.1 → 0.18.3
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/.gitlab-ci.yml +12 -6
- package/bun.lock +1 -1
- package/bunfig.toml +2 -1
- package/docker-compose.test.yml +17 -0
- package/docs/ROADMAP.md +121 -36
- package/docs/specs/verification-architecture-v2.md +343 -0
- package/nax/config.json +13 -10
- package/nax/features/smart-test-runner/plan.md +7 -0
- package/nax/features/smart-test-runner/prd.json +203 -0
- package/nax/features/smart-test-runner/progress.txt +13 -0
- package/nax/features/smart-test-runner/spec.md +7 -0
- package/nax/features/smart-test-runner/tasks.md +8 -0
- package/nax/features/v0.18.3-execution-reliability/prd.json +80 -0
- package/nax/features/v0.18.3-execution-reliability/progress.txt +3 -0
- package/package.json +2 -2
- package/src/config/defaults.ts +2 -0
- package/src/config/schema.ts +1 -0
- package/src/config/schemas.ts +24 -0
- package/src/config/types.ts +16 -1
- package/src/context/builder.ts +11 -0
- package/src/context/elements.ts +38 -1
- package/src/execution/escalation/tier-escalation.ts +28 -3
- package/src/execution/post-verify-rectification.ts +4 -2
- package/src/execution/post-verify.ts +73 -9
- package/src/execution/progress.ts +2 -0
- package/src/pipeline/stages/review.ts +5 -3
- package/src/pipeline/stages/routing.ts +14 -9
- package/src/pipeline/stages/verify.ts +54 -1
- package/src/prd/index.ts +16 -1
- package/src/prd/types.ts +33 -0
- package/src/precheck/index.ts +9 -4
- package/src/routing/strategies/llm.ts +5 -0
- package/src/verification/gate.ts +2 -1
- package/src/verification/smart-runner.ts +214 -0
- package/src/verification/types.ts +2 -0
- package/test/US-002-orchestrator.test.ts +5 -5
- package/test/context/prior-failures.test.ts +462 -0
- package/test/execution/post-verify-bug026.test.ts +443 -0
- package/test/execution/post-verify.test.ts +32 -0
- package/test/execution/structured-failure.test.ts +414 -0
- package/test/integration/logger.test.ts +1 -1
- package/test/integration/review-plugin-integration.test.ts +2 -1
- package/test/integration/story-id-in-events.test.ts +1 -1
- package/test/unit/config/smart-runner-flag.test.ts +249 -0
- package/test/unit/pipeline/routing-partial-override.test.ts +141 -0
- package/test/unit/pipeline/verify-smart-runner.test.ts +344 -0
- package/test/unit/prd-get-next-story.test.ts +28 -0
- package/test/unit/routing.test.ts +102 -0
- package/test/unit/smart-test-runner.test.ts +512 -0
- package/test/unit/verification/smart-runner.test.ts +246 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for StructuredFailure and priorFailures tracking
|
|
3
|
+
*
|
|
4
|
+
* Tests the structured failure context for escalated tiers to know exactly what failed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { loadPRD } from "../../src/prd";
|
|
9
|
+
import type { StructuredFailure, TestFailureContext, UserStory } from "../../src/prd";
|
|
10
|
+
|
|
11
|
+
describe("StructuredFailure Type", () => {
|
|
12
|
+
test("should have all required fields", () => {
|
|
13
|
+
const failure: StructuredFailure = {
|
|
14
|
+
attempt: 1,
|
|
15
|
+
modelTier: "balanced",
|
|
16
|
+
stage: "verify",
|
|
17
|
+
summary: "Test failed",
|
|
18
|
+
timestamp: new Date().toISOString(),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
expect(failure.attempt).toBe(1);
|
|
22
|
+
expect(failure.modelTier).toBe("balanced");
|
|
23
|
+
expect(failure.stage).toBe("verify");
|
|
24
|
+
expect(failure.summary).toBe("Test failed");
|
|
25
|
+
expect(failure.timestamp).toBeDefined();
|
|
26
|
+
expect(typeof failure.timestamp).toBe("string");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("should have optional testFailures field", () => {
|
|
30
|
+
const testFailure: TestFailureContext = {
|
|
31
|
+
file: "test/foo.test.ts",
|
|
32
|
+
testName: "should pass",
|
|
33
|
+
error: "Expected 1 to equal 2",
|
|
34
|
+
stackTrace: ["at foo.ts:10:15", "at Object.test (foo.ts:8:3)"],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const failure: StructuredFailure = {
|
|
38
|
+
attempt: 1,
|
|
39
|
+
modelTier: "balanced",
|
|
40
|
+
stage: "verify",
|
|
41
|
+
summary: "Test failed",
|
|
42
|
+
testFailures: [testFailure],
|
|
43
|
+
timestamp: new Date().toISOString(),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
expect(failure.testFailures).toBeDefined();
|
|
47
|
+
expect(failure.testFailures?.length).toBe(1);
|
|
48
|
+
expect(failure.testFailures?.[0].file).toBe("test/foo.test.ts");
|
|
49
|
+
expect(failure.testFailures?.[0].testName).toBe("should pass");
|
|
50
|
+
expect(failure.testFailures?.[0].error).toBe("Expected 1 to equal 2");
|
|
51
|
+
expect(failure.testFailures?.[0].stackTrace.length).toBe(2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("should support all verification stages", () => {
|
|
55
|
+
const stages: Array<StructuredFailure["stage"]> = [
|
|
56
|
+
"verify",
|
|
57
|
+
"review",
|
|
58
|
+
"regression",
|
|
59
|
+
"rectification",
|
|
60
|
+
"agent-session",
|
|
61
|
+
"escalation",
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
for (const stage of stages) {
|
|
65
|
+
const failure: StructuredFailure = {
|
|
66
|
+
attempt: 1,
|
|
67
|
+
modelTier: "balanced",
|
|
68
|
+
stage,
|
|
69
|
+
summary: "Test failed",
|
|
70
|
+
timestamp: new Date().toISOString(),
|
|
71
|
+
};
|
|
72
|
+
expect(failure.stage).toBe(stage);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("should allow different model tiers", () => {
|
|
77
|
+
const tiers = ["fast", "balanced", "powerful"];
|
|
78
|
+
|
|
79
|
+
for (const tier of tiers) {
|
|
80
|
+
const failure: StructuredFailure = {
|
|
81
|
+
attempt: 1,
|
|
82
|
+
modelTier: tier,
|
|
83
|
+
stage: "verify",
|
|
84
|
+
summary: "Test failed",
|
|
85
|
+
timestamp: new Date().toISOString(),
|
|
86
|
+
};
|
|
87
|
+
expect(failure.modelTier).toBe(tier);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("should track multiple test failures", () => {
|
|
92
|
+
const testFailures: TestFailureContext[] = [
|
|
93
|
+
{
|
|
94
|
+
file: "test/foo.test.ts",
|
|
95
|
+
testName: "test 1",
|
|
96
|
+
error: "Error 1",
|
|
97
|
+
stackTrace: ["at foo.ts:10"],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
file: "test/bar.test.ts",
|
|
101
|
+
testName: "test 2",
|
|
102
|
+
error: "Error 2",
|
|
103
|
+
stackTrace: ["at bar.ts:20"],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
file: "test/baz.test.ts",
|
|
107
|
+
testName: "test 3",
|
|
108
|
+
error: "Error 3",
|
|
109
|
+
stackTrace: ["at baz.ts:30"],
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
const failure: StructuredFailure = {
|
|
114
|
+
attempt: 2,
|
|
115
|
+
modelTier: "balanced",
|
|
116
|
+
stage: "regression",
|
|
117
|
+
summary: "Multiple test failures",
|
|
118
|
+
testFailures,
|
|
119
|
+
timestamp: new Date().toISOString(),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
expect(failure.testFailures?.length).toBe(3);
|
|
123
|
+
expect(failure.testFailures?.[0].file).toBe("test/foo.test.ts");
|
|
124
|
+
expect(failure.testFailures?.[1].file).toBe("test/bar.test.ts");
|
|
125
|
+
expect(failure.testFailures?.[2].file).toBe("test/baz.test.ts");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("TestFailureContext Type", () => {
|
|
130
|
+
test("should have all required fields", () => {
|
|
131
|
+
const context: TestFailureContext = {
|
|
132
|
+
file: "test/example.test.ts",
|
|
133
|
+
testName: "should do something",
|
|
134
|
+
error: "AssertionError: expected true to be false",
|
|
135
|
+
stackTrace: ["at Object.test (example.test.ts:42:10)", "at async runTest (test.ts:100:5)"],
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
expect(context.file).toBe("test/example.test.ts");
|
|
139
|
+
expect(context.testName).toBe("should do something");
|
|
140
|
+
expect(context.error).toBe("AssertionError: expected true to be false");
|
|
141
|
+
expect(context.stackTrace.length).toBe(2);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("should handle nested test names", () => {
|
|
145
|
+
const context: TestFailureContext = {
|
|
146
|
+
file: "test/example.test.ts",
|
|
147
|
+
testName: "describe block > nested block > test name",
|
|
148
|
+
error: "Error",
|
|
149
|
+
stackTrace: [],
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
expect(context.testName).toContain("describe block");
|
|
153
|
+
expect(context.testName).toContain("nested block");
|
|
154
|
+
expect(context.testName).toContain("test name");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("should support empty stack traces", () => {
|
|
158
|
+
const context: TestFailureContext = {
|
|
159
|
+
file: "test/example.test.ts",
|
|
160
|
+
testName: "test",
|
|
161
|
+
error: "Error",
|
|
162
|
+
stackTrace: [],
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
expect(context.stackTrace.length).toBe(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("should support multiple stack trace lines", () => {
|
|
169
|
+
const context: TestFailureContext = {
|
|
170
|
+
file: "test/example.test.ts",
|
|
171
|
+
testName: "test",
|
|
172
|
+
error: "Error",
|
|
173
|
+
stackTrace: [
|
|
174
|
+
"at foo.ts:10:15",
|
|
175
|
+
"at bar.ts:20:10",
|
|
176
|
+
"at baz.ts:30:5",
|
|
177
|
+
"at Object.test (example.ts:40:3)",
|
|
178
|
+
"at async runTest (test.ts:50:5)",
|
|
179
|
+
],
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
expect(context.stackTrace.length).toBe(5);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("UserStory priorFailures Field", () => {
|
|
187
|
+
test("should have optional priorFailures field", () => {
|
|
188
|
+
const story: UserStory = {
|
|
189
|
+
id: "US-001",
|
|
190
|
+
title: "Test Story",
|
|
191
|
+
description: "A test story",
|
|
192
|
+
acceptanceCriteria: [],
|
|
193
|
+
tags: [],
|
|
194
|
+
dependencies: [],
|
|
195
|
+
status: "pending",
|
|
196
|
+
passes: false,
|
|
197
|
+
escalations: [],
|
|
198
|
+
attempts: 0,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
expect(story.priorFailures).toBeUndefined();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("should initialize priorFailures to empty array in loadPRD", async () => {
|
|
205
|
+
// Create a temporary PRD file without priorFailures
|
|
206
|
+
const prdContent = JSON.stringify({
|
|
207
|
+
project: "test",
|
|
208
|
+
feature: "test-feature",
|
|
209
|
+
branchName: "test-branch",
|
|
210
|
+
createdAt: new Date().toISOString(),
|
|
211
|
+
updatedAt: new Date().toISOString(),
|
|
212
|
+
userStories: [
|
|
213
|
+
{
|
|
214
|
+
id: "US-001",
|
|
215
|
+
title: "Test Story",
|
|
216
|
+
description: "Description",
|
|
217
|
+
acceptanceCriteria: [],
|
|
218
|
+
tags: [],
|
|
219
|
+
dependencies: [],
|
|
220
|
+
status: "pending",
|
|
221
|
+
passes: false,
|
|
222
|
+
escalations: [],
|
|
223
|
+
attempts: 0,
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Write to temp file
|
|
229
|
+
const tmpFile = "/tmp/test-prd-priorFailures.json";
|
|
230
|
+
await Bun.write(tmpFile, prdContent);
|
|
231
|
+
|
|
232
|
+
// Load and verify
|
|
233
|
+
const prd = await loadPRD(tmpFile);
|
|
234
|
+
expect(prd.userStories[0].priorFailures).toBeDefined();
|
|
235
|
+
expect(Array.isArray(prd.userStories[0].priorFailures)).toBe(true);
|
|
236
|
+
expect(prd.userStories[0].priorFailures?.length).toBe(0);
|
|
237
|
+
|
|
238
|
+
// Cleanup
|
|
239
|
+
await Bun.file(tmpFile).delete();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("should preserve existing priorFailures when loading PRD", async () => {
|
|
243
|
+
const existingFailure: StructuredFailure = {
|
|
244
|
+
attempt: 1,
|
|
245
|
+
modelTier: "balanced",
|
|
246
|
+
stage: "verify",
|
|
247
|
+
summary: "Test failed",
|
|
248
|
+
timestamp: new Date().toISOString(),
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const prdContent = JSON.stringify({
|
|
252
|
+
project: "test",
|
|
253
|
+
feature: "test-feature",
|
|
254
|
+
branchName: "test-branch",
|
|
255
|
+
createdAt: new Date().toISOString(),
|
|
256
|
+
updatedAt: new Date().toISOString(),
|
|
257
|
+
userStories: [
|
|
258
|
+
{
|
|
259
|
+
id: "US-001",
|
|
260
|
+
title: "Test Story",
|
|
261
|
+
description: "Description",
|
|
262
|
+
acceptanceCriteria: [],
|
|
263
|
+
tags: [],
|
|
264
|
+
dependencies: [],
|
|
265
|
+
status: "pending",
|
|
266
|
+
passes: false,
|
|
267
|
+
escalations: [],
|
|
268
|
+
attempts: 1,
|
|
269
|
+
priorFailures: [existingFailure],
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const tmpFile = "/tmp/test-prd-existing-failures.json";
|
|
275
|
+
await Bun.write(tmpFile, prdContent);
|
|
276
|
+
|
|
277
|
+
const prd = await loadPRD(tmpFile);
|
|
278
|
+
expect(prd.userStories[0].priorFailures?.length).toBe(1);
|
|
279
|
+
expect(prd.userStories[0].priorFailures?.[0].attempt).toBe(1);
|
|
280
|
+
expect(prd.userStories[0].priorFailures?.[0].stage).toBe("verify");
|
|
281
|
+
|
|
282
|
+
await Bun.file(tmpFile).delete();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("should allow adding multiple priorFailures to a story", () => {
|
|
286
|
+
const story: UserStory = {
|
|
287
|
+
id: "US-001",
|
|
288
|
+
title: "Test Story",
|
|
289
|
+
description: "Description",
|
|
290
|
+
acceptanceCriteria: [],
|
|
291
|
+
tags: [],
|
|
292
|
+
dependencies: [],
|
|
293
|
+
status: "failed",
|
|
294
|
+
passes: false,
|
|
295
|
+
escalations: [],
|
|
296
|
+
attempts: 3,
|
|
297
|
+
priorFailures: [
|
|
298
|
+
{
|
|
299
|
+
attempt: 1,
|
|
300
|
+
modelTier: "fast",
|
|
301
|
+
stage: "verify",
|
|
302
|
+
summary: "First failure",
|
|
303
|
+
timestamp: new Date().toISOString(),
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
attempt: 2,
|
|
307
|
+
modelTier: "balanced",
|
|
308
|
+
stage: "regression",
|
|
309
|
+
summary: "Second failure",
|
|
310
|
+
timestamp: new Date().toISOString(),
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
expect(story.priorFailures?.length).toBe(2);
|
|
316
|
+
expect(story.priorFailures?.[0].modelTier).toBe("fast");
|
|
317
|
+
expect(story.priorFailures?.[1].modelTier).toBe("balanced");
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe("StructuredFailure Attempt Tracking", () => {
|
|
322
|
+
test("should increment attempt number correctly", () => {
|
|
323
|
+
const failures: StructuredFailure[] = [];
|
|
324
|
+
|
|
325
|
+
for (let i = 1; i <= 3; i++) {
|
|
326
|
+
failures.push({
|
|
327
|
+
attempt: i,
|
|
328
|
+
modelTier: "balanced",
|
|
329
|
+
stage: "verify",
|
|
330
|
+
summary: `Attempt ${i} failed`,
|
|
331
|
+
timestamp: new Date().toISOString(),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
expect(failures[0].attempt).toBe(1);
|
|
336
|
+
expect(failures[1].attempt).toBe(2);
|
|
337
|
+
expect(failures[2].attempt).toBe(3);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("should track tier escalation in priorFailures", () => {
|
|
341
|
+
const failures: StructuredFailure[] = [
|
|
342
|
+
{
|
|
343
|
+
attempt: 1,
|
|
344
|
+
modelTier: "fast",
|
|
345
|
+
stage: "verify",
|
|
346
|
+
summary: "Failed on fast tier",
|
|
347
|
+
timestamp: new Date().toISOString(),
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
attempt: 2,
|
|
351
|
+
modelTier: "balanced",
|
|
352
|
+
stage: "escalation",
|
|
353
|
+
summary: "Escalated to balanced tier",
|
|
354
|
+
timestamp: new Date().toISOString(),
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
attempt: 3,
|
|
358
|
+
modelTier: "powerful",
|
|
359
|
+
stage: "escalation",
|
|
360
|
+
summary: "Escalated to powerful tier",
|
|
361
|
+
timestamp: new Date().toISOString(),
|
|
362
|
+
},
|
|
363
|
+
];
|
|
364
|
+
|
|
365
|
+
expect(failures.length).toBe(3);
|
|
366
|
+
expect(failures[0].modelTier).toBe("fast");
|
|
367
|
+
expect(failures[1].modelTier).toBe("balanced");
|
|
368
|
+
expect(failures[2].modelTier).toBe("powerful");
|
|
369
|
+
|
|
370
|
+
// Verify escalation stages
|
|
371
|
+
expect(failures[1].stage).toBe("escalation");
|
|
372
|
+
expect(failures[2].stage).toBe("escalation");
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
describe("StructuredFailure Timestamps", () => {
|
|
377
|
+
test("should use ISO format timestamps", () => {
|
|
378
|
+
const failure: StructuredFailure = {
|
|
379
|
+
attempt: 1,
|
|
380
|
+
modelTier: "balanced",
|
|
381
|
+
stage: "verify",
|
|
382
|
+
summary: "Test failed",
|
|
383
|
+
timestamp: new Date().toISOString(),
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
// ISO format: YYYY-MM-DDTHH:mm:ss.sssZ
|
|
387
|
+
expect(failure.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("should have different timestamps for different failures", async () => {
|
|
391
|
+
const failure1: StructuredFailure = {
|
|
392
|
+
attempt: 1,
|
|
393
|
+
modelTier: "balanced",
|
|
394
|
+
stage: "verify",
|
|
395
|
+
summary: "First failure",
|
|
396
|
+
timestamp: new Date().toISOString(),
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// Small delay to ensure different timestamp
|
|
400
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
401
|
+
|
|
402
|
+
const failure2: StructuredFailure = {
|
|
403
|
+
attempt: 2,
|
|
404
|
+
modelTier: "balanced",
|
|
405
|
+
stage: "verify",
|
|
406
|
+
summary: "Second failure",
|
|
407
|
+
timestamp: new Date().toISOString(),
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// While millisecond precision should make them different, we just verify they're valid
|
|
411
|
+
expect(failure1.timestamp).toBeDefined();
|
|
412
|
+
expect(failure2.timestamp).toBeDefined();
|
|
413
|
+
});
|
|
414
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { Logger, getLogger, initLogger, resetLogger } from "../../src/logger
|
|
4
|
+
import { Logger, getLogger, initLogger, resetLogger } from "../../src/logger";
|
|
5
5
|
|
|
6
6
|
const TEST_LOG_DIR = path.join(process.cwd(), "test-logs");
|
|
7
7
|
const TEST_LOG_FILE = path.join(TEST_LOG_DIR, "test.jsonl");
|
|
@@ -124,7 +124,8 @@ describe("Review Stage - Plugin Integration", () => {
|
|
|
124
124
|
const result = await reviewStage.execute(ctx);
|
|
125
125
|
|
|
126
126
|
expect(pluginCalled).toBe(false);
|
|
127
|
-
|
|
127
|
+
// BUG-030: Review lint/typecheck failures now escalate instead of hard-failing
|
|
128
|
+
expect(result.action).toBe("escalate");
|
|
128
129
|
expect(result.reason).toContain("Review failed");
|
|
129
130
|
});
|
|
130
131
|
|
|
@@ -11,7 +11,7 @@ import { tmpdir } from "node:os";
|
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import type { NaxConfig } from "../../src/config/schema";
|
|
13
13
|
import { initLogger, resetLogger } from "../../src/logger";
|
|
14
|
-
import { getLogger } from "../../src/logger
|
|
14
|
+
import { getLogger } from "../../src/logger";
|
|
15
15
|
import type { LogEntry } from "../../src/logger/types";
|
|
16
16
|
import { executionStage } from "../../src/pipeline/stages/execution";
|
|
17
17
|
import { verifyStage } from "../../src/pipeline/stages/verify";
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart Test Runner Config Flag Tests (STR-004)
|
|
3
|
+
*
|
|
4
|
+
* Verifies that execution.smartTestRunner is present in the ExecutionConfig
|
|
5
|
+
* interface, defaults to true in the Zod schema, and loads correctly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { globalConfigPath, loadConfig } from "../../../src/config/loader";
|
|
13
|
+
import { DEFAULT_CONFIG } from "../../../src/config/defaults";
|
|
14
|
+
import { NaxConfigSchema } from "../../../src/config/schemas";
|
|
15
|
+
|
|
16
|
+
describe("execution.smartTestRunner config flag", () => {
|
|
17
|
+
let tempDir: string;
|
|
18
|
+
let globalBackup: string | null = null;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
tempDir = join(tmpdir(), `nax-str-004-${Date.now()}`);
|
|
22
|
+
mkdirSync(join(tempDir, "nax"), { recursive: true });
|
|
23
|
+
|
|
24
|
+
const globalPath = globalConfigPath();
|
|
25
|
+
if (existsSync(globalPath)) {
|
|
26
|
+
globalBackup = `${globalPath}.test-backup-${Date.now()}`;
|
|
27
|
+
renameSync(globalPath, globalBackup);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
if (tempDir) {
|
|
33
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
if (globalBackup && existsSync(globalBackup)) {
|
|
36
|
+
const globalPath = globalConfigPath();
|
|
37
|
+
if (existsSync(globalPath)) rmSync(globalPath);
|
|
38
|
+
renameSync(globalBackup, globalPath);
|
|
39
|
+
globalBackup = null;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("DEFAULT_CONFIG has smartTestRunner: true", () => {
|
|
44
|
+
expect(DEFAULT_CONFIG.execution.smartTestRunner).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("Zod schema defaults smartTestRunner to enabled object when field is absent", () => {
|
|
48
|
+
const minimal = {
|
|
49
|
+
version: 1,
|
|
50
|
+
models: {
|
|
51
|
+
fast: { provider: "anthropic", model: "haiku" },
|
|
52
|
+
balanced: { provider: "anthropic", model: "sonnet" },
|
|
53
|
+
powerful: { provider: "anthropic", model: "opus" },
|
|
54
|
+
},
|
|
55
|
+
autoMode: {
|
|
56
|
+
enabled: true,
|
|
57
|
+
defaultAgent: "claude",
|
|
58
|
+
fallbackOrder: [],
|
|
59
|
+
complexityRouting: { simple: "fast", medium: "balanced", complex: "powerful", expert: "powerful" },
|
|
60
|
+
escalation: { enabled: true, tierOrder: [{ tier: "fast", attempts: 1 }] },
|
|
61
|
+
},
|
|
62
|
+
routing: { strategy: "keyword" },
|
|
63
|
+
execution: {
|
|
64
|
+
maxIterations: 10,
|
|
65
|
+
iterationDelayMs: 0,
|
|
66
|
+
costLimit: 1,
|
|
67
|
+
sessionTimeoutSeconds: 60,
|
|
68
|
+
maxStoriesPerFeature: 10,
|
|
69
|
+
rectification: {
|
|
70
|
+
enabled: true,
|
|
71
|
+
maxRetries: 1,
|
|
72
|
+
fullSuiteTimeoutSeconds: 30,
|
|
73
|
+
maxFailureSummaryChars: 500,
|
|
74
|
+
abortOnIncreasingFailures: true,
|
|
75
|
+
},
|
|
76
|
+
regressionGate: { enabled: true, timeoutSeconds: 30 },
|
|
77
|
+
contextProviderTokenBudget: 100,
|
|
78
|
+
// smartTestRunner intentionally omitted
|
|
79
|
+
},
|
|
80
|
+
quality: {
|
|
81
|
+
requireTypecheck: true,
|
|
82
|
+
requireLint: true,
|
|
83
|
+
requireTests: true,
|
|
84
|
+
commands: {},
|
|
85
|
+
forceExit: false,
|
|
86
|
+
detectOpenHandles: true,
|
|
87
|
+
detectOpenHandlesRetries: 1,
|
|
88
|
+
gracePeriodMs: 500,
|
|
89
|
+
drainTimeoutMs: 0,
|
|
90
|
+
shell: "/bin/sh",
|
|
91
|
+
stripEnvVars: [],
|
|
92
|
+
environmentalEscalationDivisor: 2,
|
|
93
|
+
},
|
|
94
|
+
tdd: { maxRetries: 0, autoVerifyIsolation: false, autoApproveVerifier: false },
|
|
95
|
+
constitution: { enabled: false, path: "constitution.md", maxTokens: 100 },
|
|
96
|
+
analyze: { llmEnhanced: false, model: "balanced", fallbackToKeywords: true, maxCodebaseSummaryTokens: 100 },
|
|
97
|
+
review: { enabled: false, checks: [], commands: {} },
|
|
98
|
+
plan: { model: "balanced", outputPath: "spec.md" },
|
|
99
|
+
acceptance: { enabled: false, maxRetries: 0, generateTests: false, testPath: "acceptance.test.ts" },
|
|
100
|
+
context: {
|
|
101
|
+
testCoverage: { enabled: false, detail: "names-only", maxTokens: 50, testPattern: "**/*.test.ts", scopeToStory: false },
|
|
102
|
+
autoDetect: { enabled: false, maxFiles: 1, traceImports: false },
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const result = NaxConfigSchema.safeParse(minimal);
|
|
107
|
+
expect(result.success).toBe(true);
|
|
108
|
+
if (result.success) {
|
|
109
|
+
expect(result.data.execution.smartTestRunner).toEqual({
|
|
110
|
+
enabled: true,
|
|
111
|
+
testFilePatterns: ["test/**/*.test.ts"],
|
|
112
|
+
fallback: "import-grep",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("Zod schema coerces smartTestRunner: false to disabled config object", () => {
|
|
118
|
+
const result = NaxConfigSchema.safeParse({
|
|
119
|
+
...buildMinimalConfig(),
|
|
120
|
+
execution: { ...buildMinimalConfig().execution, smartTestRunner: false },
|
|
121
|
+
});
|
|
122
|
+
expect(result.success).toBe(true);
|
|
123
|
+
if (result.success) {
|
|
124
|
+
expect(result.data.execution.smartTestRunner).toEqual({
|
|
125
|
+
enabled: false,
|
|
126
|
+
testFilePatterns: ["test/**/*.test.ts"],
|
|
127
|
+
fallback: "import-grep",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("Zod schema coerces smartTestRunner: true to enabled config object", () => {
|
|
133
|
+
const result = NaxConfigSchema.safeParse({
|
|
134
|
+
...buildMinimalConfig(),
|
|
135
|
+
execution: { ...buildMinimalConfig().execution, smartTestRunner: true },
|
|
136
|
+
});
|
|
137
|
+
expect(result.success).toBe(true);
|
|
138
|
+
if (result.success) {
|
|
139
|
+
expect(result.data.execution.smartTestRunner).toEqual({
|
|
140
|
+
enabled: true,
|
|
141
|
+
testFilePatterns: ["test/**/*.test.ts"],
|
|
142
|
+
fallback: "import-grep",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("loadConfig defaults smartTestRunner to enabled object when not in project config", async () => {
|
|
148
|
+
const configPath = join(tempDir, "nax", "config.json");
|
|
149
|
+
writeFileSync(configPath, JSON.stringify({ routing: { strategy: "keyword" } }, null, 2));
|
|
150
|
+
|
|
151
|
+
const config = await loadConfig(join(tempDir, "nax"));
|
|
152
|
+
expect(config.execution.smartTestRunner).toEqual({
|
|
153
|
+
enabled: true,
|
|
154
|
+
testFilePatterns: ["test/**/*.test.ts"],
|
|
155
|
+
fallback: "import-grep",
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("loadConfig coerces smartTestRunner: false to disabled config object", async () => {
|
|
160
|
+
const configPath = join(tempDir, "nax", "config.json");
|
|
161
|
+
writeFileSync(
|
|
162
|
+
configPath,
|
|
163
|
+
JSON.stringify({ execution: { smartTestRunner: false } }, null, 2),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const config = await loadConfig(join(tempDir, "nax"));
|
|
167
|
+
expect(config.execution.smartTestRunner).toEqual({
|
|
168
|
+
enabled: false,
|
|
169
|
+
testFilePatterns: ["test/**/*.test.ts"],
|
|
170
|
+
fallback: "import-grep",
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("loadConfig normalizes to enabled object when field is absent (backward compat)", async () => {
|
|
175
|
+
const configPath = join(tempDir, "nax", "config.json");
|
|
176
|
+
// Config without smartTestRunner field at all
|
|
177
|
+
writeFileSync(configPath, JSON.stringify({}, null, 2));
|
|
178
|
+
|
|
179
|
+
const config = await loadConfig(join(tempDir, "nax"));
|
|
180
|
+
expect(config.execution.smartTestRunner).toEqual({
|
|
181
|
+
enabled: true,
|
|
182
|
+
testFilePatterns: ["test/**/*.test.ts"],
|
|
183
|
+
fallback: "import-grep",
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Minimal valid config helper
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
function buildMinimalConfig() {
|
|
193
|
+
return {
|
|
194
|
+
version: 1,
|
|
195
|
+
models: {
|
|
196
|
+
fast: { provider: "anthropic", model: "haiku" },
|
|
197
|
+
balanced: { provider: "anthropic", model: "sonnet" },
|
|
198
|
+
powerful: { provider: "anthropic", model: "opus" },
|
|
199
|
+
},
|
|
200
|
+
autoMode: {
|
|
201
|
+
enabled: true,
|
|
202
|
+
defaultAgent: "claude",
|
|
203
|
+
fallbackOrder: [],
|
|
204
|
+
complexityRouting: { simple: "fast", medium: "balanced", complex: "powerful", expert: "powerful" },
|
|
205
|
+
escalation: { enabled: true, tierOrder: [{ tier: "fast", attempts: 1 }] },
|
|
206
|
+
},
|
|
207
|
+
routing: { strategy: "keyword" as const },
|
|
208
|
+
execution: {
|
|
209
|
+
maxIterations: 10,
|
|
210
|
+
iterationDelayMs: 0,
|
|
211
|
+
costLimit: 1,
|
|
212
|
+
sessionTimeoutSeconds: 60,
|
|
213
|
+
maxStoriesPerFeature: 10,
|
|
214
|
+
rectification: {
|
|
215
|
+
enabled: true,
|
|
216
|
+
maxRetries: 1,
|
|
217
|
+
fullSuiteTimeoutSeconds: 30,
|
|
218
|
+
maxFailureSummaryChars: 500,
|
|
219
|
+
abortOnIncreasingFailures: true,
|
|
220
|
+
},
|
|
221
|
+
regressionGate: { enabled: true, timeoutSeconds: 30 },
|
|
222
|
+
contextProviderTokenBudget: 100,
|
|
223
|
+
},
|
|
224
|
+
quality: {
|
|
225
|
+
requireTypecheck: true,
|
|
226
|
+
requireLint: true,
|
|
227
|
+
requireTests: true,
|
|
228
|
+
commands: {},
|
|
229
|
+
forceExit: false,
|
|
230
|
+
detectOpenHandles: true,
|
|
231
|
+
detectOpenHandlesRetries: 1,
|
|
232
|
+
gracePeriodMs: 500,
|
|
233
|
+
drainTimeoutMs: 0,
|
|
234
|
+
shell: "/bin/sh",
|
|
235
|
+
stripEnvVars: [],
|
|
236
|
+
environmentalEscalationDivisor: 2,
|
|
237
|
+
},
|
|
238
|
+
tdd: { maxRetries: 0, autoVerifyIsolation: false, autoApproveVerifier: false },
|
|
239
|
+
constitution: { enabled: false, path: "constitution.md", maxTokens: 100 },
|
|
240
|
+
analyze: { llmEnhanced: false, model: "balanced", fallbackToKeywords: true, maxCodebaseSummaryTokens: 100 },
|
|
241
|
+
review: { enabled: false, checks: [] as Array<"typecheck" | "lint" | "test">, commands: {} },
|
|
242
|
+
plan: { model: "balanced", outputPath: "spec.md" },
|
|
243
|
+
acceptance: { enabled: false, maxRetries: 0, generateTests: false, testPath: "acceptance.test.ts" },
|
|
244
|
+
context: {
|
|
245
|
+
testCoverage: { enabled: false, detail: "names-only" as const, maxTokens: 50, testPattern: "**/*.test.ts", scopeToStory: false },
|
|
246
|
+
autoDetect: { enabled: false, maxFiles: 1, traceImports: false },
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|