@nathapp/nax 0.49.1 → 0.49.6
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/CHANGELOG.md +14 -0
- package/README.md +282 -10
- package/dist/nax.js +257 -136
- package/package.json +1 -1
- package/src/agents/acp/adapter.ts +53 -23
- package/src/agents/acp/spawn-client.ts +0 -2
- package/src/agents/claude/execution.ts +14 -0
- package/src/agents/types.ts +7 -0
- package/src/cli/prompts-main.ts +4 -59
- package/src/cli/prompts-shared.ts +70 -0
- package/src/cli/prompts-tdd.ts +1 -1
- package/src/config/merge.ts +18 -0
- package/src/config/test-strategy.ts +4 -4
- package/src/execution/iteration-runner.ts +1 -1
- package/src/execution/pipeline-result-handler.ts +4 -1
- package/src/execution/story-selector.ts +2 -1
- package/src/interaction/plugins/webhook.ts +44 -25
- package/src/pipeline/stages/autofix.ts +26 -7
- package/src/pipeline/stages/routing.ts +1 -1
- package/src/review/runner.ts +15 -0
- package/src/tdd/cleanup.ts +15 -6
- package/src/tdd/isolation.ts +9 -2
- package/src/tdd/rectification-gate.ts +41 -10
- package/src/tdd/session-runner.ts +71 -38
- package/src/verification/executor.ts +4 -1
- package/src/verification/strategies/acceptance.ts +4 -1
package/src/tdd/cleanup.ts
CHANGED
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
import { getLogger } from "../logger";
|
|
9
9
|
|
|
10
|
+
/** Injectable deps for testability — mock _cleanupDeps instead of global Bun.spawn/process.kill */
|
|
11
|
+
export const _cleanupDeps = {
|
|
12
|
+
spawn: Bun.spawn as typeof Bun.spawn,
|
|
13
|
+
sleep: Bun.sleep as typeof Bun.sleep,
|
|
14
|
+
kill: process.kill.bind(process) as typeof process.kill,
|
|
15
|
+
};
|
|
16
|
+
|
|
10
17
|
/**
|
|
11
18
|
* Get process group ID (PGID) for a given process ID.
|
|
12
19
|
*
|
|
@@ -24,17 +31,19 @@ import { getLogger } from "../logger";
|
|
|
24
31
|
export async function getPgid(pid: number): Promise<number | null> {
|
|
25
32
|
try {
|
|
26
33
|
// Use ps to get PGID for the process
|
|
27
|
-
const proc =
|
|
34
|
+
const proc = _cleanupDeps.spawn(["ps", "-o", "pgid=", "-p", String(pid)], {
|
|
28
35
|
stdout: "pipe",
|
|
29
36
|
stderr: "pipe",
|
|
30
37
|
});
|
|
31
38
|
|
|
39
|
+
// Read stdout BEFORE awaiting exit — stream may be closed after exit in Bun 1.3.9.
|
|
40
|
+
// Bun.readableStreamToText is more reliable than new Response(stream).text()
|
|
41
|
+
// with both real pipes and mocked streams.
|
|
42
|
+
const output = await Bun.readableStreamToText(proc.stdout);
|
|
32
43
|
const exitCode = await proc.exited;
|
|
33
44
|
if (exitCode !== 0) {
|
|
34
45
|
return null;
|
|
35
46
|
}
|
|
36
|
-
|
|
37
|
-
const output = await new Response(proc.stdout).text();
|
|
38
47
|
const pgid = Number.parseInt(output.trim(), 10);
|
|
39
48
|
|
|
40
49
|
return Number.isNaN(pgid) ? null : pgid;
|
|
@@ -72,7 +81,7 @@ export async function cleanupProcessTree(pid: number, gracePeriodMs = 3000): Pro
|
|
|
72
81
|
|
|
73
82
|
// Send SIGTERM to all processes in the group (negative PGID)
|
|
74
83
|
try {
|
|
75
|
-
|
|
84
|
+
_cleanupDeps.kill(-pgid, "SIGTERM");
|
|
76
85
|
} catch (error) {
|
|
77
86
|
// ESRCH means no such process — already dead
|
|
78
87
|
const err = error as NodeJS.ErrnoException;
|
|
@@ -83,7 +92,7 @@ export async function cleanupProcessTree(pid: number, gracePeriodMs = 3000): Pro
|
|
|
83
92
|
}
|
|
84
93
|
|
|
85
94
|
// Wait for graceful shutdown
|
|
86
|
-
await
|
|
95
|
+
await _cleanupDeps.sleep(gracePeriodMs);
|
|
87
96
|
|
|
88
97
|
// Re-check PGID before SIGKILL to prevent race condition
|
|
89
98
|
// If the original process exited and a new process inherited its PID,
|
|
@@ -95,7 +104,7 @@ export async function cleanupProcessTree(pid: number, gracePeriodMs = 3000): Pro
|
|
|
95
104
|
// 2. PGID hasn't changed (still the same process group)
|
|
96
105
|
if (pgidAfterWait && pgidAfterWait === pgid) {
|
|
97
106
|
try {
|
|
98
|
-
|
|
107
|
+
_cleanupDeps.kill(-pgid, "SIGKILL");
|
|
99
108
|
} catch {
|
|
100
109
|
// Ignore errors — processes may have exited during the wait
|
|
101
110
|
}
|
package/src/tdd/isolation.ts
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
import type { IsolationCheck } from "./types";
|
|
10
10
|
|
|
11
|
+
/** Injectable deps for testability — mock _isolationDeps.spawn instead of global Bun.spawn */
|
|
12
|
+
export const _isolationDeps = { spawn: Bun.spawn as typeof Bun.spawn };
|
|
13
|
+
|
|
11
14
|
/** Common test directory patterns */
|
|
12
15
|
const TEST_PATTERNS = [/^test\//, /^tests\//, /^__tests__\//, /\.spec\.\w+$/, /\.test\.\w+$/, /\.e2e-spec\.\w+$/];
|
|
13
16
|
|
|
@@ -26,14 +29,18 @@ export function isSourceFile(filePath: string): boolean {
|
|
|
26
29
|
|
|
27
30
|
/** Get changed files from git diff */
|
|
28
31
|
export async function getChangedFiles(workdir: string, fromRef = "HEAD"): Promise<string[]> {
|
|
29
|
-
const proc =
|
|
32
|
+
const proc = _isolationDeps.spawn(["git", "diff", "--name-only", fromRef], {
|
|
30
33
|
cwd: workdir,
|
|
31
34
|
stdout: "pipe",
|
|
32
35
|
stderr: "pipe",
|
|
33
36
|
});
|
|
34
37
|
|
|
38
|
+
// Use Bun.readableStreamToText — more reliable than new Response(stream).text()
|
|
39
|
+
// with both real pipes and mocked ReadableStreams across Bun versions.
|
|
40
|
+
// Must read BEFORE awaiting proc.exited to avoid stream-closed-on-exit issues.
|
|
41
|
+
const output = await Bun.readableStreamToText(proc.stdout);
|
|
35
42
|
await proc.exited;
|
|
36
|
-
|
|
43
|
+
|
|
37
44
|
return output.trim().split("\n").filter(Boolean);
|
|
38
45
|
}
|
|
39
46
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { AgentAdapter } from "../agents";
|
|
10
|
+
import { buildSessionName } from "../agents/acp/adapter";
|
|
10
11
|
import type { ModelTier, NaxConfig } from "../config";
|
|
11
12
|
import { resolveModel } from "../config";
|
|
12
13
|
import { resolvePermissions } from "../config/permissions";
|
|
@@ -14,15 +15,23 @@ import type { getLogger } from "../logger";
|
|
|
14
15
|
import type { UserStory } from "../prd";
|
|
15
16
|
import { autoCommitIfDirty, captureGitRef } from "../utils/git";
|
|
16
17
|
import {
|
|
18
|
+
type parseBunTestOutput as ParseBunTestOutputType,
|
|
17
19
|
type RectificationState,
|
|
18
|
-
executeWithTimeout,
|
|
19
|
-
parseBunTestOutput,
|
|
20
|
-
shouldRetryRectification,
|
|
20
|
+
executeWithTimeout as _executeWithTimeout,
|
|
21
|
+
parseBunTestOutput as _parseBunTestOutput,
|
|
22
|
+
shouldRetryRectification as _shouldRetryRectification,
|
|
21
23
|
} from "../verification";
|
|
22
24
|
import { cleanupProcessTree } from "./cleanup";
|
|
23
25
|
import { verifyImplementerIsolation } from "./isolation";
|
|
24
26
|
import { buildImplementerRectificationPrompt } from "./prompts";
|
|
25
27
|
|
|
28
|
+
/** Injectable deps for testability — avoids mock.module() contamination */
|
|
29
|
+
export const _rectificationGateDeps = {
|
|
30
|
+
executeWithTimeout: _executeWithTimeout,
|
|
31
|
+
parseBunTestOutput: _parseBunTestOutput,
|
|
32
|
+
shouldRetryRectification: _shouldRetryRectification,
|
|
33
|
+
};
|
|
34
|
+
|
|
26
35
|
/**
|
|
27
36
|
* Run full test suite gate before verifier session (v0.11 Rectification).
|
|
28
37
|
*/
|
|
@@ -49,11 +58,13 @@ export async function runFullSuiteGate(
|
|
|
49
58
|
timeout: fullSuiteTimeout,
|
|
50
59
|
});
|
|
51
60
|
|
|
52
|
-
const fullSuiteResult = await executeWithTimeout(testCmd, fullSuiteTimeout, undefined, {
|
|
61
|
+
const fullSuiteResult = await _rectificationGateDeps.executeWithTimeout(testCmd, fullSuiteTimeout, undefined, {
|
|
62
|
+
cwd: workdir,
|
|
63
|
+
});
|
|
53
64
|
const fullSuitePassed = fullSuiteResult.success && fullSuiteResult.exitCode === 0;
|
|
54
65
|
|
|
55
66
|
if (!fullSuitePassed && fullSuiteResult.output) {
|
|
56
|
-
const testSummary = parseBunTestOutput(fullSuiteResult.output);
|
|
67
|
+
const testSummary = _rectificationGateDeps.parseBunTestOutput(fullSuiteResult.output);
|
|
57
68
|
|
|
58
69
|
if (testSummary.failed > 0) {
|
|
59
70
|
return await runRectificationLoop(
|
|
@@ -117,7 +128,7 @@ async function runRectificationLoop(
|
|
|
117
128
|
contextMarkdown: string | undefined,
|
|
118
129
|
lite: boolean,
|
|
119
130
|
logger: ReturnType<typeof getLogger>,
|
|
120
|
-
testSummary: ReturnType<typeof
|
|
131
|
+
testSummary: ReturnType<typeof _parseBunTestOutput>,
|
|
121
132
|
rectificationConfig: NonNullable<NaxConfig["execution"]["rectification"]>,
|
|
122
133
|
testCmd: string,
|
|
123
134
|
fullSuiteTimeout: number,
|
|
@@ -135,9 +146,19 @@ async function runRectificationLoop(
|
|
|
135
146
|
passedTests: testSummary.passed,
|
|
136
147
|
});
|
|
137
148
|
|
|
138
|
-
|
|
149
|
+
// Build session name once so all rectification attempts share the same ACP session.
|
|
150
|
+
// This preserves full conversation context across retries (the agent knows what it already tried).
|
|
151
|
+
const rectificationSessionName = buildSessionName(workdir, featureName, story.id, "implementer");
|
|
152
|
+
logger.debug("tdd", "Rectification session name (shared across all attempts)", {
|
|
153
|
+
storyId: story.id,
|
|
154
|
+
sessionName: rectificationSessionName,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
while (_rectificationGateDeps.shouldRetryRectification(rectificationState, rectificationConfig)) {
|
|
139
158
|
rectificationState.attempt++;
|
|
140
159
|
|
|
160
|
+
const isLastAttempt = rectificationState.attempt >= rectificationConfig.maxRetries;
|
|
161
|
+
|
|
141
162
|
logger.info(
|
|
142
163
|
"tdd",
|
|
143
164
|
`-> Implementer rectification attempt ${rectificationState.attempt}/${rectificationConfig.maxRetries}`,
|
|
@@ -166,6 +187,12 @@ async function runRectificationLoop(
|
|
|
166
187
|
featureName,
|
|
167
188
|
storyId: story.id,
|
|
168
189
|
sessionRole: "implementer",
|
|
190
|
+
// Reuse the same ACP session across all rectification attempts so the agent
|
|
191
|
+
// retains full conversation context (knows what it already tried).
|
|
192
|
+
acpSessionName: rectificationSessionName,
|
|
193
|
+
// Keep session open until the last attempt — the session sweep at run end
|
|
194
|
+
// will handle cleanup. On the last attempt, let the adapter close normally.
|
|
195
|
+
keepSessionOpen: !isLastAttempt,
|
|
169
196
|
});
|
|
170
197
|
|
|
171
198
|
if (!rectifyResult.success && rectifyResult.pid) {
|
|
@@ -201,7 +228,9 @@ async function runRectificationLoop(
|
|
|
201
228
|
break;
|
|
202
229
|
}
|
|
203
230
|
|
|
204
|
-
const retryFullSuite = await executeWithTimeout(testCmd, fullSuiteTimeout, undefined, {
|
|
231
|
+
const retryFullSuite = await _rectificationGateDeps.executeWithTimeout(testCmd, fullSuiteTimeout, undefined, {
|
|
232
|
+
cwd: workdir,
|
|
233
|
+
});
|
|
205
234
|
const retrySuitePassed = retryFullSuite.success && retryFullSuite.exitCode === 0;
|
|
206
235
|
|
|
207
236
|
if (retrySuitePassed) {
|
|
@@ -213,7 +242,7 @@ async function runRectificationLoop(
|
|
|
213
242
|
}
|
|
214
243
|
|
|
215
244
|
if (retryFullSuite.output) {
|
|
216
|
-
const newTestSummary = parseBunTestOutput(retryFullSuite.output);
|
|
245
|
+
const newTestSummary = _rectificationGateDeps.parseBunTestOutput(retryFullSuite.output);
|
|
217
246
|
rectificationState.currentFailures = newTestSummary.failed;
|
|
218
247
|
testSummary.failures = newTestSummary.failures;
|
|
219
248
|
testSummary.failed = newTestSummary.failed;
|
|
@@ -227,7 +256,9 @@ async function runRectificationLoop(
|
|
|
227
256
|
});
|
|
228
257
|
}
|
|
229
258
|
|
|
230
|
-
const finalFullSuite = await executeWithTimeout(testCmd, fullSuiteTimeout, undefined, {
|
|
259
|
+
const finalFullSuite = await _rectificationGateDeps.executeWithTimeout(testCmd, fullSuiteTimeout, undefined, {
|
|
260
|
+
cwd: workdir,
|
|
261
|
+
});
|
|
231
262
|
const finalSuitePassed = finalFullSuite.success && finalFullSuite.exitCode === 0;
|
|
232
263
|
|
|
233
264
|
if (!finalSuitePassed) {
|
|
@@ -12,17 +12,39 @@ import { getLogger } from "../logger";
|
|
|
12
12
|
import type { UserStory } from "../prd";
|
|
13
13
|
import { PromptBuilder } from "../prompts";
|
|
14
14
|
import { autoCommitIfDirty as _autoCommitIfDirtyFn } from "../utils/git";
|
|
15
|
-
import {
|
|
16
|
-
|
|
15
|
+
import { captureGitRef as _captureGitRef } from "../utils/git";
|
|
16
|
+
import { cleanupProcessTree as _cleanupProcessTree } from "./cleanup";
|
|
17
17
|
/**
|
|
18
18
|
* Injectable dependencies for session-runner — allows tests to mock
|
|
19
19
|
* autoCommitIfDirty without going through internal git deps.
|
|
20
20
|
* @internal
|
|
21
21
|
*/
|
|
22
|
+
import {
|
|
23
|
+
getChangedFiles as _getChangedFiles,
|
|
24
|
+
verifyImplementerIsolation as _verifyImplementerIsolation,
|
|
25
|
+
verifyTestWriterIsolation as _verifyTestWriterIsolation,
|
|
26
|
+
} from "./isolation";
|
|
27
|
+
|
|
22
28
|
export const _sessionRunnerDeps = {
|
|
23
29
|
autoCommitIfDirty: _autoCommitIfDirtyFn,
|
|
30
|
+
spawn: Bun.spawn as typeof Bun.spawn,
|
|
31
|
+
getChangedFiles: _getChangedFiles,
|
|
32
|
+
verifyTestWriterIsolation: _verifyTestWriterIsolation,
|
|
33
|
+
verifyImplementerIsolation: _verifyImplementerIsolation,
|
|
34
|
+
captureGitRef: _captureGitRef,
|
|
35
|
+
cleanupProcessTree: _cleanupProcessTree,
|
|
36
|
+
buildPrompt: null as
|
|
37
|
+
| null
|
|
38
|
+
| ((
|
|
39
|
+
role: TddSessionRole,
|
|
40
|
+
config: NaxConfig,
|
|
41
|
+
story: UserStory,
|
|
42
|
+
workdir: string,
|
|
43
|
+
contextMarkdown?: string,
|
|
44
|
+
lite?: boolean,
|
|
45
|
+
constitution?: string,
|
|
46
|
+
) => Promise<string>),
|
|
24
47
|
};
|
|
25
|
-
import { getChangedFiles, verifyImplementerIsolation, verifyTestWriterIsolation } from "./isolation";
|
|
26
48
|
import type { IsolationCheck } from "./types";
|
|
27
49
|
import type { TddSessionResult, TddSessionRole } from "./types";
|
|
28
50
|
|
|
@@ -53,7 +75,7 @@ export async function rollbackToRef(workdir: string, ref: string): Promise<void>
|
|
|
53
75
|
const logger = getLogger();
|
|
54
76
|
logger.warn("tdd", "Rolling back git changes", { ref });
|
|
55
77
|
|
|
56
|
-
const resetProc =
|
|
78
|
+
const resetProc = _sessionRunnerDeps.spawn(["git", "reset", "--hard", ref], {
|
|
57
79
|
cwd: workdir,
|
|
58
80
|
stdout: "pipe",
|
|
59
81
|
stderr: "pipe",
|
|
@@ -66,7 +88,7 @@ export async function rollbackToRef(workdir: string, ref: string): Promise<void>
|
|
|
66
88
|
throw new Error(`Git rollback failed: ${stderr}`);
|
|
67
89
|
}
|
|
68
90
|
|
|
69
|
-
const cleanProc =
|
|
91
|
+
const cleanProc = _sessionRunnerDeps.spawn(["git", "clean", "-fd"], {
|
|
70
92
|
cwd: workdir,
|
|
71
93
|
stdout: "pipe",
|
|
72
94
|
stderr: "pipe",
|
|
@@ -98,41 +120,51 @@ export async function runTddSession(
|
|
|
98
120
|
): Promise<TddSessionResult> {
|
|
99
121
|
const startTime = Date.now();
|
|
100
122
|
|
|
101
|
-
// Build prompt
|
|
123
|
+
// Build prompt — use injectable buildPrompt if set, otherwise default PromptBuilder
|
|
102
124
|
let prompt: string;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
if (_sessionRunnerDeps.buildPrompt) {
|
|
126
|
+
prompt = await _sessionRunnerDeps.buildPrompt(role, config, story, workdir, contextMarkdown, lite, constitution);
|
|
127
|
+
} else {
|
|
128
|
+
switch (role) {
|
|
129
|
+
case "test-writer":
|
|
130
|
+
prompt = await PromptBuilder.for("test-writer", { isolation: lite ? "lite" : "strict" })
|
|
131
|
+
.withLoader(workdir, config)
|
|
132
|
+
.story(story)
|
|
133
|
+
.context(contextMarkdown)
|
|
134
|
+
.constitution(constitution)
|
|
135
|
+
.testCommand(config.quality?.commands?.test)
|
|
136
|
+
.build();
|
|
137
|
+
break;
|
|
138
|
+
case "implementer":
|
|
139
|
+
prompt = await PromptBuilder.for("implementer", { variant: lite ? "lite" : "standard" })
|
|
140
|
+
.withLoader(workdir, config)
|
|
141
|
+
.story(story)
|
|
142
|
+
.context(contextMarkdown)
|
|
143
|
+
.constitution(constitution)
|
|
144
|
+
.testCommand(config.quality?.commands?.test)
|
|
145
|
+
.build();
|
|
146
|
+
break;
|
|
147
|
+
case "verifier":
|
|
148
|
+
prompt = await PromptBuilder.for("verifier")
|
|
149
|
+
.withLoader(workdir, config)
|
|
150
|
+
.story(story)
|
|
151
|
+
.context(contextMarkdown)
|
|
152
|
+
.constitution(constitution)
|
|
153
|
+
.testCommand(config.quality?.commands?.test)
|
|
154
|
+
.build();
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
131
157
|
}
|
|
132
158
|
|
|
133
159
|
const logger = getLogger();
|
|
134
160
|
logger.info("tdd", `-> Session: ${role}`, { role, storyId: story.id, lite });
|
|
135
161
|
|
|
162
|
+
// When rectification is enabled, keep the implementer session open after it finishes.
|
|
163
|
+
// The rectification gate uses the same session name (buildSessionName + "implementer")
|
|
164
|
+
// and will resume it directly — so the implementer retains full context of what it built.
|
|
165
|
+
// The session sweep (or the last rectification attempt) handles final cleanup.
|
|
166
|
+
const keepSessionOpen = role === "implementer" && (config.execution.rectification?.enabled ?? false);
|
|
167
|
+
|
|
136
168
|
// Run the agent
|
|
137
169
|
const result = await agent.run({
|
|
138
170
|
prompt,
|
|
@@ -147,11 +179,12 @@ export async function runTddSession(
|
|
|
147
179
|
featureName,
|
|
148
180
|
storyId: story.id,
|
|
149
181
|
sessionRole: role,
|
|
182
|
+
keepSessionOpen,
|
|
150
183
|
});
|
|
151
184
|
|
|
152
185
|
// BUG-21 Fix: Clean up orphaned child processes if agent failed
|
|
153
186
|
if (!result.success && result.pid) {
|
|
154
|
-
await cleanupProcessTree(result.pid);
|
|
187
|
+
await _sessionRunnerDeps.cleanupProcessTree(result.pid);
|
|
155
188
|
}
|
|
156
189
|
|
|
157
190
|
if (result.success) {
|
|
@@ -178,14 +211,14 @@ export async function runTddSession(
|
|
|
178
211
|
if (!skipIsolation) {
|
|
179
212
|
if (role === "test-writer") {
|
|
180
213
|
const allowedPaths = config.tdd.testWriterAllowedPaths ?? ["src/index.ts", "src/**/index.ts"];
|
|
181
|
-
isolation = await verifyTestWriterIsolation(workdir, beforeRef, allowedPaths);
|
|
214
|
+
isolation = await _sessionRunnerDeps.verifyTestWriterIsolation(workdir, beforeRef, allowedPaths);
|
|
182
215
|
} else if (role === "implementer" || role === "verifier") {
|
|
183
|
-
isolation = await verifyImplementerIsolation(workdir, beforeRef);
|
|
216
|
+
isolation = await _sessionRunnerDeps.verifyImplementerIsolation(workdir, beforeRef);
|
|
184
217
|
}
|
|
185
218
|
}
|
|
186
219
|
|
|
187
220
|
// Get changed files
|
|
188
|
-
const filesChanged = await getChangedFiles(workdir, beforeRef);
|
|
221
|
+
const filesChanged = await _sessionRunnerDeps.getChangedFiles(workdir, beforeRef);
|
|
189
222
|
|
|
190
223
|
const durationMs = Date.now() - startTime;
|
|
191
224
|
|
|
@@ -9,6 +9,9 @@ import type { Subprocess } from "bun";
|
|
|
9
9
|
import { errorMessage } from "../utils/errors";
|
|
10
10
|
import type { TestExecutionResult } from "./types";
|
|
11
11
|
|
|
12
|
+
/** Injectable deps for testability — mock _executorDeps.spawn instead of global Bun.spawn */
|
|
13
|
+
export const _executorDeps = { spawn: Bun.spawn as typeof Bun.spawn };
|
|
14
|
+
|
|
12
15
|
/**
|
|
13
16
|
* Drain stdout+stderr from a killed Bun subprocess with a hard deadline.
|
|
14
17
|
*
|
|
@@ -92,7 +95,7 @@ export async function executeWithTimeout(
|
|
|
92
95
|
const gracePeriodMs = options?.gracePeriodMs ?? 5000;
|
|
93
96
|
const drainTimeoutMs = options?.drainTimeoutMs ?? 2000;
|
|
94
97
|
|
|
95
|
-
const proc =
|
|
98
|
+
const proc = _executorDeps.spawn([shell, "-c", command], {
|
|
96
99
|
stdout: "pipe",
|
|
97
100
|
stderr: "pipe",
|
|
98
101
|
env: env || normalizeEnvironment(process.env as Record<string, string | undefined>),
|
|
@@ -11,6 +11,9 @@ import { getLogger } from "../../logger";
|
|
|
11
11
|
import type { IVerificationStrategy, VerifyContext, VerifyResult } from "../orchestrator-types";
|
|
12
12
|
import { makeFailResult, makePassResult, makeSkippedResult } from "../orchestrator-types";
|
|
13
13
|
|
|
14
|
+
/** Injectable deps for testability */
|
|
15
|
+
export const _acceptanceDeps = { spawn: Bun.spawn as typeof Bun.spawn };
|
|
16
|
+
|
|
14
17
|
function parseFailedACs(output: string): string[] {
|
|
15
18
|
const failed: string[] = [];
|
|
16
19
|
for (const line of output.split("\n")) {
|
|
@@ -51,7 +54,7 @@ export class AcceptanceStrategy implements IVerificationStrategy {
|
|
|
51
54
|
|
|
52
55
|
const start = Date.now();
|
|
53
56
|
const timeoutMs = ctx.timeoutSeconds * 1000;
|
|
54
|
-
const proc =
|
|
57
|
+
const proc = _acceptanceDeps.spawn(["bun", "test", testPath], {
|
|
55
58
|
cwd: ctx.workdir,
|
|
56
59
|
stdout: "pipe",
|
|
57
60
|
stderr: "pipe",
|