@nathapp/nax 0.40.0 → 0.40.1

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/dist/nax.js CHANGED
@@ -17708,7 +17708,9 @@ var init_schemas3 = __esm(() => {
17708
17708
  testPath: exports_external.string().min(1, "acceptance.testPath must be non-empty"),
17709
17709
  model: exports_external.enum(["fast", "balanced", "powerful"]).default("fast"),
17710
17710
  refinement: exports_external.boolean().default(true),
17711
- redGate: exports_external.boolean().default(true)
17711
+ redGate: exports_external.boolean().default(true),
17712
+ testStrategy: exports_external.enum(["unit", "component", "cli", "e2e", "snapshot"]).optional(),
17713
+ testFramework: exports_external.string().min(1, "acceptance.testFramework must be non-empty").optional()
17712
17714
  });
17713
17715
  TestCoverageConfigSchema = exports_external.object({
17714
17716
  enabled: exports_external.boolean().default(true),
@@ -18362,14 +18364,16 @@ __export(exports_refinement, {
18362
18364
  buildRefinementPrompt: () => buildRefinementPrompt,
18363
18365
  _refineDeps: () => _refineDeps
18364
18366
  });
18365
- function buildRefinementPrompt(criteria, codebaseContext) {
18367
+ function buildRefinementPrompt(criteria, codebaseContext, options) {
18366
18368
  const criteriaList = criteria.map((c, i) => `${i + 1}. ${c}`).join(`
18367
18369
  `);
18370
+ const strategySection = buildStrategySection(options);
18371
+ const refinedExample = buildRefinedExample(options?.testStrategy);
18368
18372
  return `You are an acceptance criteria refinement assistant. Your task is to convert raw acceptance criteria into concrete, machine-verifiable assertions.
18369
18373
 
18370
18374
  CODEBASE CONTEXT:
18371
18375
  ${codebaseContext}
18372
-
18376
+ ${strategySection}
18373
18377
  ACCEPTANCE CRITERIA TO REFINE:
18374
18378
  ${criteriaList}
18375
18379
 
@@ -18384,11 +18388,53 @@ Respond with ONLY a JSON array (no markdown code fences):
18384
18388
 
18385
18389
  Rules:
18386
18390
  - "original" must match the input criterion text exactly
18387
- - "refined" must be a concrete assertion (e.g., "Function returns array of length N", "HTTP status 200 returned")
18391
+ - "refined" must be a concrete assertion (e.g., ${refinedExample})
18388
18392
  - "testable" is false only if the criterion cannot be automatically verified (e.g., "UX feels responsive", "design looks good")
18389
18393
  - "storyId" leave as empty string \u2014 it will be assigned by the caller
18390
18394
  - Respond with ONLY the JSON array`;
18391
18395
  }
18396
+ function buildStrategySection(options) {
18397
+ if (!options?.testStrategy) {
18398
+ return "";
18399
+ }
18400
+ const framework = options.testFramework ? ` Use ${options.testFramework} testing library syntax.` : "";
18401
+ switch (options.testStrategy) {
18402
+ case "component":
18403
+ return `
18404
+ TEST STRATEGY: component
18405
+ Focus assertions on rendered output visible on screen \u2014 text content, visible elements, and screen state.
18406
+ Assert what the user sees rendered in the component, not what internal functions produce.${framework}
18407
+ `;
18408
+ case "cli":
18409
+ return `
18410
+ TEST STRATEGY: cli
18411
+ Focus assertions on stdout and stderr text output from the CLI command.
18412
+ Assert about terminal output content, exit codes, and standard output/standard error streams.${framework}
18413
+ `;
18414
+ case "e2e":
18415
+ return `
18416
+ TEST STRATEGY: e2e
18417
+ Focus assertions on HTTP response content \u2014 status codes, response bodies, and endpoint behavior.
18418
+ Assert about HTTP responses, status codes, and API endpoint output.${framework}
18419
+ `;
18420
+ default:
18421
+ return framework ? `
18422
+ TEST FRAMEWORK: ${options.testFramework}
18423
+ ` : "";
18424
+ }
18425
+ }
18426
+ function buildRefinedExample(testStrategy) {
18427
+ switch (testStrategy) {
18428
+ case "component":
18429
+ return '"Text content visible on screen matches expected", "Rendered output contains expected element"';
18430
+ case "cli":
18431
+ return '"stdout contains expected text", "stderr is empty on success", "exit code is 0"';
18432
+ case "e2e":
18433
+ return '"HTTP status 200 returned", "Response body contains expected field", "Endpoint returns JSON"';
18434
+ default:
18435
+ return '"Array of length N returned", "HTTP status 200 returned"';
18436
+ }
18437
+ }
18392
18438
  function parseRefinementResponse(response, criteria) {
18393
18439
  if (!response || !response.trim()) {
18394
18440
  return fallbackCriteria(criteria);
@@ -18412,7 +18458,7 @@ async function refineAcceptanceCriteria(criteria, context) {
18412
18458
  if (criteria.length === 0) {
18413
18459
  return [];
18414
18460
  }
18415
- const { storyId, codebaseContext, config: config2 } = context;
18461
+ const { storyId, codebaseContext, config: config2, testStrategy, testFramework } = context;
18416
18462
  const logger = getLogger();
18417
18463
  const modelTier = config2.acceptance?.model ?? "fast";
18418
18464
  const modelEntry = config2.models[modelTier] ?? config2.models.fast;
@@ -18420,7 +18466,7 @@ async function refineAcceptanceCriteria(criteria, context) {
18420
18466
  throw new Error(`[refinement] config.models.${modelTier} not configured`);
18421
18467
  }
18422
18468
  const modelDef = resolveModel(modelEntry);
18423
- const prompt = buildRefinementPrompt(criteria, codebaseContext);
18469
+ const prompt = buildRefinementPrompt(criteria, codebaseContext, { testStrategy, testFramework });
18424
18470
  let response;
18425
18471
  try {
18426
18472
  response = await _refineDeps.adapter.complete(prompt, {
@@ -18483,6 +18529,7 @@ async function generateFromPRD(_stories, refinedCriteria, options) {
18483
18529
  }
18484
18530
  const criteriaList = refinedCriteria.map((c, i) => `AC-${i + 1}: ${c.refined}`).join(`
18485
18531
  `);
18532
+ const strategyInstructions = buildStrategyInstructions(options.testStrategy, options.testFramework);
18486
18533
  const prompt = `You are a test engineer. Generate acceptance tests for the "${options.featureName}" feature based on the refined acceptance criteria below.
18487
18534
 
18488
18535
  CODEBASE CONTEXT:
@@ -18491,7 +18538,7 @@ ${options.codebaseContext}
18491
18538
  ACCEPTANCE CRITERIA (refined):
18492
18539
  ${criteriaList}
18493
18540
 
18494
- Generate a complete acceptance.test.ts file using bun:test framework. Each AC maps to exactly one test named "AC-N: <description>".
18541
+ ${strategyInstructions}Generate a complete acceptance.test.ts file using bun:test framework. Each AC maps to exactly one test named "AC-N: <description>".
18495
18542
 
18496
18543
  Use this structure:
18497
18544
 
@@ -18518,6 +18565,40 @@ Respond with ONLY the TypeScript test code (no markdown code fences, no explanat
18518
18565
  await _generatorPRDDeps.writeFile(join2(options.workdir, "acceptance-refined.json"), refinedJsonContent);
18519
18566
  return { testCode, criteria };
18520
18567
  }
18568
+ function buildStrategyInstructions(strategy, framework) {
18569
+ switch (strategy) {
18570
+ case "component": {
18571
+ const fw = framework ?? "ink-testing-library";
18572
+ if (fw === "react") {
18573
+ return `TEST STRATEGY: component (react)
18574
+ Import render and screen from @testing-library/react. Render the component and use screen.getByText to assert on output.
18575
+
18576
+ `;
18577
+ }
18578
+ return `TEST STRATEGY: component (ink-testing-library)
18579
+ Import render from ink-testing-library. Render the component and use lastFrame() to assert on output.
18580
+
18581
+ `;
18582
+ }
18583
+ case "cli":
18584
+ return `TEST STRATEGY: cli
18585
+ Use Bun.spawn to run the binary. Read stdout and assert on the text output.
18586
+
18587
+ `;
18588
+ case "e2e":
18589
+ return `TEST STRATEGY: e2e
18590
+ Use fetch() against http://localhost to call the running service. Assert on response body using response.text() or response.json().
18591
+
18592
+ `;
18593
+ case "snapshot":
18594
+ return `TEST STRATEGY: snapshot
18595
+ Render the component and use toMatchSnapshot() to capture and compare snapshots.
18596
+
18597
+ `;
18598
+ default:
18599
+ return "";
18600
+ }
18601
+ }
18521
18602
  function parseAcceptanceCriteria(specContent) {
18522
18603
  const criteria = [];
18523
18604
  const lines = specContent.split(`
@@ -21034,7 +21115,7 @@ var package_default;
21034
21115
  var init_package = __esm(() => {
21035
21116
  package_default = {
21036
21117
  name: "@nathapp/nax",
21037
- version: "0.40.0",
21118
+ version: "0.40.1",
21038
21119
  description: "AI Coding Agent Orchestrator \u2014 loops until done",
21039
21120
  type: "module",
21040
21121
  bin: {
@@ -21098,8 +21179,8 @@ var init_version = __esm(() => {
21098
21179
  NAX_VERSION = package_default.version;
21099
21180
  NAX_COMMIT = (() => {
21100
21181
  try {
21101
- if (/^[0-9a-f]{6,10}$/.test("5b23ab2"))
21102
- return "5b23ab2";
21182
+ if (/^[0-9a-f]{6,10}$/.test("ba3f634"))
21183
+ return "ba3f634";
21103
21184
  } catch {}
21104
21185
  try {
21105
21186
  const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
@@ -22846,7 +22927,9 @@ ${stderr}` };
22846
22927
  refinedCriteria = await _acceptanceSetupDeps.refine(allCriteria, {
22847
22928
  storyId: ctx.prd.userStories[0]?.id ?? "US-001",
22848
22929
  codebaseContext: "",
22849
- config: ctx.config
22930
+ config: ctx.config,
22931
+ testStrategy: ctx.config.acceptance.testStrategy,
22932
+ testFramework: ctx.config.acceptance.testFramework
22850
22933
  });
22851
22934
  } else {
22852
22935
  refinedCriteria = allCriteria.map((c) => ({
@@ -22863,7 +22946,9 @@ ${stderr}` };
22863
22946
  codebaseContext: "",
22864
22947
  modelTier: ctx.config.acceptance.model ?? "fast",
22865
22948
  modelDef: resolveModel(ctx.config.models[ctx.config.acceptance.model ?? "fast"]),
22866
- config: ctx.config
22949
+ config: ctx.config,
22950
+ testStrategy: ctx.config.acceptance.testStrategy,
22951
+ testFramework: ctx.config.acceptance.testFramework
22867
22952
  });
22868
22953
  await _acceptanceSetupDeps.writeFile(testPath, result.testCode);
22869
22954
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.40.0",
3
+ "version": "0.40.1",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -82,6 +82,8 @@ export async function generateFromPRD(
82
82
 
83
83
  const criteriaList = refinedCriteria.map((c, i) => `AC-${i + 1}: ${c.refined}`).join("\n");
84
84
 
85
+ const strategyInstructions = buildStrategyInstructions(options.testStrategy, options.testFramework);
86
+
85
87
  const prompt = `You are a test engineer. Generate acceptance tests for the "${options.featureName}" feature based on the refined acceptance criteria below.
86
88
 
87
89
  CODEBASE CONTEXT:
@@ -90,7 +92,7 @@ ${options.codebaseContext}
90
92
  ACCEPTANCE CRITERIA (refined):
91
93
  ${criteriaList}
92
94
 
93
- Generate a complete acceptance.test.ts file using bun:test framework. Each AC maps to exactly one test named "AC-N: <description>".
95
+ ${strategyInstructions}Generate a complete acceptance.test.ts file using bun:test framework. Each AC maps to exactly one test named "AC-N: <description>".
94
96
 
95
97
  Use this structure:
96
98
 
@@ -127,6 +129,26 @@ Respond with ONLY the TypeScript test code (no markdown code fences, no explanat
127
129
  return { testCode, criteria };
128
130
  }
129
131
 
132
+ function buildStrategyInstructions(strategy?: string, framework?: string): string {
133
+ switch (strategy) {
134
+ case "component": {
135
+ const fw = framework ?? "ink-testing-library";
136
+ if (fw === "react") {
137
+ return "TEST STRATEGY: component (react)\nImport render and screen from @testing-library/react. Render the component and use screen.getByText to assert on output.\n\n";
138
+ }
139
+ return "TEST STRATEGY: component (ink-testing-library)\nImport render from ink-testing-library. Render the component and use lastFrame() to assert on output.\n\n";
140
+ }
141
+ case "cli":
142
+ return "TEST STRATEGY: cli\nUse Bun.spawn to run the binary. Read stdout and assert on the text output.\n\n";
143
+ case "e2e":
144
+ return "TEST STRATEGY: e2e\nUse fetch() against http://localhost to call the running service. Assert on response body using response.text() or response.json().\n\n";
145
+ case "snapshot":
146
+ return "TEST STRATEGY: snapshot\nRender the component and use toMatchSnapshot() to capture and compare snapshots.\n\n";
147
+ default:
148
+ return "";
149
+ }
150
+ }
151
+
130
152
  export function parseAcceptanceCriteria(specContent: string): AcceptanceCriterion[] {
131
153
  const criteria: AcceptanceCriterion[] = [];
132
154
  const lines = specContent.split("\n");
@@ -22,21 +22,38 @@ export const _refineDeps = {
22
22
  adapter: new ClaudeCodeAdapter() as AgentAdapter,
23
23
  };
24
24
 
25
+ /**
26
+ * Strategy-specific context for the refinement prompt.
27
+ */
28
+ export interface RefinementPromptOptions {
29
+ /** Test strategy — controls strategy-specific prompt instructions */
30
+ testStrategy?: "unit" | "component" | "cli" | "e2e" | "snapshot";
31
+ /** Test framework — informs LLM which testing library syntax to use */
32
+ testFramework?: string;
33
+ }
34
+
25
35
  /**
26
36
  * Build the LLM prompt for refining acceptance criteria.
27
37
  *
28
38
  * @param criteria - Raw AC strings from PRD
29
39
  * @param codebaseContext - File tree / dependency context
40
+ * @param options - Optional strategy/framework context
30
41
  * @returns Formatted prompt string
31
42
  */
32
- export function buildRefinementPrompt(criteria: string[], codebaseContext: string): string {
43
+ export function buildRefinementPrompt(
44
+ criteria: string[],
45
+ codebaseContext: string,
46
+ options?: RefinementPromptOptions,
47
+ ): string {
33
48
  const criteriaList = criteria.map((c, i) => `${i + 1}. ${c}`).join("\n");
49
+ const strategySection = buildStrategySection(options);
50
+ const refinedExample = buildRefinedExample(options?.testStrategy);
34
51
 
35
52
  return `You are an acceptance criteria refinement assistant. Your task is to convert raw acceptance criteria into concrete, machine-verifiable assertions.
36
53
 
37
54
  CODEBASE CONTEXT:
38
55
  ${codebaseContext}
39
-
56
+ ${strategySection}
40
57
  ACCEPTANCE CRITERIA TO REFINE:
41
58
  ${criteriaList}
42
59
 
@@ -51,12 +68,62 @@ Respond with ONLY a JSON array (no markdown code fences):
51
68
 
52
69
  Rules:
53
70
  - "original" must match the input criterion text exactly
54
- - "refined" must be a concrete assertion (e.g., "Function returns array of length N", "HTTP status 200 returned")
71
+ - "refined" must be a concrete assertion (e.g., ${refinedExample})
55
72
  - "testable" is false only if the criterion cannot be automatically verified (e.g., "UX feels responsive", "design looks good")
56
73
  - "storyId" leave as empty string — it will be assigned by the caller
57
74
  - Respond with ONLY the JSON array`;
58
75
  }
59
76
 
77
+ /**
78
+ * Build strategy-specific instructions section for the prompt.
79
+ */
80
+ function buildStrategySection(options?: RefinementPromptOptions): string {
81
+ if (!options?.testStrategy) {
82
+ return "";
83
+ }
84
+
85
+ const framework = options.testFramework ? ` Use ${options.testFramework} testing library syntax.` : "";
86
+
87
+ switch (options.testStrategy) {
88
+ case "component":
89
+ return `
90
+ TEST STRATEGY: component
91
+ Focus assertions on rendered output visible on screen — text content, visible elements, and screen state.
92
+ Assert what the user sees rendered in the component, not what internal functions produce.${framework}
93
+ `;
94
+ case "cli":
95
+ return `
96
+ TEST STRATEGY: cli
97
+ Focus assertions on stdout and stderr text output from the CLI command.
98
+ Assert about terminal output content, exit codes, and standard output/standard error streams.${framework}
99
+ `;
100
+ case "e2e":
101
+ return `
102
+ TEST STRATEGY: e2e
103
+ Focus assertions on HTTP response content — status codes, response bodies, and endpoint behavior.
104
+ Assert about HTTP responses, status codes, and API endpoint output.${framework}
105
+ `;
106
+ default:
107
+ return framework ? `\nTEST FRAMEWORK: ${options.testFramework}\n` : "";
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Build the "refined" example string based on the test strategy.
113
+ */
114
+ function buildRefinedExample(testStrategy?: RefinementPromptOptions["testStrategy"]): string {
115
+ switch (testStrategy) {
116
+ case "component":
117
+ return '"Text content visible on screen matches expected", "Rendered output contains expected element"';
118
+ case "cli":
119
+ return '"stdout contains expected text", "stderr is empty on success", "exit code is 0"';
120
+ case "e2e":
121
+ return '"HTTP status 200 returned", "Response body contains expected field", "Endpoint returns JSON"';
122
+ default:
123
+ return '"Array of length N returned", "HTTP status 200 returned"';
124
+ }
125
+ }
126
+
60
127
  /**
61
128
  * Parse the LLM JSON response into RefinedCriterion[].
62
129
  *
@@ -105,7 +172,7 @@ export async function refineAcceptanceCriteria(
105
172
  return [];
106
173
  }
107
174
 
108
- const { storyId, codebaseContext, config } = context;
175
+ const { storyId, codebaseContext, config, testStrategy, testFramework } = context;
109
176
  const logger = getLogger();
110
177
 
111
178
  const modelTier = config.acceptance?.model ?? "fast";
@@ -116,7 +183,7 @@ export async function refineAcceptanceCriteria(
116
183
  }
117
184
 
118
185
  const modelDef = resolveModel(modelEntry);
119
- const prompt = buildRefinementPrompt(criteria, codebaseContext);
186
+ const prompt = buildRefinementPrompt(criteria, codebaseContext, { testStrategy, testFramework });
120
187
 
121
188
  let response: string;
122
189
 
@@ -0,0 +1,47 @@
1
+ /**
2
+ * CLI test template builder
3
+ *
4
+ * Generates acceptance test structure for CLI testing strategy.
5
+ * Uses Bun.spawn to run the binary and asserts on stdout text.
6
+ */
7
+
8
+ import type { AcceptanceCriterion } from "../types";
9
+
10
+ export interface CliTemplateOptions {
11
+ featureName: string;
12
+ criteria: AcceptanceCriterion[];
13
+ }
14
+
15
+ /**
16
+ * Build CLI test template code for the given criteria.
17
+ *
18
+ * @param options - Feature name and criteria list
19
+ * @returns TypeScript test code string
20
+ */
21
+ export function buildCliTemplate(options: CliTemplateOptions): string {
22
+ const { featureName, criteria } = options;
23
+
24
+ const tests = criteria
25
+ .map(
26
+ (ac) => ` test("${ac.id}: ${ac.text}", async () => {
27
+ const proc = Bun.spawn(["bun", "run", "src/${featureName}.ts"], {
28
+ stdout: "pipe",
29
+ stderr: "pipe",
30
+ });
31
+ const [exitCode, stdout] = await Promise.all([
32
+ proc.exited,
33
+ new Response(proc.stdout).text(),
34
+ ]);
35
+ expect(exitCode).toBe(0);
36
+ expect(stdout).toContain(""); // Replace with expected stdout text
37
+ });`,
38
+ )
39
+ .join("\n\n");
40
+
41
+ return `import { describe, expect, test } from "bun:test";
42
+
43
+ describe("${featureName} - Acceptance Tests", () => {
44
+ ${tests}
45
+ });
46
+ `;
47
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Component test template builder
3
+ *
4
+ * Generates acceptance test structure for component testing strategy.
5
+ * Supports ink-testing-library (lastFrame) and react (screen.getByText).
6
+ */
7
+
8
+ import type { AcceptanceCriterion } from "../types";
9
+
10
+ export interface ComponentTemplateOptions {
11
+ featureName: string;
12
+ criteria: AcceptanceCriterion[];
13
+ /** Test framework: 'ink-testing-library' | 'react' */
14
+ testFramework?: string;
15
+ }
16
+
17
+ /**
18
+ * Build component test template code for the given criteria.
19
+ *
20
+ * @param options - Feature name, criteria, and test framework
21
+ * @returns TypeScript test code string
22
+ */
23
+ export function buildComponentTemplate(options: ComponentTemplateOptions): string {
24
+ const { featureName, criteria, testFramework = "ink-testing-library" } = options;
25
+
26
+ if (testFramework === "react") {
27
+ return buildReactTemplate(featureName, criteria);
28
+ }
29
+
30
+ return buildInkTemplate(featureName, criteria);
31
+ }
32
+
33
+ function buildInkTemplate(featureName: string, criteria: AcceptanceCriterion[]): string {
34
+ const tests = criteria
35
+ .map(
36
+ (ac) => ` test("${ac.id}: ${ac.text}", () => {
37
+ const { lastFrame } = render(<${toPascalCase(featureName)} />);
38
+ expect(lastFrame()).toContain(""); // Replace with expected output
39
+ });`,
40
+ )
41
+ .join("\n\n");
42
+
43
+ return `import { describe, expect, test } from "bun:test";
44
+ import { render } from "ink-testing-library";
45
+ import { ${toPascalCase(featureName)} } from "../src/${featureName}";
46
+
47
+ describe("${featureName} - Acceptance Tests", () => {
48
+ ${tests}
49
+ });
50
+ `;
51
+ }
52
+
53
+ function buildReactTemplate(featureName: string, criteria: AcceptanceCriterion[]): string {
54
+ const tests = criteria
55
+ .map(
56
+ (ac) => ` test("${ac.id}: ${ac.text}", () => {
57
+ render(<${toPascalCase(featureName)} />);
58
+ expect(screen.getByText("")).toBeTruthy(); // Replace with expected text
59
+ });`,
60
+ )
61
+ .join("\n\n");
62
+
63
+ return `import { describe, expect, test } from "bun:test";
64
+ import { render, screen } from "@testing-library/react";
65
+ import { ${toPascalCase(featureName)} } from "../src/${featureName}";
66
+
67
+ describe("${featureName} - Acceptance Tests", () => {
68
+ ${tests}
69
+ });
70
+ `;
71
+ }
72
+
73
+ function toPascalCase(name: string): string {
74
+ return name
75
+ .split(/[-_\s]+/)
76
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
77
+ .join("");
78
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * E2E test template builder
3
+ *
4
+ * Generates acceptance test structure for end-to-end testing strategy.
5
+ * Uses fetch() against localhost and asserts on response body.
6
+ */
7
+
8
+ import type { AcceptanceCriterion } from "../types";
9
+
10
+ export interface E2eTemplateOptions {
11
+ featureName: string;
12
+ criteria: AcceptanceCriterion[];
13
+ }
14
+
15
+ const DEFAULT_PORT = 3000;
16
+
17
+ /**
18
+ * Build E2E test template code for the given criteria.
19
+ *
20
+ * @param options - Feature name and criteria list
21
+ * @returns TypeScript test code string
22
+ */
23
+ export function buildE2eTemplate(options: E2eTemplateOptions): string {
24
+ const { featureName, criteria } = options;
25
+
26
+ const tests = criteria
27
+ .map(
28
+ (ac) => ` test("${ac.id}: ${ac.text}", async () => {
29
+ const response = await fetch("http://localhost:${DEFAULT_PORT}/api/${featureName}");
30
+ expect(response.ok).toBe(true);
31
+ const body = await response.text();
32
+ expect(body).toContain(""); // Replace with expected response body
33
+ });`,
34
+ )
35
+ .join("\n\n");
36
+
37
+ return `import { describe, expect, test } from "bun:test";
38
+
39
+ describe("${featureName} - Acceptance Tests", () => {
40
+ ${tests}
41
+ });
42
+ `;
43
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Acceptance Test Template Builders
3
+ *
4
+ * One builder per test strategy. The generator selects the appropriate
5
+ * builder based on the testStrategy option.
6
+ */
7
+
8
+ export { buildUnitTemplate } from "./unit";
9
+ export type { UnitTemplateOptions } from "./unit";
10
+
11
+ export { buildComponentTemplate } from "./component";
12
+ export type { ComponentTemplateOptions } from "./component";
13
+
14
+ export { buildCliTemplate } from "./cli";
15
+ export type { CliTemplateOptions } from "./cli";
16
+
17
+ export { buildE2eTemplate } from "./e2e";
18
+ export type { E2eTemplateOptions } from "./e2e";
19
+
20
+ export { buildSnapshotTemplate } from "./snapshot";
21
+ export type { SnapshotTemplateOptions } from "./snapshot";
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Snapshot test template builder
3
+ *
4
+ * Generates acceptance test structure for snapshot testing strategy.
5
+ * Renders the component and uses toMatchSnapshot() for assertions.
6
+ */
7
+
8
+ import type { AcceptanceCriterion } from "../types";
9
+
10
+ export interface SnapshotTemplateOptions {
11
+ featureName: string;
12
+ criteria: AcceptanceCriterion[];
13
+ /** Test framework: 'ink-testing-library' | 'react' */
14
+ testFramework?: string;
15
+ }
16
+
17
+ /**
18
+ * Build snapshot test template code for the given criteria.
19
+ *
20
+ * @param options - Feature name, criteria, and optional test framework
21
+ * @returns TypeScript test code string
22
+ */
23
+ export function buildSnapshotTemplate(options: SnapshotTemplateOptions): string {
24
+ const { featureName, criteria } = options;
25
+
26
+ const tests = criteria
27
+ .map(
28
+ (ac) => ` test("${ac.id}: ${ac.text}", () => {
29
+ const { lastFrame } = render(<${toPascalCase(featureName)} />);
30
+ expect(lastFrame()).toMatchSnapshot();
31
+ });`,
32
+ )
33
+ .join("\n\n");
34
+
35
+ return `import { describe, expect, test } from "bun:test";
36
+ import { render } from "ink-testing-library";
37
+ import { ${toPascalCase(featureName)} } from "../src/${featureName}";
38
+
39
+ describe("${featureName} - Acceptance Tests", () => {
40
+ ${tests}
41
+ });
42
+ `;
43
+ }
44
+
45
+ function toPascalCase(name: string): string {
46
+ return name
47
+ .split(/[-_\s]+/)
48
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
49
+ .join("");
50
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Unit test template builder
3
+ *
4
+ * Generates acceptance test structure for unit testing strategy:
5
+ * imports the function under test, calls it, and asserts on the return value.
6
+ */
7
+
8
+ import type { AcceptanceCriterion } from "../types";
9
+
10
+ export interface UnitTemplateOptions {
11
+ featureName: string;
12
+ criteria: AcceptanceCriterion[];
13
+ }
14
+
15
+ /**
16
+ * Build unit test template code for the given criteria.
17
+ *
18
+ * @param options - Feature name and criteria list
19
+ * @returns TypeScript test code string
20
+ */
21
+ export function buildUnitTemplate(options: UnitTemplateOptions): string {
22
+ const { featureName, criteria } = options;
23
+
24
+ const tests = criteria
25
+ .map(
26
+ (ac) => ` test("${ac.id}: ${ac.text}", async () => {
27
+ // TODO: import and call the function under test
28
+ expect(true).toBe(true); // Replace with real assertion
29
+ });`,
30
+ )
31
+ .join("\n\n");
32
+
33
+ return `import { describe, expect, test } from "bun:test";
34
+ import { ${toCamelCase(featureName)} } from "../src/${toKebabCase(featureName)}";
35
+
36
+ describe("${featureName} - Acceptance Tests", () => {
37
+ ${tests}
38
+ });
39
+ `;
40
+ }
41
+
42
+ function toCamelCase(name: string): string {
43
+ return name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
44
+ }
45
+
46
+ function toKebabCase(name: string): string {
47
+ return name.toLowerCase().replace(/\s+/g, "-");
48
+ }
@@ -4,7 +4,7 @@
4
4
  * Types for generating acceptance tests from spec.md acceptance criteria.
5
5
  */
6
6
 
7
- import type { ModelDef, ModelTier, NaxConfig } from "../config/schema";
7
+ import type { AcceptanceTestStrategy, ModelDef, ModelTier, NaxConfig } from "../config/schema";
8
8
 
9
9
  /**
10
10
  * A single refined acceptance criterion produced by the refinement module.
@@ -30,6 +30,10 @@ export interface RefinementContext {
30
30
  codebaseContext: string;
31
31
  /** Global config — model tier resolved from config.acceptance.model */
32
32
  config: NaxConfig;
33
+ /** Test strategy — controls strategy-specific prompt instructions */
34
+ testStrategy?: AcceptanceTestStrategy;
35
+ /** Test framework — informs LLM which testing library syntax to use */
36
+ testFramework?: string;
33
37
  }
34
38
 
35
39
  /**
@@ -84,6 +88,10 @@ export interface GenerateFromPRDOptions {
84
88
  modelDef: ModelDef;
85
89
  /** Global config for quality settings */
86
90
  config: NaxConfig;
91
+ /** Test strategy to use for template selection (default: 'unit') */
92
+ testStrategy?: AcceptanceTestStrategy;
93
+ /** Test framework for component/snapshot strategies (e.g. 'ink-testing-library', 'react') */
94
+ testFramework?: string;
87
95
  }
88
96
 
89
97
  export interface GenerateAcceptanceTestsOptions {
@@ -5,12 +5,74 @@
5
5
  * for nax/config.json.
6
6
  */
7
7
 
8
- import { existsSync } from "node:fs";
8
+ import { existsSync, readFileSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
 
11
11
  /** Detected project runtime */
12
12
  export type Runtime = "bun" | "node" | "unknown";
13
13
 
14
+ /** Detected UI framework */
15
+ export type UIFramework = "ink" | "react" | "vue" | "svelte";
16
+
17
+ /** Full stack info including UI framework and bin detection */
18
+ export interface StackInfo extends ProjectStack {
19
+ uiFramework?: UIFramework;
20
+ hasBin?: boolean;
21
+ }
22
+
23
+ /** Shape of a parsed package.json for detection purposes */
24
+ interface PackageJson {
25
+ dependencies?: Record<string, string>;
26
+ devDependencies?: Record<string, string>;
27
+ peerDependencies?: Record<string, string>;
28
+ bin?: Record<string, string> | string;
29
+ }
30
+
31
+ function readPackageJson(projectRoot: string): PackageJson | undefined {
32
+ const pkgPath = join(projectRoot, "package.json");
33
+ if (!existsSync(pkgPath)) return undefined;
34
+ try {
35
+ return JSON.parse(readFileSync(pkgPath, "utf-8")) as PackageJson;
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ }
40
+
41
+ function allDeps(pkg: PackageJson): Record<string, string> {
42
+ return {
43
+ ...pkg.dependencies,
44
+ ...pkg.devDependencies,
45
+ ...pkg.peerDependencies,
46
+ };
47
+ }
48
+
49
+ function detectUIFramework(pkg: PackageJson): UIFramework | undefined {
50
+ const deps = allDeps(pkg);
51
+ if ("ink" in deps) return "ink";
52
+ if ("react" in deps || "next" in deps) return "react";
53
+ if ("vue" in deps || "nuxt" in deps) return "vue";
54
+ if ("svelte" in deps || "@sveltejs/kit" in deps) return "svelte";
55
+ return undefined;
56
+ }
57
+
58
+ function detectHasBin(pkg: PackageJson): boolean {
59
+ return pkg.bin !== undefined;
60
+ }
61
+
62
+ /**
63
+ * Detect the project stack including UI framework from package.json.
64
+ */
65
+ export function detectStack(projectRoot: string): StackInfo {
66
+ const base = detectProjectStack(projectRoot);
67
+ const pkg = readPackageJson(projectRoot);
68
+ if (!pkg) return base;
69
+ return {
70
+ ...base,
71
+ uiFramework: detectUIFramework(pkg),
72
+ hasBin: detectHasBin(pkg) || undefined,
73
+ };
74
+ }
75
+
14
76
  /** Detected project language */
15
77
  export type Language = "typescript" | "python" | "rust" | "go" | "unknown";
16
78
 
@@ -146,24 +208,48 @@ function isStackDetected(stack: ProjectStack): boolean {
146
208
  return stack.runtime !== "unknown" || stack.language !== "unknown";
147
209
  }
148
210
 
211
+ /** Build the acceptance config section from StackInfo, or undefined if not applicable. */
212
+ function buildAcceptanceConfig(stack: StackInfo): { testStrategy: string; testFramework?: string } | undefined {
213
+ if (stack.uiFramework === "ink") {
214
+ return { testStrategy: "component", testFramework: "ink-testing-library" };
215
+ }
216
+ if (stack.uiFramework === "react") {
217
+ return { testStrategy: "component", testFramework: "@testing-library/react" };
218
+ }
219
+ if (stack.uiFramework === "vue") {
220
+ return { testStrategy: "component", testFramework: "@testing-library/vue" };
221
+ }
222
+ if (stack.uiFramework === "svelte") {
223
+ return { testStrategy: "component", testFramework: "@testing-library/svelte" };
224
+ }
225
+ if (stack.hasBin) {
226
+ const testFramework = stack.runtime === "bun" ? "bun:test" : "jest";
227
+ return { testStrategy: "cli", testFramework };
228
+ }
229
+ return undefined;
230
+ }
231
+
149
232
  /**
150
233
  * Build the full init config object from a detected project stack.
151
234
  * Falls back to minimal config when stack is undetected.
152
235
  */
153
- export function buildInitConfig(stack: ProjectStack): object {
236
+ export function buildInitConfig(stack: ProjectStack | StackInfo): object {
237
+ const stackInfo = stack as StackInfo;
238
+ const acceptance = buildAcceptanceConfig(stackInfo);
239
+
154
240
  if (!isStackDetected(stack)) {
155
- return { version: 1 };
241
+ return acceptance ? { version: 1, acceptance } : { version: 1 };
156
242
  }
157
243
 
158
244
  const commands = buildQualityCommands(stack);
159
245
  const hasCommands = Object.keys(commands).length > 0;
160
246
 
161
- if (!hasCommands) {
247
+ if (!hasCommands && !acceptance) {
162
248
  return { version: 1 };
163
249
  }
164
250
 
165
- return {
166
- version: 1,
167
- quality: { commands },
168
- };
251
+ const config: Record<string, unknown> = { version: 1 };
252
+ if (hasCommands) config.quality = { commands };
253
+ if (acceptance) config.acceptance = acceptance;
254
+ return config;
169
255
  }
package/src/cli/init.ts CHANGED
@@ -10,7 +10,7 @@ import { join } from "node:path";
10
10
  import { globalConfigDir, projectConfigDir } from "../config/paths";
11
11
  import { getLogger } from "../logger";
12
12
  import { initContext } from "./init-context";
13
- import { buildInitConfig, detectProjectStack } from "./init-detect";
13
+ import { buildInitConfig, detectStack } from "./init-detect";
14
14
  import type { ProjectStack } from "./init-detect";
15
15
  import { promptsInitCommand } from "./prompts";
16
16
 
@@ -183,7 +183,7 @@ export async function initProject(projectRoot: string, options?: InitProjectOpti
183
183
  }
184
184
 
185
185
  // Detect project stack and build config
186
- const stack = detectProjectStack(projectRoot);
186
+ const stack = detectStack(projectRoot);
187
187
  const projectConfig = buildInitConfig(stack);
188
188
  logger.info("init", "Detected project stack", {
189
189
  runtime: stack.runtime,
@@ -14,7 +14,7 @@ export type {
14
14
  TierConfig,
15
15
  RectificationConfig,
16
16
  } from "./schema";
17
- export { DEFAULT_CONFIG, resolveModel, NaxConfigSchema } from "./schema";
17
+ export { DEFAULT_CONFIG, resolveModel, NaxConfigSchema, AcceptanceConfigSchema } from "./schema";
18
18
  export { loadConfig, findProjectDir, globalConfigPath } from "./loader";
19
19
  export { validateConfig, type ValidationResult } from "./validate"; // @deprecated: Use NaxConfigSchema.safeParse() instead
20
20
  export { validateDirectory, validateFilePath, isWithinDirectory, MAX_DIRECTORY_DEPTH } from "./path-security";
@@ -228,6 +228,9 @@ export interface PlanConfig {
228
228
  outputPath: string;
229
229
  }
230
230
 
231
+ /** Valid test strategy values for acceptance testing */
232
+ export type AcceptanceTestStrategy = "unit" | "component" | "cli" | "e2e" | "snapshot";
233
+
231
234
  /** Acceptance validation config */
232
235
  export interface AcceptanceConfig {
233
236
  /** Enable acceptance test generation and validation */
@@ -244,6 +247,10 @@ export interface AcceptanceConfig {
244
247
  refinement: boolean;
245
248
  /** Whether to run RED gate check after generating acceptance tests (default: true) */
246
249
  redGate: boolean;
250
+ /** Test strategy for acceptance tests (default: auto-detect) */
251
+ testStrategy?: AcceptanceTestStrategy;
252
+ /** Test framework for acceptance tests (default: auto-detect) */
253
+ testFramework?: string;
247
254
  }
248
255
 
249
256
  /** Optimizer config (v0.10) */
@@ -30,6 +30,7 @@ export type {
30
30
  ReviewConfig,
31
31
  PlanConfig,
32
32
  AcceptanceConfig,
33
+ AcceptanceTestStrategy,
33
34
  OptimizerConfig,
34
35
  PluginConfigEntry,
35
36
  HooksConfig,
@@ -52,7 +53,7 @@ export type {
52
53
  export { resolveModel } from "./types";
53
54
 
54
55
  // Zod schemas
55
- export { NaxConfigSchema } from "./schemas";
56
+ export { NaxConfigSchema, AcceptanceConfigSchema } from "./schemas";
56
57
 
57
58
  // Default config
58
59
  export { DEFAULT_CONFIG } from "./defaults";
@@ -210,7 +210,7 @@ const PlanConfigSchema = z.object({
210
210
  outputPath: z.string().min(1, "plan.outputPath must be non-empty"),
211
211
  });
212
212
 
213
- const AcceptanceConfigSchema = z.object({
213
+ export const AcceptanceConfigSchema = z.object({
214
214
  enabled: z.boolean(),
215
215
  maxRetries: z.number().int().nonnegative(),
216
216
  generateTests: z.boolean(),
@@ -218,6 +218,8 @@ const AcceptanceConfigSchema = z.object({
218
218
  model: z.enum(["fast", "balanced", "powerful"]).default("fast"),
219
219
  refinement: z.boolean().default(true),
220
220
  redGate: z.boolean().default(true),
221
+ testStrategy: z.enum(["unit", "component", "cli", "e2e", "snapshot"]).optional(),
222
+ testFramework: z.string().min(1, "acceptance.testFramework must be non-empty").optional(),
221
223
  });
222
224
 
223
225
  const TestCoverageConfigSchema = z.object({
@@ -24,6 +24,7 @@ export { resolveModel } from "./schema-types";
24
24
  // Runtime types
25
25
  export type {
26
26
  AcceptanceConfig,
27
+ AcceptanceTestStrategy,
27
28
  AnalyzeConfig,
28
29
  AutoModeConfig,
29
30
  ConstitutionConfig,
@@ -89,6 +89,8 @@ export const acceptanceSetupStage: PipelineStage = {
89
89
  storyId: ctx.prd.userStories[0]?.id ?? "US-001",
90
90
  codebaseContext: "",
91
91
  config: ctx.config,
92
+ testStrategy: ctx.config.acceptance.testStrategy,
93
+ testFramework: ctx.config.acceptance.testFramework,
92
94
  });
93
95
  } else {
94
96
  refinedCriteria = allCriteria.map((c) => ({
@@ -108,6 +110,8 @@ export const acceptanceSetupStage: PipelineStage = {
108
110
  modelTier: ctx.config.acceptance.model ?? "fast",
109
111
  modelDef: resolveModel(ctx.config.models[ctx.config.acceptance.model ?? "fast"]),
110
112
  config: ctx.config,
113
+ testStrategy: ctx.config.acceptance.testStrategy,
114
+ testFramework: ctx.config.acceptance.testFramework,
111
115
  });
112
116
 
113
117
  await _acceptanceSetupDeps.writeFile(testPath, result.testCode);