@nathapp/nax 0.23.0 → 0.25.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/docs/ROADMAP.md +33 -15
- package/docs/specs/trigger-completion.md +145 -0
- package/nax/features/central-run-registry/prd.json +105 -0
- package/nax/features/trigger-completion/prd.json +150 -0
- package/nax/features/trigger-completion/progress.txt +7 -0
- package/nax/status.json +14 -24
- 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/config/types.ts +3 -1
- package/src/execution/crash-recovery.ts +11 -0
- package/src/execution/executor-types.ts +1 -1
- package/src/execution/lifecycle/run-setup.ts +4 -0
- package/src/execution/sequential-executor.ts +49 -7
- package/src/interaction/plugins/auto.ts +10 -1
- package/src/pipeline/event-bus.ts +14 -1
- package/src/pipeline/stages/completion.ts +20 -0
- package/src/pipeline/stages/execution.ts +62 -0
- package/src/pipeline/stages/review.ts +25 -1
- package/src/pipeline/subscribers/events-writer.ts +121 -0
- package/src/pipeline/subscribers/hooks.ts +32 -0
- package/src/pipeline/subscribers/interaction.ts +36 -1
- package/src/pipeline/subscribers/registry.ts +73 -0
- package/src/routing/router.ts +3 -2
- package/src/routing/strategies/keyword.ts +2 -1
- package/src/routing/strategies/llm-prompts.ts +29 -28
- package/src/utils/git.ts +21 -0
- package/test/integration/cli/cli-logs.test.ts +40 -17
- package/test/integration/routing/plugin-routing-core.test.ts +1 -1
- package/test/unit/commands/logs.test.ts +63 -22
- package/test/unit/commands/runs.test.ts +303 -0
- package/test/unit/execution/sequential-executor.test.ts +235 -0
- package/test/unit/interaction/auto-plugin.test.ts +162 -0
- package/test/unit/interaction-plugins.test.ts +308 -1
- package/test/unit/pipeline/stages/completion-review-gate.test.ts +218 -0
- package/test/unit/pipeline/stages/execution-ambiguity.test.ts +311 -0
- package/test/unit/pipeline/stages/execution-merge-conflict.test.ts +218 -0
- package/test/unit/pipeline/stages/review.test.ts +201 -0
- package/test/unit/pipeline/subscribers/events-writer.test.ts +227 -0
- package/test/unit/pipeline/subscribers/hooks.test.ts +43 -4
- package/test/unit/pipeline/subscribers/interaction.test.ts +284 -2
- package/test/unit/pipeline/subscribers/registry.test.ts +149 -0
- package/test/unit/prd-auto-default.test.ts +2 -2
- package/test/unit/routing/routing-stability.test.ts +1 -1
- package/test/unit/routing-core.test.ts +5 -5
- package/test/unit/routing-strategies.test.ts +1 -3
- package/test/unit/utils/git.test.ts +50 -0
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* Tests for Telegram, Webhook, and Auto plugins.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { createHmac } from "node:crypto";
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
9
10
|
import type { InteractionRequest } from "../../src/interaction";
|
|
10
11
|
import { AutoInteractionPlugin } from "../../src/interaction/plugins/auto";
|
|
11
12
|
import { TelegramInteractionPlugin } from "../../src/interaction/plugins/telegram";
|
|
@@ -163,3 +164,309 @@ describe("AutoInteractionPlugin", () => {
|
|
|
163
164
|
expect(plugin.name).toBe("auto");
|
|
164
165
|
});
|
|
165
166
|
});
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Telegram send() and poll() flow tests (TC-006)
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
describe("TelegramInteractionPlugin - send() and poll()", () => {
|
|
173
|
+
const originalFetch = globalThis.fetch;
|
|
174
|
+
|
|
175
|
+
afterEach(() => {
|
|
176
|
+
mock.restore();
|
|
177
|
+
globalThis.fetch = originalFetch;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
function makeConfirmRequest(id: string): InteractionRequest {
|
|
181
|
+
return {
|
|
182
|
+
id,
|
|
183
|
+
type: "confirm",
|
|
184
|
+
featureName: "my-feature",
|
|
185
|
+
stage: "review",
|
|
186
|
+
summary: "Proceed with merge?",
|
|
187
|
+
fallback: "abort",
|
|
188
|
+
createdAt: Date.now(),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
test("send() POSTs to correct Telegram API URL with message text and inline keyboard", async () => {
|
|
193
|
+
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
|
|
194
|
+
|
|
195
|
+
globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
|
|
196
|
+
const urlStr = url.toString();
|
|
197
|
+
const body = JSON.parse((init?.body as string) ?? "{}");
|
|
198
|
+
calls.push({ url: urlStr, body });
|
|
199
|
+
return new Response(
|
|
200
|
+
JSON.stringify({ ok: true, result: { message_id: 42, chat: { id: 12345 } } }),
|
|
201
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
202
|
+
);
|
|
203
|
+
}) as typeof fetch;
|
|
204
|
+
|
|
205
|
+
const plugin = new TelegramInteractionPlugin();
|
|
206
|
+
await plugin.init({ botToken: "bot-abc123", chatId: "99999" });
|
|
207
|
+
|
|
208
|
+
await plugin.send(makeConfirmRequest("tg-send-1"));
|
|
209
|
+
|
|
210
|
+
expect(calls).toHaveLength(1);
|
|
211
|
+
const { url, body } = calls[0];
|
|
212
|
+
|
|
213
|
+
// Correct API endpoint
|
|
214
|
+
expect(url).toContain("api.telegram.org/botbot-abc123/sendMessage");
|
|
215
|
+
|
|
216
|
+
// Correct chat_id
|
|
217
|
+
expect(body.chat_id).toBe("99999");
|
|
218
|
+
|
|
219
|
+
// Message text present
|
|
220
|
+
expect(typeof body.text).toBe("string");
|
|
221
|
+
expect((body.text as string).length).toBeGreaterThan(0);
|
|
222
|
+
|
|
223
|
+
// Inline keyboard has approve and reject buttons
|
|
224
|
+
const keyboard = (body.reply_markup as { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> })
|
|
225
|
+
.inline_keyboard;
|
|
226
|
+
expect(Array.isArray(keyboard)).toBe(true);
|
|
227
|
+
const allButtons = keyboard.flat();
|
|
228
|
+
const approveBtn = allButtons.find((b) => b.callback_data === "tg-send-1:approve");
|
|
229
|
+
const rejectBtn = allButtons.find((b) => b.callback_data === "tg-send-1:reject");
|
|
230
|
+
expect(approveBtn).toBeDefined();
|
|
231
|
+
expect(rejectBtn).toBeDefined();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("receive() parses callback_query correctly", async () => {
|
|
235
|
+
globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
|
|
236
|
+
const urlStr = url.toString();
|
|
237
|
+
|
|
238
|
+
if (urlStr.includes("sendMessage")) {
|
|
239
|
+
return new Response(
|
|
240
|
+
JSON.stringify({ ok: true, result: { message_id: 10, chat: { id: 99999 } } }),
|
|
241
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (urlStr.includes("getUpdates")) {
|
|
246
|
+
return new Response(
|
|
247
|
+
JSON.stringify({
|
|
248
|
+
ok: true,
|
|
249
|
+
result: [
|
|
250
|
+
{
|
|
251
|
+
update_id: 1,
|
|
252
|
+
callback_query: {
|
|
253
|
+
id: "cq-001",
|
|
254
|
+
data: "tg-poll-1:approve",
|
|
255
|
+
message: { message_id: 10, chat: { id: 99999 } },
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
}),
|
|
260
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (urlStr.includes("answerCallbackQuery")) {
|
|
265
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return new Response("not found", { status: 404 });
|
|
269
|
+
}) as typeof fetch;
|
|
270
|
+
|
|
271
|
+
const plugin = new TelegramInteractionPlugin();
|
|
272
|
+
await plugin.init({ botToken: "bot-abc123", chatId: "99999" });
|
|
273
|
+
|
|
274
|
+
// send() first so message_id is stored (needed for text-message flow, not callback_query)
|
|
275
|
+
await plugin.send(makeConfirmRequest("tg-poll-1"));
|
|
276
|
+
|
|
277
|
+
const response = await plugin.receive("tg-poll-1", 5000);
|
|
278
|
+
|
|
279
|
+
expect(response.action).toBe("approve");
|
|
280
|
+
expect(response.respondedBy).toBe("telegram");
|
|
281
|
+
expect(response.requestId).toBe("tg-poll-1");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("receive() handles choose callback_query with value", async () => {
|
|
285
|
+
globalThis.fetch = mock(async (url: string | URL | Request) => {
|
|
286
|
+
const urlStr = url.toString();
|
|
287
|
+
|
|
288
|
+
if (urlStr.includes("sendMessage")) {
|
|
289
|
+
return new Response(
|
|
290
|
+
JSON.stringify({ ok: true, result: { message_id: 11, chat: { id: 99999 } } }),
|
|
291
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (urlStr.includes("getUpdates")) {
|
|
296
|
+
return new Response(
|
|
297
|
+
JSON.stringify({
|
|
298
|
+
ok: true,
|
|
299
|
+
result: [
|
|
300
|
+
{
|
|
301
|
+
update_id: 2,
|
|
302
|
+
callback_query: {
|
|
303
|
+
id: "cq-002",
|
|
304
|
+
data: "tg-choose-1:choose:option-b",
|
|
305
|
+
message: { message_id: 11, chat: { id: 99999 } },
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
}),
|
|
310
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (urlStr.includes("answerCallbackQuery")) {
|
|
315
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return new Response("not found", { status: 404 });
|
|
319
|
+
}) as typeof fetch;
|
|
320
|
+
|
|
321
|
+
const plugin = new TelegramInteractionPlugin();
|
|
322
|
+
await plugin.init({ botToken: "bot-abc123", chatId: "99999" });
|
|
323
|
+
|
|
324
|
+
const chooseRequest: InteractionRequest = {
|
|
325
|
+
id: "tg-choose-1",
|
|
326
|
+
type: "choose",
|
|
327
|
+
featureName: "my-feature",
|
|
328
|
+
stage: "review",
|
|
329
|
+
summary: "Which option?",
|
|
330
|
+
fallback: "continue",
|
|
331
|
+
createdAt: Date.now(),
|
|
332
|
+
options: [
|
|
333
|
+
{ key: "a", label: "Option A" },
|
|
334
|
+
{ key: "b", label: "Option B" },
|
|
335
|
+
],
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
await plugin.send(chooseRequest);
|
|
339
|
+
const response = await plugin.receive("tg-choose-1", 5000);
|
|
340
|
+
|
|
341
|
+
expect(response.action).toBe("choose");
|
|
342
|
+
expect(response.value).toBe("option-b");
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// Webhook send() and HMAC validation tests (TC-006)
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
describe("WebhookInteractionPlugin - send() and HMAC validation", () => {
|
|
351
|
+
afterEach(async () => {
|
|
352
|
+
mock.restore();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
function makeWebhookRequest(id: string): InteractionRequest {
|
|
356
|
+
return {
|
|
357
|
+
id,
|
|
358
|
+
type: "confirm",
|
|
359
|
+
featureName: "wh-feature",
|
|
360
|
+
stage: "merge",
|
|
361
|
+
summary: "Approve merge?",
|
|
362
|
+
fallback: "abort",
|
|
363
|
+
createdAt: Date.now(),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
test("send() POSTs payload with correct Content-Type", async () => {
|
|
368
|
+
// Start a local server to capture the outgoing request
|
|
369
|
+
const captured: { contentType: string | null; body: unknown } = { contentType: null, body: null };
|
|
370
|
+
|
|
371
|
+
const testServer = Bun.serve({
|
|
372
|
+
port: 19977,
|
|
373
|
+
fetch: async (req) => {
|
|
374
|
+
captured.contentType = req.headers.get("content-type");
|
|
375
|
+
captured.body = await req.json();
|
|
376
|
+
return new Response("OK", { status: 200 });
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const plugin = new WebhookInteractionPlugin();
|
|
381
|
+
try {
|
|
382
|
+
await plugin.init({ url: "http://localhost:19977/hook" });
|
|
383
|
+
|
|
384
|
+
await plugin.send(makeWebhookRequest("wh-send-1"));
|
|
385
|
+
|
|
386
|
+
expect(captured.contentType).toBe("application/json");
|
|
387
|
+
expect((captured.body as { id: string }).id).toBe("wh-send-1");
|
|
388
|
+
// callbackUrl is injected by send()
|
|
389
|
+
expect(typeof (captured.body as { callbackUrl: string }).callbackUrl).toBe("string");
|
|
390
|
+
} finally {
|
|
391
|
+
testServer.stop();
|
|
392
|
+
await plugin.destroy();
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("send() includes X-Nax-Signature header when secret is configured", async () => {
|
|
397
|
+
const captured: { signature: string | null; body: string } = { signature: null, body: "" };
|
|
398
|
+
|
|
399
|
+
const testServer = Bun.serve({
|
|
400
|
+
port: 19978,
|
|
401
|
+
fetch: async (req) => {
|
|
402
|
+
captured.signature = req.headers.get("x-nax-signature");
|
|
403
|
+
captured.body = await req.text();
|
|
404
|
+
return new Response("OK", { status: 200 });
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const plugin = new WebhookInteractionPlugin();
|
|
409
|
+
try {
|
|
410
|
+
await plugin.init({ url: "http://localhost:19978/hook", secret: "my-secret" });
|
|
411
|
+
|
|
412
|
+
await plugin.send(makeWebhookRequest("wh-sig-1"));
|
|
413
|
+
|
|
414
|
+
expect(captured.signature).not.toBeNull();
|
|
415
|
+
// Verify the signature matches expected HMAC
|
|
416
|
+
const expected = createHmac("sha256", "my-secret").update(captured.body).digest("hex");
|
|
417
|
+
expect(captured.signature).toBe(expected);
|
|
418
|
+
} finally {
|
|
419
|
+
testServer.stop();
|
|
420
|
+
await plugin.destroy();
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("HMAC validation: tampered payload (no signature) is rejected with 401", async () => {
|
|
425
|
+
const plugin = new WebhookInteractionPlugin();
|
|
426
|
+
// url won't be called in this test — we test the callback server
|
|
427
|
+
await plugin.init({
|
|
428
|
+
url: "http://localhost:19900/unused",
|
|
429
|
+
secret: "test-secret",
|
|
430
|
+
callbackPort: 19988,
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// Start the callback server by calling receive() in the background
|
|
434
|
+
const receivePromise = plugin.receive("wh-hmac-1", 4000);
|
|
435
|
+
|
|
436
|
+
// Give the server a moment to bind
|
|
437
|
+
await Bun.sleep(60);
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
// POST without signature → 401
|
|
441
|
+
const noSigResp = await fetch("http://localhost:19988/nax/interact/wh-hmac-1", {
|
|
442
|
+
method: "POST",
|
|
443
|
+
headers: { "Content-Type": "application/json" },
|
|
444
|
+
body: JSON.stringify({ requestId: "wh-hmac-1", action: "approve", respondedAt: Date.now() }),
|
|
445
|
+
});
|
|
446
|
+
expect(noSigResp.status).toBe(401);
|
|
447
|
+
|
|
448
|
+
// POST with wrong signature → 401
|
|
449
|
+
const badSigResp = await fetch("http://localhost:19988/nax/interact/wh-hmac-1", {
|
|
450
|
+
method: "POST",
|
|
451
|
+
headers: { "Content-Type": "application/json", "X-Nax-Signature": "deadbeef" },
|
|
452
|
+
body: JSON.stringify({ requestId: "wh-hmac-1", action: "approve", respondedAt: Date.now() }),
|
|
453
|
+
});
|
|
454
|
+
expect(badSigResp.status).toBe(401);
|
|
455
|
+
|
|
456
|
+
// POST with correct HMAC signature → 200, receive() resolves
|
|
457
|
+
const payload = JSON.stringify({ requestId: "wh-hmac-1", action: "approve", respondedAt: Date.now() });
|
|
458
|
+
const sig = createHmac("sha256", "test-secret").update(payload).digest("hex");
|
|
459
|
+
const validResp = await fetch("http://localhost:19988/nax/interact/wh-hmac-1", {
|
|
460
|
+
method: "POST",
|
|
461
|
+
headers: { "Content-Type": "application/json", "X-Nax-Signature": sig },
|
|
462
|
+
body: payload,
|
|
463
|
+
});
|
|
464
|
+
expect(validResp.status).toBe(200);
|
|
465
|
+
|
|
466
|
+
const response = await receivePromise;
|
|
467
|
+
expect(response.action).toBe("approve");
|
|
468
|
+
} finally {
|
|
469
|
+
await plugin.destroy();
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for review-gate trigger wiring in completion stage (TC-004)
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - review-gate trigger fires after each story passes when enabled
|
|
6
|
+
* - review-gate is disabled by default
|
|
7
|
+
* - trigger responds abort → logs warning (non-blocking)
|
|
8
|
+
* - trigger responds approve → continues normally
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
12
|
+
import { mkdtempSync } from "fs";
|
|
13
|
+
import { tmpdir } from "os";
|
|
14
|
+
import type { NaxConfig } from "../../../../src/config";
|
|
15
|
+
import { InteractionChain } from "../../../../src/interaction/chain";
|
|
16
|
+
import type { InteractionPlugin, InteractionResponse } from "../../../../src/interaction/types";
|
|
17
|
+
import { _completionDeps } from "../../../../src/pipeline/stages/completion";
|
|
18
|
+
import type { PipelineContext } from "../../../../src/pipeline/types";
|
|
19
|
+
import type { PRD, UserStory } from "../../../../src/prd";
|
|
20
|
+
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
// Save originals for restoration
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const originalCheckReviewGate = _completionDeps.checkReviewGate;
|
|
26
|
+
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
// Helpers
|
|
29
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function makeChain(action: InteractionResponse["action"]): InteractionChain {
|
|
32
|
+
const chain = new InteractionChain({ defaultTimeout: 5000, defaultFallback: "abort" });
|
|
33
|
+
const plugin: InteractionPlugin = {
|
|
34
|
+
name: "test",
|
|
35
|
+
send: mock(async () => {}),
|
|
36
|
+
receive: mock(async (id: string): Promise<InteractionResponse> => ({
|
|
37
|
+
requestId: id,
|
|
38
|
+
action,
|
|
39
|
+
respondedBy: "user",
|
|
40
|
+
respondedAt: Date.now(),
|
|
41
|
+
})),
|
|
42
|
+
};
|
|
43
|
+
chain.register(plugin);
|
|
44
|
+
return chain;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeConfig(triggers: Record<string, unknown>): NaxConfig {
|
|
48
|
+
return {
|
|
49
|
+
autoMode: { defaultAgent: "test-agent" },
|
|
50
|
+
models: { fast: "claude-haiku-4-5", balanced: "claude-sonnet-4-5", powerful: "claude-opus-4-5" },
|
|
51
|
+
execution: {
|
|
52
|
+
sessionTimeoutSeconds: 60,
|
|
53
|
+
dangerouslySkipPermissions: false,
|
|
54
|
+
costLimit: 10,
|
|
55
|
+
maxIterations: 10,
|
|
56
|
+
rectification: { maxRetries: 3 },
|
|
57
|
+
},
|
|
58
|
+
interaction: {
|
|
59
|
+
plugin: "cli",
|
|
60
|
+
defaults: { timeout: 30000, fallback: "abort" as const },
|
|
61
|
+
triggers,
|
|
62
|
+
},
|
|
63
|
+
} as unknown as NaxConfig;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeStory(): UserStory {
|
|
67
|
+
return {
|
|
68
|
+
id: "US-001",
|
|
69
|
+
title: "Test Story",
|
|
70
|
+
description: "Test",
|
|
71
|
+
acceptanceCriteria: [],
|
|
72
|
+
tags: [],
|
|
73
|
+
dependencies: [],
|
|
74
|
+
status: "in-progress",
|
|
75
|
+
passes: false,
|
|
76
|
+
escalations: [],
|
|
77
|
+
attempts: 1,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function makePRD(): PRD {
|
|
82
|
+
return {
|
|
83
|
+
project: "test",
|
|
84
|
+
feature: "my-feature",
|
|
85
|
+
branchName: "test-branch",
|
|
86
|
+
createdAt: new Date().toISOString(),
|
|
87
|
+
updatedAt: new Date().toISOString(),
|
|
88
|
+
userStories: [makeStory()],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function makeCtx(config: NaxConfig, interaction?: InteractionChain): PipelineContext {
|
|
93
|
+
const tempDir = mkdtempSync(`${tmpdir()}/nax-test-`);
|
|
94
|
+
return {
|
|
95
|
+
config,
|
|
96
|
+
prd: makePRD(),
|
|
97
|
+
story: makeStory(),
|
|
98
|
+
stories: [makeStory()],
|
|
99
|
+
routing: { complexity: "simple", modelTier: "fast", testStrategy: "test-after", reasoning: "" },
|
|
100
|
+
workdir: tempDir,
|
|
101
|
+
featureDir: tempDir,
|
|
102
|
+
agentResult: { success: true, estimatedCost: 0.01, output: "", stderr: "", exitCode: 0, rateLimited: false },
|
|
103
|
+
hooks: {} as PipelineContext["hooks"],
|
|
104
|
+
interaction,
|
|
105
|
+
storyStartTime: new Date().toISOString(),
|
|
106
|
+
} as unknown as PipelineContext;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
mock.restore();
|
|
111
|
+
_completionDeps.checkReviewGate = originalCheckReviewGate;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
115
|
+
// review-gate trigger tests (via _completionDeps injection)
|
|
116
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe("completionStage — review-gate trigger", () => {
|
|
119
|
+
test("calls review-gate trigger after story passes when enabled", async () => {
|
|
120
|
+
const { completionStage } = await import("../../../../src/pipeline/stages/completion");
|
|
121
|
+
_completionDeps.checkReviewGate = mock(async () => true);
|
|
122
|
+
|
|
123
|
+
const config = makeConfig({ "review-gate": { enabled: true } });
|
|
124
|
+
const chain = makeChain("approve");
|
|
125
|
+
const ctx = makeCtx(config, chain);
|
|
126
|
+
|
|
127
|
+
const result = await completionStage.execute(ctx);
|
|
128
|
+
|
|
129
|
+
expect(result.action).toBe("continue");
|
|
130
|
+
expect(_completionDeps.checkReviewGate).toHaveBeenCalledTimes(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("does not call trigger when review-gate is disabled (default)", async () => {
|
|
134
|
+
const { completionStage } = await import("../../../../src/pipeline/stages/completion");
|
|
135
|
+
_completionDeps.checkReviewGate = mock(async () => true);
|
|
136
|
+
|
|
137
|
+
const config = makeConfig({});
|
|
138
|
+
const chain = makeChain("approve");
|
|
139
|
+
const ctx = makeCtx(config, chain);
|
|
140
|
+
|
|
141
|
+
const result = await completionStage.execute(ctx);
|
|
142
|
+
|
|
143
|
+
expect(result.action).toBe("continue");
|
|
144
|
+
expect(_completionDeps.checkReviewGate).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("does not fail pipeline when trigger responds abort", async () => {
|
|
148
|
+
const { completionStage } = await import("../../../../src/pipeline/stages/completion");
|
|
149
|
+
_completionDeps.checkReviewGate = mock(async () => false);
|
|
150
|
+
|
|
151
|
+
const config = makeConfig({ "review-gate": { enabled: true } });
|
|
152
|
+
const chain = makeChain("abort");
|
|
153
|
+
const ctx = makeCtx(config, chain);
|
|
154
|
+
|
|
155
|
+
const result = await completionStage.execute(ctx);
|
|
156
|
+
|
|
157
|
+
expect(result.action).toBe("continue");
|
|
158
|
+
expect(_completionDeps.checkReviewGate).toHaveBeenCalledTimes(1);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("continues normally when trigger approves", async () => {
|
|
162
|
+
const { completionStage } = await import("../../../../src/pipeline/stages/completion");
|
|
163
|
+
_completionDeps.checkReviewGate = mock(async () => true);
|
|
164
|
+
|
|
165
|
+
const config = makeConfig({ "review-gate": { enabled: true } });
|
|
166
|
+
const chain = makeChain("approve");
|
|
167
|
+
const ctx = makeCtx(config, chain);
|
|
168
|
+
|
|
169
|
+
const result = await completionStage.execute(ctx);
|
|
170
|
+
|
|
171
|
+
expect(result.action).toBe("continue");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("does not call trigger when no interaction chain", async () => {
|
|
175
|
+
const { completionStage } = await import("../../../../src/pipeline/stages/completion");
|
|
176
|
+
_completionDeps.checkReviewGate = mock(async () => true);
|
|
177
|
+
|
|
178
|
+
const config = makeConfig({ "review-gate": { enabled: true } });
|
|
179
|
+
const ctx = makeCtx(config); // no chain
|
|
180
|
+
|
|
181
|
+
const result = await completionStage.execute(ctx);
|
|
182
|
+
|
|
183
|
+
expect(result.action).toBe("continue");
|
|
184
|
+
expect(_completionDeps.checkReviewGate).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("passes correct context to checkReviewGate", async () => {
|
|
188
|
+
const { completionStage } = await import("../../../../src/pipeline/stages/completion");
|
|
189
|
+
_completionDeps.checkReviewGate = mock(async () => true);
|
|
190
|
+
|
|
191
|
+
const config = makeConfig({ "review-gate": { enabled: true } });
|
|
192
|
+
const chain = makeChain("approve");
|
|
193
|
+
const ctx = makeCtx(config, chain);
|
|
194
|
+
|
|
195
|
+
await completionStage.execute(ctx);
|
|
196
|
+
|
|
197
|
+
const callArgs = (_completionDeps.checkReviewGate as any).mock.calls[0];
|
|
198
|
+
expect(callArgs[0].featureName).toBe("my-feature");
|
|
199
|
+
expect(callArgs[0].storyId).toBe("US-001");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("calls trigger for each story when multiple stories passed", async () => {
|
|
203
|
+
const { completionStage } = await import("../../../../src/pipeline/stages/completion");
|
|
204
|
+
_completionDeps.checkReviewGate = mock(async () => true);
|
|
205
|
+
|
|
206
|
+
const config = makeConfig({ "review-gate": { enabled: true } });
|
|
207
|
+
const chain = makeChain("approve");
|
|
208
|
+
const ctx = makeCtx(config, chain);
|
|
209
|
+
|
|
210
|
+
const story2 = makeStory();
|
|
211
|
+
story2.id = "US-002";
|
|
212
|
+
ctx.stories = [makeStory(), story2];
|
|
213
|
+
|
|
214
|
+
await completionStage.execute(ctx);
|
|
215
|
+
|
|
216
|
+
expect(_completionDeps.checkReviewGate).toHaveBeenCalledTimes(2);
|
|
217
|
+
});
|
|
218
|
+
});
|