@activate-core/test-forge 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,20 +10,49 @@
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 } 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
- const fw = config.framework?.[journey.type];
21
- const isApi = journey.type === 'api' || fw === 'jest' || fw === 'supertest';
22
- const framework = fw
23
- ? `${fw} for a "${journey.type}" test`
24
- : isApi
25
- ? 'Jest (with supertest for HTTP) for an API/backend test'
26
- : 'an appropriate test framework for this kind of journey';
44
+ // Opinionated: testforge only generates Jest (api/backend) or Playwright
45
+ // (web/UI) tests. We look the framework up by AREA, but clamp anything
46
+ // that is not jest/supertest/playwright (e.g. a project's cypress setup)
47
+ // to the sensible default for the journey's Type — so no other framework
48
+ // ever leaks into generated output.
49
+ const configured = (config.framework?.[journey.area] ?? '').toLowerCase();
50
+ const isApi = configured === 'jest' ||
51
+ configured === 'supertest' ||
52
+ (configured !== 'playwright' && journey.type === 'api');
53
+ const framework = isApi
54
+ ? 'Jest (with supertest for HTTP) for an API/backend test'
55
+ : 'Playwright for a web/UI test';
27
56
  const ext = config.testExtension ?? '.test.ts';
28
57
  const isJs = ext.endsWith('.js');
29
58
  const language = isJs
