@activate-core/test-forge 0.4.0 → 0.5.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/cli/index.js CHANGED
@@ -49,7 +49,10 @@ function printUsage() {
49
49
  console.log(' testforge generate Generate tests from the journey docs');
50
50
  console.log(' testforge generate --basic Use the basic generator only');
51
51
  console.log(' testforge generate --force Regenerate everything');
52
+ console.log(' testforge generate --journey <name> Generate just ONE journey');
52
53
  console.log(' testforge discover Draft journey docs (asks: map or reference)');
54
+ console.log(' testforge discover --journey <name> Draft just ONE journey doc');
55
+ console.log(' testforge discover --reference <file|url> Base docs on a reference');
53
56
  console.log(' testforge map Scan project, write journey + data map');
54
57
  console.log(' testforge init Inspect project and write config');
55
58
  console.log(' testforge update Re-check and update only what changed');
@@ -78,7 +81,7 @@ async function main() {
78
81
  else if (command === 'discover') {
79
82
  console.log('Drafting journey files with AI...');
80
83
  console.log('This writes drafts into docs/ from your chosen source.\n');
81
- const output = await runDiscover('.', 'docs', sourceOptions());
84
+ const output = await runDiscover('.', 'docs', sourceOptions(), flagValue('--journey'));
82
85
  console.log(output);
83
86
  console.log('\nDraft journeys written to docs/. Please review and edit them,');
84
87
  console.log('then run `testforge generate`.');
@@ -91,7 +94,7 @@ async function main() {
91
94
  console.log(useAI
92
95
  ? 'Generating tests with AI for changed journeys...\n'
93
96
  : 'Generating tests with the basic generator...\n');
94
- const results = runGenerate('docs', useAI, force);
97
+ const results = await runGenerate('docs', useAI, force, flagValue('--journey'));
95
98
  if (results.length === 0) {
96
99
  console.log('No journey files found in docs/. Add a .md file and try again.');
97
100
  }
@@ -102,8 +105,11 @@ async function main() {
102
105
  }
103
106
  else {
104
107
  const tag = r.status === 'ai' ? '[AI]' : '[basic]';
105
- console.log(` ${tag} ${r.journeyName}`);
106
- console.log(` test: ${r.testPath}`);
108
+ console.log(` ${tag} ${r.journeyName} — ${r.testFiles.length} test file(s)`);
109
+ console.log(` dir: ${r.testDir}/`);
110
+ for (const tf of r.testFiles) {
111
+ console.log(` - ${tf.slug}`);
112
+ }
107
113
  if (r.status === 'ai') {
108
114
  if (r.seedStatus === 'ok') {
109
115
  console.log(` seed: ${r.seedPath}`);
@@ -10,22 +10,42 @@
10
10
  * We call it in print mode (`claude -p`) and feed the prompt via
11
11
  * stdin so long prompts and special characters are handled safely.
12
12
  */
13
- import { execFileSync } from 'node:child_process';
13
+ import { execFileSync, spawn } from 'node:child_process';
14
14
  import { readFileSync, existsSync } from 'node:fs';
15
- import { loadConfig, splitCommand, withTextOnlyTools } from './config.js';
15
+ import { loadConfig, splitCommand, withReadOnlyTools, resolveFramework, } from './config.js';
16
16
  const MAP_PATH = 'testforge.md';
17
+ /**
18
+ * Spawn a command, feed `input` on stdin, resolve with its stdout.
19
+ * Async (unlike execFileSync) so many calls can run concurrently.
20
+ */
21
+ function runWithStdin(program, args, input) {
22
+ return new Promise((resolve, reject) => {
23
+ const child = spawn(program, args, {
24
+ stdio: ['pipe', 'pipe', 'pipe'],
25
+ });
26
+ let out = '';
27
+ let err = '';
28
+ child.stdout.setEncoding('utf-8');
29
+ child.stdout.on('data', (d) => (out += d));
30
+ child.stderr.on('data', (d) => (err += d));
31
+ child.on('error', reject);
32
+ child.on('close', (code) => {
33
+ if (code === 0)
34
+ resolve(out);
35
+ else
36
+ reject(new Error(`${program} exited ${code}: ${err.slice(0, 500)}`));
37
+ });
38
+ child.stdin.end(input);
39
+ });
40
+ }
17
41
  /** Build the prompt we send to the AI for one journey. */
18
- export function buildPrompt(journey, map, testPath) {
42
+ export function buildPrompt(journey, map, testPath, focus) {
19
43
  const config = loadConfig();
20
44
  // Opinionated: testforge only generates Jest (api/backend) or Playwright
21
- // (web/UI) tests. We look the framework up by AREA, but clamp anything
22
- // that is not jest/supertest/playwright (e.g. a project's cypress setup)
23
- // to the sensible default for the journey's Type — so no other framework
24
- // ever leaks into generated output.
25
- const configured = (config.framework?.[journey.area] ?? '').toLowerCase();
26
- const isApi = configured === 'jest' ||
27
- configured === 'supertest' ||
28
- (configured !== 'playwright' && journey.type === 'api');
45
+ // (web/UI) tests — any other framework (e.g. a project's Cypress setup)
46
+ // is clamped to the right one for the journey. Single source of truth in
47
+ // resolveFramework, shared with the output-layout decision.
48
+ const isApi = resolveFramework(config, journey.area, journey.type) === 'jest';
29
49
  const framework = isApi
30
50
  ? 'Jest (with supertest for HTTP) for an API/backend test'
31
51
  : 'Playwright for a web/UI test';
@@ -38,6 +58,19 @@ export function buildPrompt(journey, map, testPath) {
38
58
  `You are a senior QA engineer. Generate a complete ${framework}`,
39
59
  `for the user journey described below.`,
40
60
  ``,
61
+ ...(focus
62
+ ? [
63
+ `SCOPE — this is ONE file in a larger suite for this journey.`,
64
+ `Cover ONLY this concern in this file:`,
65
+ ` ${focus.title}`,
66
+ ` ${focus.covers}`,
67
+ `Do NOT test the journey's other concerns here — separate files`,
68
+ `cover those. Go DEEP on this one concern (happy path + its own`,
69
+ `edge/negative/security cases) rather than broad-but-shallow.`,
70
+ `Use this exact describe() title: "${focus.title}".`,
71
+ ``,
72
+ ]
73
+ : []),
41
74
  `Think like an experienced tester whose goal is to break the`,
42
75
  `application. Cover, where relevant: happy path, missing/empty`,
43
76
  `required fields, invalid formats and boundary values (very long,`,
@@ -57,6 +90,19 @@ export function buildPrompt(journey, map, testPath) {
57
90
  `guard is missing and produce a failing test that falsely brands the`,
58
91
  `product as vulnerable.`,
59
92
  ``,
93
+ `GROUND EVERYTHING IN THE REAL SOURCE. You are running inside the`,
94
+ `project's repository and have read-only tools (Read, Grep, Glob, LS).`,
95
+ `BEFORE writing tests, look up the actual code for this journey — use`,
96
+ `the route/endpoint named below to grep for the router, then read the`,
97
+ `route file, its controller/handler, the service it calls, and the`,
98
+ `models it touches. Confirm the REAL endpoint paths, HTTP methods,`,
99
+ `request/response shapes, status codes, auth requirements, and function`,
100
+ `signatures from the code. Base every assertion on what the code`,
101
+ `actually does. Only fall back to "// TODO:" or test.todo/it.skip when`,
102
+ `the behavior genuinely cannot be determined from the source — do not`,
103
+ `leave something as a TODO that a quick read of the code would answer.`,
104
+ `Do NOT write or edit any file; output only the test code to stdout.`,
105
+ ``,
60
106
  isApi
61
107
  ? [
62
108
  `Because this is an API journey, write Jest tests that call the`,
@@ -122,12 +168,13 @@ export function buildPrompt(journey, map, testPath) {
122
168
  * journey. Reads the project map (testforge.md) for data-layer
123
169
  * context. Returns the test file content.
124
170
  */
125
- export function generateWithAI(journey, testPath) {
171
+ export function generateWithAI(journey, testPath, focus) {
126
172
  const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
127
- const prompt = buildPrompt(journey, map, testPath);
128
- // Generation is pure text — run with NO tools so the model just
129
- // prints code instead of trying to write files.
130
- const { program, args } = withTextOnlyTools(splitCommand(loadConfig().aiCommand));
173
+ const prompt = buildPrompt(journey, map, testPath, focus);
174
+ // Allow read-only exploration so the model grounds the test in the
175
+ // real source (routes/controllers/models); Write stays denied, so it
176
+ // still can't create files — it reads, then prints the code.
177
+ const { program, args } = withReadOnlyTools(splitCommand(loadConfig().aiCommand));
131
178
  const output = execFileSync(program, args, {
132
179
  input: prompt,
133
180
  encoding: 'utf-8',
@@ -135,6 +182,18 @@ export function generateWithAI(journey, testPath) {
135
182
  });
136
183
  return cleanOutput(output);
137
184
  }
185
+ /**
186
+ * Async twin of generateWithAI, so multiple focused files in one
187
+ * journey's suite can be generated concurrently (each spawns its own
188
+ * `claude` process). Same prompt, same read-only exploration.
189
+ */
190
+ export async function generateWithAIAsync(journey, testPath, focus) {
191
+ const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
192
+ const prompt = buildPrompt(journey, map, testPath, focus);
193
+ const { program, args } = withReadOnlyTools(splitCommand(loadConfig().aiCommand));
194
+ const stdout = await runWithStdin(program, args, prompt);
195
+ return cleanOutput(stdout);
196
+ }
138
197
  /** Does this line look like the start (or body) of real code? */
139
198
  function looksLikeCode(line) {
140
199
  const t = line.trim();
@@ -99,6 +99,19 @@ export function splitCommand(command) {
99
99
  const args = tokens.slice(1);
100
100
  return { program, args };
101
101
  }
102
+ /**
103
+ * Resolve the test framework for a journey — clamped to the only two
104
+ * testforge supports. A web/UI journey is always Playwright (even if the
105
+ * project uses Cypress); an endpoint journey is Jest. This single source
106
+ * of truth drives BOTH the generation prompt and the output layout.
107
+ */
108
+ export function resolveFramework(config, area, type) {
109
+ const configured = (config.framework?.[area] ?? '').toLowerCase();
110
+ const isApi = configured === 'jest' ||
111
+ configured === 'supertest' ||
112
+ (configured !== 'playwright' && type === 'api');
113
+ return isApi ? 'jest' : 'playwright';
114
+ }
102
115
  /** Is this command Claude's CLI (so Claude-specific flags apply)? */
103
116
  function isClaude(program) {
104
117
  const base = program.split(/[\\/]/).pop() ?? program;
@@ -115,6 +128,27 @@ export function withTextOnlyTools(cmd) {
115
128
  return cmd;
116
129
  return { program: cmd.program, args: [...cmd.args, '--allowedTools', ''] };
117
130
  }
131
+ /**
132
+ * Args for a generation call that should EXPLORE the real source before
133
+ * writing — read-only tools (Read/Grep/Glob/LS) are allowed so the model
134
+ * can confirm actual endpoint paths, request/response shapes, and model
135
+ * schemas from the code, but Write/Edit/Bash stay OUT of the allowlist so
136
+ * it still can't create files or run commands. The model reads, then
137
+ * prints the test/seed code to stdout.
138
+ *
139
+ * This is the key to tests grounded in the real code instead of
140
+ * assumptions: without source access the model leaves "endpoint shape
141
+ * unconfirmed" TODOs; with it, those become real assertions.
142
+ */
143
+ export function withReadOnlyTools(cmd) {
144
+ if (!isClaude(cmd.program))
145
+ return cmd;
146
+ return {
147
+ program: cmd.program,
148
+ // Space-separated allowlist; Write/Edit/Bash deliberately excluded.
149
+ args: [...cmd.args, '--allowedTools', 'Read Grep Glob LS'],
150
+ };
151
+ }
118
152
  /**
119
153
  * Args for a phase where the model must read/write files (init, map,
120
154
  * discover). Mirrors withTextOnlyTools but grants edit permission.
@@ -16,15 +16,30 @@
16
16
  */
17
17
  import { execFileSync } from 'node:child_process';
18
18
  import { loadConfig, splitCommand, withEditTools } from './config.js';
19
+ import { DEEP_DOC_GUIDANCE } from './doc-guidance.js';
19
20
  import { promptConfirm } from './prompt.js';
20
21
  import { resolveDocsSource, saveSourceChoice, } from './source-of-truth.js';
21
22
  import { analyzeReference, draftDocsFromReference, DIFF_PATH, } from './reference.js';
22
- /** Build the discovery prompt. */
23
- export function buildDiscoverPrompt(srcDir, docsDir) {
23
+ /** Scope line: one journey, or all of them. */
24
+ function scopeLines(journey) {
25
+ return journey
26
+ ? [
27
+ `SCOPE: document ONLY the single journey matching "${journey}"`,
28
+ `(match by name, or by "area/name"). Create just that ONE file and`,
29
+ `ignore every other journey.`,
30
+ ``,
31
+ ]
32
+ : [`Document EVERY journey in the map (one file each).`, ``];
33
+ }
34
+ /** Build the discovery prompt. Optionally scoped to one journey. */
35
+ export function buildDiscoverPrompt(srcDir, docsDir, journey) {
24
36
  return [
25
37
  `You are a senior QA engineer creating per-journey documentation`,
26
38
  `for a project, so a QA tool can generate tests from it.`,
27
39
  ``,
40
+ ...scopeLines(journey),
41
+ DEEP_DOC_GUIDANCE,
42
+ ``,
28
43
  `First, read the project map at "testforge.md" if it exists. It`,
29
44
  `lists the project's journeys (grouped by area) and its data layer`,
30
45
  `(database, models, key fields, enums). Use the map as your guide`,
@@ -89,8 +104,8 @@ export function buildDiscoverPrompt(srcDir, docsDir) {
89
104
  * Draft the docs from the project's own code (the default). Invokes the
90
105
  * AI CLI with permission to read source and write markdown files.
91
106
  */
92
- function runCodeDiscover(srcDir, docsDir) {
93
- const prompt = buildDiscoverPrompt(srcDir, docsDir);
107
+ function runCodeDiscover(srcDir, docsDir, journey) {
108
+ const prompt = buildDiscoverPrompt(srcDir, docsDir, journey);
94
109
  // Use the configured AI command. Note: discovery needs the AI to
95
110
  // read source files and write markdown files. The flags for that
96
111
  // are tool-specific; for Claude we add acceptEdits. Other tools may
@@ -113,7 +128,7 @@ function runCodeDiscover(srcDir, docsDir) {
113
128
  * Async because the source-of-truth barrier may prompt the developer.
114
129
  * Returns the AI's text summary of what it created.
115
130
  */
116
- export async function runDiscover(srcDir, docsDir, opts = {}) {
131
+ export async function runDiscover(srcDir, docsDir, opts = {}, journey) {
117
132
  const source = await resolveDocsSource(opts);
118
133
  if (source.mode === 'reference' && source.reference) {
119
134
  console.log(`\nComparing the reference against testforge.md: ${source.reference}`);
@@ -128,14 +143,14 @@ export async function runDiscover(srcDir, docsDir, opts = {}) {
128
143
  if (!ok) {
129
144
  console.log('Basing docs on testforge.md instead.\n');
130
145
  saveSourceChoice({ mode: 'map' });
131
- return runCodeDiscover(srcDir, docsDir);
146
+ return runCodeDiscover(srcDir, docsDir, journey);
132
147
  }
133
148
  }
134
149
  console.log('\nDrafting docs from the reference...\n');
135
- const out = draftDocsFromReference(srcDir, docsDir, source.reference);
150
+ const out = draftDocsFromReference(srcDir, docsDir, source.reference, journey);
136
151
  saveSourceChoice({ mode: 'reference', reference: source.reference });
137
152
  return out;
138
153
  }
139
154
  saveSourceChoice({ mode: 'map' });
140
- return runCodeDiscover(srcDir, docsDir);
155
+ return runCodeDiscover(srcDir, docsDir, journey);
141
156
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * testforge — shared doc-generation guidance.
3
+ *
4
+ * The journey doc is the source of truth for the generated tests, so its
5
+ * depth is the ceiling on test quality. This wording is shared by both
6
+ * discover prompts (code-based and reference-based) so a doc is deep and
7
+ * well-sectioned regardless of where it is drafted from.
8
+ */
9
+ export const DEEP_DOC_GUIDANCE = [
10
+ `Make each doc DEEP and exhaustive — the generated tests can only be as`,
11
+ `good as this doc, so do NOT stop at a thin outline. For the journey,`,
12
+ `document, where they apply:`,
13
+ `- every distinct sub-flow and lifecycle STATE it can be in, and the`,
14
+ ` transitions between them (not just the happy path);`,
15
+ `- each endpoint/screen involved, with real inputs, outputs, status`,
16
+ ` codes, and side effects;`,
17
+ `- edge cases, race conditions, idempotency, retries, webhooks and`,
18
+ ` signature checks, refunds/reversals, permissions/auth, and any`,
19
+ ` money/amount CALCULATIONS — with the actual rules and formulas, not`,
20
+ ` vague mentions;`,
21
+ `- concrete acceptance criteria per case (exactly what must hold).`,
22
+ `Aim for the depth of a spec a new engineer could implement from.`,
23
+ `Organise the body with numbered "## N. <Section>" headings, ONE`,
24
+ `concern per section, because each section later becomes its own`,
25
+ `focused test file — so the richer and better-separated the sections,`,
26
+ `the better the test suite.`,
27
+ ].join('\n');
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import { execFileSync } from 'node:child_process';
25
25
  import { loadConfig, splitCommand, withReferenceTools } from './config.js';
26
+ import { DEEP_DOC_GUIDANCE } from './doc-guidance.js';
26
27
  /** Where the comparison report is written. */
27
28
  export const DIFF_PATH = 'testforge.diff.md';
28
29
  const MAP_PATH = 'testforge.md';
@@ -87,7 +88,15 @@ export function buildAnalyzePrompt(reference, docsDir) {
87
88
  * treating it as the source of truth for behavior. Mirrors the code-based
88
89
  * discover format so the docs are interchangeable downstream.
89
90
  */
90
- export function buildReferenceDiscoverPrompt(srcDir, docsDir, reference) {
91
+ export function buildReferenceDiscoverPrompt(srcDir, docsDir, reference, journey) {
92
+ const scope = journey
93
+ ? [
94
+ `SCOPE: document ONLY the single journey matching "${journey}"`,
95
+ `(match by name, or by "area/name"). Create just that ONE file and`,
96
+ `ignore every other journey the reference describes.`,
97
+ ``,
98
+ ]
99
+ : [`Document EVERY journey the reference describes (one file each).`, ``];
91
100
  return [
92
101
  `You are a senior QA engineer creating per-journey documentation so a`,
93
102
  `QA tool can generate tests from it. The developer has chosen an`,
@@ -96,9 +105,14 @@ export function buildReferenceDiscoverPrompt(srcDir, docsDir, reference) {
96
105
  `does something different, document the reference's expected behavior`,
97
106
  `(the code may be buggy — that is exactly what these tests must catch).`,
98
107
  ``,
108
+ ...scope,
109
+ DEEP_DOC_GUIDANCE,
110
+ ``,
99
111
  `Reference: ${reference}`,
100
- `Read it with your available tools (read the file, fetch the URL/page).`,
101
- `The comparison you need is already in "${DIFF_PATH}" — use it.`,
112
+ `Read it FULLY with your available tools (read the file, fetch the`,
113
+ `URL/page — follow sub-pages/links the reference points to). Do not`,
114
+ `summarise shallowly; pull out every flow, rule, state and edge case`,
115
+ `it describes. The comparison you need is already in "${DIFF_PATH}".`,
102
116
  ``,
103
117
  `Use the project map at "${MAP_PATH}" and the source under "${srcDir}"`,
104
118
  `ONLY for TECHNICAL detail the reference does not spell out: routes,`,
@@ -157,6 +171,6 @@ export function analyzeReference(reference, docsDir) {
157
171
  * Draft the journey docs from the reference. Returns the AI's summary of
158
172
  * the journeys it documented.
159
173
  */
160
- export function draftDocsFromReference(srcDir, docsDir, reference) {
161
- return runAI(buildReferenceDiscoverPrompt(srcDir, docsDir, reference));
174
+ export function draftDocsFromReference(srcDir, docsDir, reference, journey) {
175
+ return runAI(buildReferenceDiscoverPrompt(srcDir, docsDir, reference, journey));
162
176
  }
@@ -21,15 +21,53 @@ import { join, dirname } from 'node:path';
21
21
  import { parseJourney } from './parser.js';
22
22
  import { generateTestCases } from './generator.js';
23
23
  import { writePlaywrightFile } from './playwright-writer.js';
24
- import { generateWithAI } from './ai-generator.js';
24
+ import { generateWithAI, generateWithAIAsync } from './ai-generator.js';
25
25
  import { generateSeed } from './seed-generator.js';
26
+ import { planSuite } from './suite-planner.js';
26
27
  import { isSyntaxValid } from './validate.js';
27
28
  import { hashContent, loadCache, saveCache } from './cache.js';
28
29
  import { findMarkdownFiles } from './find-files.js';
29
- import { loadConfig, testDirForType } from './config.js';
30
+ import { loadConfig, testDirForType, resolveFramework } from './config.js';
30
31
  const CACHE_PATH = '.testforge/cache.json';
31
32
  /** How many times to re-ask the AI when output won't parse. */
32
33
  const MAX_GEN_ATTEMPTS = 2;
34
+ /** How many focused files to generate concurrently within a journey. */
35
+ const GEN_CONCURRENCY = 4;
36
+ /**
37
+ * Run async tasks with a bounded concurrency, preserving input order in
38
+ * the results. Keeps a heavy fan-out (15 AI calls) from spawning 15
39
+ * processes at once while still finishing in a few waves, not serially.
40
+ */
41
+ async function pool(items, limit, fn) {
42
+ const results = new Array(items.length);
43
+ let next = 0;
44
+ async function worker() {
45
+ for (;;) {
46
+ const i = next++;
47
+ if (i >= items.length)
48
+ return;
49
+ results[i] = await fn(items[i], i);
50
+ }
51
+ }
52
+ const n = Math.max(1, Math.min(limit, items.length));
53
+ await Promise.all(Array.from({ length: n }, () => worker()));
54
+ return results;
55
+ }
56
+ /** Async version of generateValidated (for concurrent per-file gen). */
57
+ async function generateValidatedAsync(generate, ext) {
58
+ let content = '';
59
+ for (let attempt = 0; attempt < MAX_GEN_ATTEMPTS; attempt++) {
60
+ try {
61
+ content = await generate();
62
+ }
63
+ catch {
64
+ continue;
65
+ }
66
+ if (isSyntaxValid(content, ext))
67
+ return content;
68
+ }
69
+ return null;
70
+ }
33
71
  /**
34
72
  * Run an AI generator, cleaning + syntax-gating its output, retrying a
35
73
  * couple of times on malformed results. Returns the valid content, or
@@ -56,9 +94,9 @@ function generateValidated(generate, ext) {
56
94
  * syntax gate + retry) and falls back to the deterministic basic
57
95
  * generator if AI output can't be made valid.
58
96
  */
59
- function buildTest(journey, useAI, ext, testPath) {
97
+ function buildTest(journey, useAI, ext, testPath, focus) {
60
98
  if (useAI) {
61
- const ai = generateValidated(() => generateWithAI(journey, testPath), ext);
99
+ const ai = generateValidated(() => generateWithAI(journey, testPath, focus), ext);
62
100
  if (ai !== null) {
63
101
  return { content: ai, status: 'ai' };
64
102
  }
@@ -101,7 +139,17 @@ function collisionWarning(testDir, id, ext) {
101
139
  return null;
102
140
  return `an existing test with different naming already covers this journey: ${clashes.join(', ')} — new file may duplicate it`;
103
141
  }
104
- export function runGenerate(docsDir, useAI = true, force = false) {
142
+ /** Does this doc file / journey match a --journey selector? */
143
+ function matchesJourney(file, journeyId, sel) {
144
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-');
145
+ const n = norm(sel);
146
+ const stem = file.replace(/\.md$/, '');
147
+ return (journeyId === n ||
148
+ norm(stem) === n ||
149
+ norm(stem).endsWith(`-${n}`) ||
150
+ norm(stem).split('/').pop() === n);
151
+ }
152
+ export async function runGenerate(docsDir, useAI = true, force = false, only) {
105
153
  const files = findMarkdownFiles(docsDir);
106
154
  if (files.length === 0) {
107
155
  return [];
@@ -121,15 +169,27 @@ export function runGenerate(docsDir, useAI = true, force = false) {
121
169
  // journeys in their own package instead of leaking into web/api.
122
170
  const area = file.split(/[\\/]/)[0] || undefined;
123
171
  const journey = parseJourney(markdown, area);
124
- // Where this journey's test and seed go.
125
- const testDir = testDirForType(config, journey.area);
126
- const testPath = join(testDir, `${journey.id}${ext}`);
172
+ // --journey <x>: generate just the one matching journey.
173
+ if (only && !matchesJourney(file, journey.id, only))
174
+ continue;
175
+ // Layout depends on the framework's test runner:
176
+ // - Playwright (web/ops) recurses, so each journey gets its own
177
+ // FOLDER of focused specs (testDir/<journey>/<slug>).
178
+ // - Jest is often pointed at a flat glob (e.g. the-seam's
179
+ // `**/service/*.test.js`, which does NOT recurse), so api/lambda
180
+ // files stay FLAT in the test dir, journey-prefixed to stay unique
181
+ // (testDir/<journey>-<slug>). Otherwise they'd silently not run.
182
+ const areaDir = testDirForType(config, journey.area);
183
+ const framework = resolveFramework(config, journey.area, journey.type);
184
+ const flat = framework === 'jest';
185
+ const testDir = flat ? areaDir : join(areaDir, journey.id);
127
186
  const seedPath = join(seedDir, `${journey.id}${seedExt}`);
128
187
  // Skip if unchanged.
129
188
  if (!force && cache[file] === hash) {
130
189
  results.push({
131
190
  journeyName: journey.name,
132
- testPath,
191
+ testDir,
192
+ testFiles: [],
133
193
  seedPath,
134
194
  status: 'skipped',
135
195
  seedStatus: 'none',
@@ -137,13 +197,56 @@ export function runGenerate(docsDir, useAI = true, force = false) {
137
197
  continue;
138
198
  }
139
199
  const warnings = [];
140
- const collision = collisionWarning(testDir, journey.id, ext);
200
+ const collision = collisionWarning(areaDir, journey.id, ext);
141
201
  if (collision)
142
202
  warnings.push(collision);
143
- // 1. Test file (syntax-gated before writing).
144
- const { content, status } = buildTest(journey, useAI, ext, testPath);
145
- mkdirSync(dirname(testPath), { recursive: true });
146
- writeFileSync(testPath, content, 'utf-8');
203
+ // 1. Plan the suite (deep mode), then generate one focused file per
204
+ // concern. In basic mode there is no AI planner — one file only.
205
+ const planned = useAI
206
+ ? planSuite(journey)
207
+ : [{ slug: journey.id, title: journey.name, covers: '' }];
208
+ // Generate all focused files CONCURRENTLY (bounded pool). Each AI
209
+ // call is independent, so a 15-file journey finishes in ~4 waves
210
+ // instead of 15 serial calls.
211
+ const generated = await pool(planned, GEN_CONCURRENCY, async (pf) => {
212
+ // Flat (jest): prefix the file with the journey so many journeys can
213
+ // share one dir without clashing. Folder (playwright): the journey
214
+ // folder already namespaces it, so the slug alone is enough.
215
+ const base = flat ? `${journey.id}-${pf.slug}` : pf.slug;
216
+ const filePath = join(testDir, `${base}${ext}`);
217
+ let content;
218
+ let status;
219
+ if (useAI) {
220
+ const ai = await generateValidatedAsync(() => generateWithAIAsync(journey, filePath, {
221
+ title: pf.title,
222
+ covers: pf.covers,
223
+ }), ext);
224
+ if (ai !== null) {
225
+ content = ai;
226
+ status = 'ai';
227
+ }
228
+ else {
229
+ const built = buildTest(journey, false, ext, filePath);
230
+ content = built.content;
231
+ status = 'basic';
232
+ }
233
+ }
234
+ else {
235
+ const built = buildTest(journey, false, ext, filePath);
236
+ content = built.content;
237
+ status = 'basic';
238
+ }
239
+ return { slug: pf.slug, path: filePath, content, status };
240
+ });
241
+ const testFiles = [];
242
+ for (const g of generated) {
243
+ mkdirSync(dirname(g.path), { recursive: true });
244
+ writeFileSync(g.path, g.content, 'utf-8');
245
+ testFiles.push({ slug: g.slug, path: g.path, status: g.status });
246
+ }
247
+ const anyAI = testFiles.some((t) => t.status === 'ai');
248
+ const anyBasic = testFiles.some((t) => t.status === 'basic');
249
+ const status = anyAI || !anyBasic ? 'ai' : 'basic';
147
250
  // 2. Seed file (only when using AI — basic mode has no seed concept).
148
251
  let seedStatus = 'none';
149
252
  if (useAI) {
@@ -163,7 +266,8 @@ export function runGenerate(docsDir, useAI = true, force = false) {
163
266
  cache[file] = hash;
164
267
  results.push({
165
268
  journeyName: journey.name,
166
- testPath,
269
+ testDir,
270
+ testFiles,
167
271
  seedPath,
168
272
  status,
169
273
  seedStatus,
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { execFileSync } from 'node:child_process';
18
18
  import { readFileSync, existsSync } from 'node:fs';
19
- import { loadConfig, splitCommand, withTextOnlyTools } from './config.js';
19
+ import { loadConfig, splitCommand, withReadOnlyTools } from './config.js';
20
20
  import { cleanOutput } from './ai-generator.js';
21
21
  const MAP_PATH = 'testforge.md';
22
22
  /** Build the seed-generation prompt for one journey. */
@@ -31,11 +31,15 @@ export function buildSeedPrompt(journeyDoc, seedPath) {
31
31
  `You are a senior SDET writing a TEST DATA SEED for one feature,`,
32
32
  `so its tests have a known, valid starting state in the database.`,
33
33
  ``,
34
- `Use the project map below to understand the data layer — the`,
35
- `database type, the models/collections, their key fields, and the`,
36
- `allowed enum values. Build records that are VALID against that`,
37
- `schema (right collection/table, right fields, right enum values,`,
38
- `correct relationships between records).`,
34
+ `Use the project map below to understand the data layer. You are`,
35
+ `running inside the repository with read-only tools (Read, Grep, Glob,`,
36
+ `LS): READ THE ACTUAL MODEL/SCHEMA FILES for the collections this`,
37
+ `feature uses (grep for the model names) and build records that are`,
38
+ `VALID against the real schema — right collection/table, right field`,
39
+ `names and types, real required fields, real enum values, and correct`,
40
+ `relationships between records. Do not guess a field that a quick read`,
41
+ `of the model would confirm. Do NOT write or edit any file; output only`,
42
+ `the seed code to stdout.`,
39
43
  ``,
40
44
  `Requirements for the seed you write:`,
41
45
  `- Connect using the env var named in the map (e.g.`,
@@ -78,9 +82,9 @@ export function buildSeedPrompt(journeyDoc, seedPath) {
78
82
  /** Generate a seed file's content for one journey doc, via the AI. */
79
83
  export function generateSeed(journeyDoc, seedPath) {
80
84
  const prompt = buildSeedPrompt(journeyDoc, seedPath);
81
- // Pure text — no tools, so the model prints the seed instead of
82
- // attempting to write it (which leaks approval prose into the output).
83
- const { program, args } = withTextOnlyTools(splitCommand(loadConfig().aiCommand));
85
+ // Allow read-only exploration so the seed matches the REAL schema
86
+ // (models/collections/enums) read from the code; Write stays denied.
87
+ const { program, args } = withReadOnlyTools(splitCommand(loadConfig().aiCommand));
84
88
  const output = execFileSync(program, args, {
85
89
  input: prompt,
86
90
  encoding: 'utf-8',
@@ -0,0 +1,130 @@
1
+ /**
2
+ * testforge — suite planner (deep mode).
3
+ *
4
+ * Instead of one monolithic test file per journey, we first ask the AI
5
+ * to DECOMPOSE a journey into a set of focused test files — one concern
6
+ * each — exactly like a human-authored suite (e.g. a payment flow splits
7
+ * into success / failures / discount / 3ds / zero-amount / ... files).
8
+ *
9
+ * The split is driven by the journey doc's own sections and the REAL
10
+ * endpoints/behaviours the AI reads from the source (read-only tools).
11
+ * Depth scales with the doc: a thin journey yields 1–2 files, a rich
12
+ * one yields many. We never force a fixed count.
13
+ *
14
+ * The planner returns structured data (a list of files), NOT code — the
15
+ * per-file generator turns each entry into an actual focused test.
16
+ */
17
+ import { execFileSync } from 'node:child_process';
18
+ import { readFileSync, existsSync } from 'node:fs';
19
+ import { loadConfig, splitCommand, withReadOnlyTools } from './config.js';
20
+ const MAP_PATH = 'testforge.md';
21
+ /** Build the planning prompt for one journey. */
22
+ function buildPlanPrompt(journey, map) {
23
+ return [
24
+ `You are a senior test architect. Plan how to split ONE user journey`,
25
+ `into a set of FOCUSED test files — one concern per file — the way a`,
26
+ `careful human would (e.g. a payment flow becomes separate files for`,
27
+ `success, failures, discount, 3D-Secure, zero-amount, price summary,`,
28
+ `and so on). Do NOT plan a single monolithic file.`,
29
+ ``,
30
+ `You are inside the project's repository with read-only tools (Read,`,
31
+ `Grep, Glob, LS). READ the real source for this journey — routes,`,
32
+ `controllers, services, models — and combine it with the journey doc`,
33
+ `to decide the split. Every file must map to a real behaviour that`,
34
+ `exists in the code or is explicitly described in the doc.`,
35
+ ``,
36
+ `Rules:`,
37
+ `- Let the doc's sections and the real endpoints drive the split.`,
38
+ `- Depth SCALES with the journey: a small/simple journey may need just`,
39
+ ` 1–2 files; a rich one (many endpoints, edge cases, webhooks, cron,`,
40
+ ` refunds) may need many. Do not pad, do not over-split, do not force`,
41
+ ` a fixed number.`,
42
+ `- Each file covers ONE concern and must be independently meaningful.`,
43
+ ``,
44
+ `Output ONLY a JSON array (no prose, no markdown fences) of objects:`,
45
+ ` [{ "slug": "<short-kebab-name>", "title": "<describe title>",`,
46
+ ` "covers": "<one or two sentences: exactly what this file tests>" }]`,
47
+ `slug: lowercase, words joined by "-", no path and no file extension.`,
48
+ `The very first character of your response must be "[".`,
49
+ ``,
50
+ `--- PROJECT MAP (data layer) ---`,
51
+ map,
52
+ ``,
53
+ `--- JOURNEY ---`,
54
+ journey.rawMarkdown,
55
+ ].join('\n');
56
+ }
57
+ /**
58
+ * Pull a JSON array out of the AI's reply, tolerating stray prose or
59
+ * code fences around it. Returns [] if nothing parseable is found.
60
+ */
61
+ function extractJsonArray(text) {
62
+ const start = text.indexOf('[');
63
+ const end = text.lastIndexOf(']');
64
+ if (start === -1 || end === -1 || end < start)
65
+ return [];
66
+ try {
67
+ const parsed = JSON.parse(text.slice(start, end + 1));
68
+ return Array.isArray(parsed) ? parsed : [];
69
+ }
70
+ catch {
71
+ return [];
72
+ }
73
+ }
74
+ /** Normalise a raw slug into a safe kebab-case file stem. */
75
+ function safeSlug(raw) {
76
+ return String(raw ?? '')
77
+ .trim()
78
+ .toLowerCase()
79
+ .replace(/\.(spec|test)\.[jt]sx?$/, '') // drop any extension the AI added
80
+ .replace(/[^a-z0-9]+/g, '-')
81
+ .replace(/^-|-$/g, '');
82
+ }
83
+ /**
84
+ * Plan the suite for one journey: returns the list of focused files to
85
+ * generate. Falls back to a single whole-journey file when the planner
86
+ * produces nothing usable, so generation never silently yields zero
87
+ * files.
88
+ */
89
+ export function planSuite(journey) {
90
+ const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
91
+ const prompt = buildPlanPrompt(journey, map);
92
+ const { program, args } = withReadOnlyTools(splitCommand(loadConfig().aiCommand));
93
+ let raw = '';
94
+ try {
95
+ raw = execFileSync(program, args, {
96
+ input: prompt,
97
+ encoding: 'utf-8',
98
+ maxBuffer: 10 * 1024 * 1024,
99
+ });
100
+ }
101
+ catch {
102
+ raw = '';
103
+ }
104
+ const seen = new Set();
105
+ const files = [];
106
+ for (const entry of extractJsonArray(raw)) {
107
+ if (!entry || typeof entry !== 'object')
108
+ continue;
109
+ const e = entry;
110
+ const slug = safeSlug(e.slug ?? e.title);
111
+ if (!slug || seen.has(slug))
112
+ continue;
113
+ seen.add(slug);
114
+ files.push({
115
+ slug,
116
+ title: String(e.title ?? journey.name).trim() || journey.name,
117
+ covers: String(e.covers ?? '').trim(),
118
+ });
119
+ }
120
+ if (files.length === 0) {
121
+ // Planner failed or returned junk — degrade to a single file for the
122
+ // whole journey rather than generating nothing.
123
+ files.push({
124
+ slug: journey.id,
125
+ title: journey.name,
126
+ covers: 'The entire journey as described in the doc.',
127
+ });
128
+ }
129
+ return files;
130
+ }
@@ -41,7 +41,7 @@ export async function runUpdate(srcDir) {
41
41
  await runDiscover(srcDir, 'docs', { noPrompt: true });
42
42
  console.log(' done — docs/\n');
43
43
  console.log('Regenerating tests for changed journeys...');
44
- const results = runGenerate('docs', true, false);
44
+ const results = await runGenerate('docs', true, false);
45
45
  const made = results.filter((r) => r.status !== 'skipped').length;
46
46
  const skipped = results.filter((r) => r.status === 'skipped').length;
47
47
  console.log(` done — ${made} updated, ${skipped} unchanged.\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@activate-core/test-forge",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "AI-powered QA automation. Scans a project, drafts journey docs, and generates seeded Jest/Playwright tests. Works in any project, with any AI CLI.",
5
5
  "type": "module",
6
6
  "bin": {