@@ -33,6 +62,19 @@ export function buildPrompt(journey, map, testPath) {
33
62
  `You are a senior QA engineer. Generate a complete ${framework}`,
34
63
  `for the user journey described below.`,
35
64
  ``,
65
+ ...(focus
66
+ ? [
67
+ `SCOPE — this is ONE file in a larger suite for this journey.`,
68
+ `Cover ONLY this concern in this file:`,
69
+ ` ${focus.title}`,
70
+ ` ${focus.covers}`,
71
+ `Do NOT test the journey's other concerns here — separate files`,
72
+ `cover those. Go DEEP on this one concern (happy path + its own`,
73
+ `edge/negative/security cases) rather than broad-but-shallow.`,
74
+ `Use this exact describe() title: "${focus.title}".`,
75
+ ``,
76
+ ]
77
+ : []),
36
78
  `Think like an experienced tester whose goal is to break the`,
37
79
  `application. Cover, where relevant: happy path, missing/empty`,
38
80
  `required fields, invalid formats and boundary values (very long,`,
@@ -52,6 +94,19 @@ export function buildPrompt(journey, map, testPath) {
52
94
  `guard is missing and produce a failing test that falsely brands the`,
53
95
  `product as vulnerable.`,
54
96
  ``,
97
+ `GROUND EVERYTHING IN THE REAL SOURCE. You are running inside the`,
98
+ `project's repository and have read-only tools (Read, Grep, Glob, LS).`,
99
+ `BEFORE writing tests, look up the actual code for this journey — use`,
100
+ `the route/endpoint named below to grep for the router, then read the`,
101
+ `route file, its controller/handler, the service it calls, and the`,
102
+ `models it touches. Confirm the REAL endpoint paths, HTTP methods,`,
103
+ `request/response shapes, status codes, auth requirements, and function`,
104
+ `signatures from the code. Base every assertion on what the code`,
105
+ `actually does. Only fall back to "// TODO:" or test.todo/it.skip when`,
106
+ `the behavior genuinely cannot be determined from the source — do not`,
107
+ `leave something as a TODO that a quick read of the code would answer.`,
108
+ `Do NOT write or edit any file; output only the test code to stdout.`,
109
+ ``,
55
110
  isApi
56
111
  ? [
57
112
  `Because this is an API journey, write Jest tests that call the`,
@@ -117,12 +172,13 @@ export function buildPrompt(journey, map, testPath) {
117
172
  * journey. Reads the project map (testforge.md) for data-layer
118
173
  * context. Returns the test file content.
119
174
  */
120
- export function generateWithAI(journey, testPath) {
175
+ export function generateWithAI(journey, testPath, focus) {
121
176
  const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
122
- const prompt = buildPrompt(journey, map, testPath);
123
- // Generation is pure text — run with NO tools so the model just
124
- // prints code instead of trying to write files.
125
- const { program, args } = withTextOnlyTools(splitCommand(loadConfig().aiCommand));
177
+ const prompt = buildPrompt(journey, map, testPath, focus);
178
+ // Allow read-only exploration so the model grounds the test in the
179
+ // real source (routes/controllers/models); Write stays denied, so it
180
+ // still can't create files — it reads, then prints the code.
181
+ const { program, args } = withReadOnlyTools(splitCommand(loadConfig().aiCommand));
126
182
  const output = execFileSync(program, args, {
127
183
  input: prompt,
128
184
  encoding: 'utf-8',
@@ -130,6 +186,18 @@ export function generateWithAI(journey, testPath) {
130
186
  });
131
187
  return cleanOutput(output);
132
188
  }
189
+ /**
190
+ * Async twin of generateWithAI, so multiple focused files in one
191
+ * journey's suite can be generated concurrently (each spawns its own
192
+ * `claude` process). Same prompt, same read-only exploration.
193
+ */
194
+ export async function generateWithAIAsync(journey, testPath, focus) {
195
+ const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
196
+ const prompt = buildPrompt(journey, map, testPath, focus);
197
+ const { program, args } = withReadOnlyTools(splitCommand(loadConfig().aiCommand));
198
+ const stdout = await runWithStdin(program, args, prompt);
199
+ return cleanOutput(stdout);
200
+ }
133
201
  /** Does this line look like the start (or body) of real code? */
134
202
  function looksLikeCode(line) {
135
203
  const t = line.trim();
@@ -115,6 +115,27 @@ export function withTextOnlyTools(cmd) {
115
115
  return cmd;
116
116
  return { program: cmd.program, args: [...cmd.args, '--allowedTools', ''] };
117
117
  }
118
+ /**
119
+ * Args for a generation call that should EXPLORE the real source before
120
+ * writing — read-only tools (Read/Grep/Glob/LS) are allowed so the model
121
+ * can confirm actual endpoint paths, request/response shapes, and model
122
+ * schemas from the code, but Write/Edit/Bash stay OUT of the allowlist so
123
+ * it still can't create files or run commands. The model reads, then
124
+ * prints the test/seed code to stdout.
125
+ *
126
+ * This is the key to tests grounded in the real code instead of
127
+ * assumptions: without source access the model leaves "endpoint shape
128
+ * unconfirmed" TODOs; with it, those become real assertions.
129
+ */
130
+ export function withReadOnlyTools(cmd) {
131
+ if (!isClaude(cmd.program))
132
+ return cmd;
133
+ return {
134
+ program: cmd.program,
135
+ // Space-separated allowlist; Write/Edit/Bash deliberately excluded.
136
+ args: [...cmd.args, '--allowedTools', 'Read Grep Glob LS'],
137
+ };
138
+ }
118
139
  /**
119
140
  * Args for a phase where the model must read/write files (init, map,
120
141
  * 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');
@@ -4,4 +4,4 @@
4
4
  * Currently exposes the package version only.
5
5
  * Model, parser, and engines will be added in upcoming steps.
6
6
  */
7
- export const VERSION = '0.3.1';
7
+ export const VERSION = '0.4.0';
package/dist/core/init.js CHANGED
@@ -29,10 +29,17 @@ export function buildInitPrompt() {
29
29
  ` Point each to where that area's tests belong (e.g. in a monorepo,`,
30
30
  ` api -> "packages/api/test", web -> "packages/webapp/tests", ops ->`,
31
31
  ` "packages/ops/tests"; in a simple app, one area mapping to "tests").`,
32
- `- framework: an object mapping each area to its test framework`,
33
- ` (e.g. { "api": "jest", "web": "playwright", "ops": "playwright" }),`,
34
- ` based on what the project already uses for that area.`,
35
- ` the api tests, e.g. "packages/api/test/helpers/seed").`,
32
+ ` For UI areas, use a plain tests folder (e.g. ".../tests" or`,
33
+ ` ".../tests/e2e") — do NOT point them at a Cypress folder such as`,
34
+ ` "cypress/integration", because testforge does not generate Cypress.`,
35
+ `- framework: an object mapping each area to its test framework.`,
36
+ ` testforge ONLY generates two kinds of tests, so each value MUST be`,
37
+ ` exactly "jest" or "playwright" — never "cypress", "mocha", "vitest",`,
38
+ ` or anything else, EVEN IF the project currently uses one of those.`,
39
+ ` Choose the closest fit: backend / API / serverless / lambda / worker`,
40
+ ` areas -> "jest"; UI / web / browser / admin-panel areas ->`,
41
+ ` "playwright". Example: { "api": "jest", "web": "playwright", "ops":`,
42
+ ` "playwright", "lambda": "jest" }.`,
36
43
  `- testExtension: ".test.ts" or ".test.js" — match what existing`,
37
44
  ` tests use, or the project's language (TypeScript vs JavaScript).`,
38
45
  `- aiCommand: keep it as "claude -p" unless there is a clear reason`,
@@ -65,8 +65,14 @@ function parseField(line) {
65
65
  const required = parts.includes('required');
66
66
  return { name, type, required };
67
67
  }
68
- /** Parse a full markdown string into a Journey object. */
69
- export function parseJourney(markdown) {
68
+ /**
69
+ * Parse a full markdown string into a Journey object.
70
+ *
71
+ * `area` is the journey's docs subfolder (e.g. "api", "ops"), which drives
72
+ * output location and framework. When omitted (a journey read outside an
73
+ * area folder), it falls back to the parsed `Type:`.
74
+ */
75
+ export function parseJourney(markdown, area) {
70
76
  const lines = markdown.split('\n').map((l) => l.trim());
71
77
  let name = '';
72
78
  let type = 'web';
@@ -112,6 +118,7 @@ export function parseJourney(markdown) {
112
118
  return {
113
119
  id: toId(name),
114
120
  name,
121
+ area: area ?? type,
115
122
  type,
116
123
  route,
117
124
  coverage,
@@ -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,8 +21,9 @@ 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';
@@ -30,6 +31,43 @@ import { loadConfig, testDirForType } 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,10 +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(markdown, useAI, ext, testPath) {
60
- const journey = parseJourney(markdown);
97
+ function buildTest(journey, useAI, ext, testPath, focus) {
61
98
  if (useAI) {
62
- const ai = generateValidated(() => generateWithAI(journey, testPath), ext);
99
+ const ai = generateValidated(() => generateWithAI(journey, testPath, focus), ext);
63
100
  if (ai !== null) {
64
101
  return { content: ai, status: 'ai' };
65
102
  }
@@ -102,7 +139,17 @@ function collisionWarning(testDir, id, ext) {
102
139
  return null;
103
140
  return `an existing test with different naming already covers this journey: ${clashes.join(', ')} — new file may duplicate it`;
104
141
  }
105
- 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) {
106
153
  const files = findMarkdownFiles(docsDir);
107
154
  if (files.length === 0) {
108
155
  return [];
@@ -116,16 +163,25 @@ export function runGenerate(docsDir, useAI = true, force = false) {
116
163
  for (const file of files) {
117
164
  const markdown = readFileSync(join(docsDir, file), 'utf-8');
118
165
  const hash = hashContent(markdown);
119
- const journey = parseJourney(markdown);
120
- // Where this journey's test and seed go.
121
- const testDir = testDirForType(config, journey.type);
122
- const testPath = join(testDir, `${journey.id}${ext}`);
166
+ // The area is the doc's top-level folder (api/web/ops/lambda/...). It
167
+ // decides where the test goes and which framework is used — NOT the
168
+ // "Type:" line, which only says UI-vs-endpoint. This keeps ops/lambda
169
+ // journeys in their own package instead of leaking into web/api.
170
+ const area = file.split(/[\\/]/)[0] || undefined;
171
+ const journey = parseJourney(markdown, area);
172
+ // --journey <x>: generate just the one matching journey.
173
+ if (only && !matchesJourney(file, journey.id, only))
174
+ continue;
175
+ // Deep mode: each journey becomes a FOLDER of focused test files.
176
+ const areaDir = testDirForType(config, journey.area);
177
+ const testDir = join(areaDir, journey.id);
123
178
  const seedPath = join(seedDir, `${journey.id}${seedExt}`);
124
179
  // Skip if unchanged.
125
180
  if (!force && cache[file] === hash) {
126
181
  results.push({
127
182
  journeyName: journey.name,
128
- testPath,
183
+ testDir,
184
+ testFiles: [],
129
185
  seedPath,
130
186
  status: 'skipped',
131
187
  seedStatus: 'none',
@@ -133,13 +189,52 @@ export function runGenerate(docsDir, useAI = true, force = false) {
133
189
  continue;
134
190
  }
135
191
  const warnings = [];
136
- const collision = collisionWarning(testDir, journey.id, ext);
192
+ const collision = collisionWarning(areaDir, journey.id, ext);
137
193
  if (collision)
138
194
  warnings.push(collision);
139
- // 1. Test file (syntax-gated before writing).
140
- const { content, status } = buildTest(markdown, useAI, ext, testPath);
141
- mkdirSync(dirname(testPath), { recursive: true });
142
- writeFileSync(testPath, content, 'utf-8');
195
+ // 1. Plan the suite (deep mode), then generate one focused file per
196
+ // concern. In basic mode there is no AI planner — one file only.
197
+ const planned = useAI
198
+ ? planSuite(journey)
199
+ : [{ slug: journey.id, title: journey.name, covers: '' }];
200
+ // Generate all focused files CONCURRENTLY (bounded pool). Each AI
201
+ // call is independent, so a 15-file journey finishes in ~4 waves
202
+ // instead of 15 serial calls.
203
+ const generated = await pool(planned, GEN_CONCURRENCY, async (pf) => {
204
+ const filePath = join(testDir, `${pf.slug}${ext}`);
205
+ let content;
206
+ let status;
207
+ if (useAI) {
208
+ const ai = await generateValidatedAsync(() => generateWithAIAsync(journey, filePath, {
209
+ title: pf.title,
210
+ covers: pf.covers,
211
+ }), ext);
212
+ if (ai !== null) {
213
+ content = ai;
214
+ status = 'ai';
215
+ }
216
+ else {
217
+ const built = buildTest(journey, false, ext, filePath);
218
+ content = built.content;
219
+ status = 'basic';
220
+ }
221
+ }
222
+ else {
223
+ const built = buildTest(journey, false, ext, filePath);
224
+ content = built.content;
225
+ status = 'basic';
226
+ }
227
+ return { slug: pf.slug, path: filePath, content, status };
228
+ });
229
+ const testFiles = [];
230
+ for (const g of generated) {
231
+ mkdirSync(dirname(g.path), { recursive: true });
232
+ writeFileSync(g.path, g.content, 'utf-8');
233
+ testFiles.push({ slug: g.slug, path: g.path, status: g.status });
234
+ }
235
+ const anyAI = testFiles.some((t) => t.status === 'ai');
236
+ const anyBasic = testFiles.some((t) => t.status === 'basic');
237
+ const status = anyAI || !anyBasic ? 'ai' : 'basic';
143
238
  // 2. Seed file (only when using AI — basic mode has no seed concept).
144
239
  let seedStatus = 'none';
145
240
  if (useAI) {
@@ -159,7 +254,8 @@ export function runGenerate(docsDir, useAI = true, force = false) {
159
254
  cache[file] = hash;
160
255
  results.push({
161
256
  journeyName: journey.name,
162
- testPath,
257
+ testDir,
258
+ testFiles,
163
259
  seedPath,
164
260
  status,
165
261
  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.3.1",
3
+ "version": "0.5.0",
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": {