@activate-core/test-forge 0.1.1 → 0.3.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/README.md +58 -2
- package/dist/cli/index.js +107 -62
- package/dist/core/ai-generator.js +79 -26
- package/dist/core/config.js +80 -5
- package/dist/core/discover.js +45 -7
- package/dist/core/env-template.js +91 -5
- package/dist/core/index.js +1 -1
- package/dist/core/init.js +3 -3
- package/dist/core/map.js +3 -3
- package/dist/core/prompt.js +70 -0
- package/dist/core/reference.js +162 -0
- package/dist/core/run-generate.js +98 -17
- package/dist/core/seed-generator.js +23 -10
- package/dist/core/setup.js +5 -4
- package/dist/core/source-of-truth.js +84 -0
- package/dist/core/update.js +5 -3
- package/dist/core/validate.js +82 -0
- package/package.json +1 -1
|
@@ -11,15 +11,19 @@
|
|
|
11
11
|
* Only names are collected, never values. The developer fills in real
|
|
12
12
|
* values in their own .env.testing (which stays out of version control).
|
|
13
13
|
*/
|
|
14
|
-
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
15
16
|
import { findMarkdownFiles } from './find-files.js';
|
|
16
17
|
/** Pull env var names from a chunk of text. */
|
|
17
18
|
function extractEnvVars(text) {
|
|
18
19
|
const vars = new Set();
|
|
19
|
-
// process.env.XXX
|
|
20
|
+
// process.env.XXX and process.env["XXX"] / process.env['XXX']
|
|
20
21
|
for (const m of text.matchAll(/process\.env\.([A-Z0-9_]+)/gi)) {
|
|
21
22
|
vars.add(m[1]);
|
|
22
23
|
}
|
|
24
|
+
for (const m of text.matchAll(/process\.env\[\s*['"]([A-Z0-9_]+)['"]\s*\]/gi)) {
|
|
25
|
+
vars.add(m[1]);
|
|
26
|
+
}
|
|
23
27
|
// backtick-wrapped ALL_CAPS names in "env var" lines
|
|
24
28
|
for (const line of text.split('\n')) {
|
|
25
29
|
if (/env var|secret|credential/i.test(line)) {
|
|
@@ -30,6 +34,69 @@ function extractEnvVars(text) {
|
|
|
30
34
|
}
|
|
31
35
|
return vars;
|
|
32
36
|
}
|
|
37
|
+
/** Directories we never want to scan for app env vars. */
|
|
38
|
+
const SKIP_DIRS = new Set([
|
|
39
|
+
'node_modules',
|
|
40
|
+
'.git',
|
|
41
|
+
'dist',
|
|
42
|
+
'build',
|
|
43
|
+
'coverage',
|
|
44
|
+
'docs',
|
|
45
|
+
'generated-tests',
|
|
46
|
+
'.testforge',
|
|
47
|
+
]);
|
|
48
|
+
const SOURCE_EXT = /\.(c|m)?[jt]sx?$/i;
|
|
49
|
+
/**
|
|
50
|
+
* Scan the project's application SOURCE (not just the docs) for
|
|
51
|
+
* process.env references. Docs describe features; they routinely miss
|
|
52
|
+
* boot-critical vars (Sentry DSN, Twilio keys, feature flags) that the
|
|
53
|
+
* app reads directly at startup. Without those in the example file, the
|
|
54
|
+
* server won't even boot for a fresh clone. We walk the source tree,
|
|
55
|
+
* skipping vendored/build/test output, with a file cap so a huge repo
|
|
56
|
+
* can't stall the run.
|
|
57
|
+
*/
|
|
58
|
+
function scanSourceEnvVars(srcDir, maxFiles = 4000) {
|
|
59
|
+
const vars = new Set();
|
|
60
|
+
let seen = 0;
|
|
61
|
+
const walk = (dir) => {
|
|
62
|
+
if (seen >= maxFiles)
|
|
63
|
+
return;
|
|
64
|
+
let entries;
|
|
65
|
+
try {
|
|
66
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (seen >= maxFiles)
|
|
73
|
+
return;
|
|
74
|
+
const full = join(dir, entry.name);
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.'))
|
|
77
|
+
continue;
|
|
78
|
+
walk(full);
|
|
79
|
+
}
|
|
80
|
+
else if (SOURCE_EXT.test(entry.name)) {
|
|
81
|
+
// Skip test files — we want app source, not existing tests.
|
|
82
|
+
if (/\.(test|spec)\./i.test(entry.name))
|
|
83
|
+
continue;
|
|
84
|
+
seen++;
|
|
85
|
+
try {
|
|
86
|
+
for (const v of extractEnvVars(readFileSync(full, 'utf-8'))) {
|
|
87
|
+
vars.add(v);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// unreadable file — ignore
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
if (existsSync(srcDir))
|
|
97
|
+
walk(srcDir);
|
|
98
|
+
return vars;
|
|
99
|
+
}
|
|
33
100
|
/** Derive "area" and "journey" from a doc file name like web-payments.md */
|
|
34
101
|
function areaAndJourney(file) {
|
|
35
102
|
// file is a relative path like "api/payments.md" (folder = area).
|
|
@@ -41,7 +108,7 @@ function areaAndJourney(file) {
|
|
|
41
108
|
// Fallback: flat file with no area folder.
|
|
42
109
|
return { area: 'general', journey: noExt };
|
|
43
110
|
}
|
|
44
|
-
export function runEnvTemplate(docsDir, outPath) {
|
|
111
|
+
export function runEnvTemplate(docsDir, outPath, srcDir = '.') {
|
|
45
112
|
if (!existsSync(docsDir)) {
|
|
46
113
|
console.log(`No docs found at "${docsDir}". Run \`testforge setup\` first.`);
|
|
47
114
|
return;
|
|
@@ -76,8 +143,18 @@ export function runEnvTemplate(docsDir, outPath) {
|
|
|
76
143
|
}
|
|
77
144
|
}
|
|
78
145
|
}
|
|
79
|
-
|
|
80
|
-
|
|
146
|
+
// Vars the app reads directly from source but that no doc/map mentioned.
|
|
147
|
+
// These are the ones that silently break `boot` when missing, so they get
|
|
148
|
+
// their own clearly-flagged section.
|
|
149
|
+
const knownFromDocs = new Set();
|
|
150
|
+
for (const areaMap of byArea.values()) {
|
|
151
|
+
for (const v of areaMap.keys())
|
|
152
|
+
knownFromDocs.add(v);
|
|
153
|
+
}
|
|
154
|
+
const sourceVars = scanSourceEnvVars(srcDir);
|
|
155
|
+
const bootVars = [...sourceVars].filter((v) => !knownFromDocs.has(v)).sort();
|
|
156
|
+
if (byArea.size === 0 && bootVars.length === 0) {
|
|
157
|
+
console.log('No environment variables found in the docs or source.');
|
|
81
158
|
return;
|
|
82
159
|
}
|
|
83
160
|
// Build the file.
|
|
@@ -90,6 +167,15 @@ export function runEnvTemplate(docsDir, outPath) {
|
|
|
90
167
|
'# Keep .env.testing OUT of version control.',
|
|
91
168
|
'',
|
|
92
169
|
];
|
|
170
|
+
if (bootVars.length > 0) {
|
|
171
|
+
lines.push(`# ${'='.repeat(40)}`);
|
|
172
|
+
lines.push('# APP (read directly from source — REQUIRED to boot)');
|
|
173
|
+
lines.push(`# ${'='.repeat(40)}`);
|
|
174
|
+
for (const v of bootVars) {
|
|
175
|
+
lines.push(`${v}= # used by: app source`);
|
|
176
|
+
}
|
|
177
|
+
lines.push('');
|
|
178
|
+
}
|
|
93
179
|
for (const area of [...byArea.keys()].sort()) {
|
|
94
180
|
lines.push(`# ${'='.repeat(40)}`);
|
|
95
181
|
lines.push(`# ${area.toUpperCase()}`);
|
package/dist/core/index.js
CHANGED
package/dist/core/init.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* infers it from what the project actually looks like.
|
|
12
12
|
*/
|
|
13
13
|
import { execFileSync } from 'node:child_process';
|
|
14
|
-
import { loadConfig, splitCommand } from './config.js';
|
|
14
|
+
import { loadConfig, splitCommand, withEditTools } from './config.js';
|
|
15
15
|
/** Build the prompt that asks the AI to write testforge.config.json. */
|
|
16
16
|
export function buildInitPrompt() {
|
|
17
17
|
return [
|
|
@@ -57,8 +57,8 @@ export function buildInitPrompt() {
|
|
|
57
57
|
/** Run init via the configured AI command. */
|
|
58
58
|
export function runInit() {
|
|
59
59
|
const prompt = buildInitPrompt();
|
|
60
|
-
const { program, args } = splitCommand(loadConfig().aiCommand);
|
|
61
|
-
const output = execFileSync(program,
|
|
60
|
+
const { program, args } = withEditTools(splitCommand(loadConfig().aiCommand));
|
|
61
|
+
const output = execFileSync(program, args, {
|
|
62
62
|
input: prompt,
|
|
63
63
|
encoding: 'utf-8',
|
|
64
64
|
maxBuffer: 10 * 1024 * 1024,
|
package/dist/core/map.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* only env var names.
|
|
19
19
|
*/
|
|
20
20
|
import { execFileSync } from 'node:child_process';
|
|
21
|
-
import { loadConfig, splitCommand } from './config.js';
|
|
21
|
+
import { loadConfig, splitCommand, withEditTools } from './config.js';
|
|
22
22
|
/** Build the prompt that asks the AI to produce the project map. */
|
|
23
23
|
export function buildMapPrompt(srcDir) {
|
|
24
24
|
return [
|
|
@@ -80,8 +80,8 @@ export function buildMapPrompt(srcDir) {
|
|
|
80
80
|
/** Run the map builder via the configured AI command. */
|
|
81
81
|
export function runMap(srcDir) {
|
|
82
82
|
const prompt = buildMapPrompt(srcDir);
|
|
83
|
-
const { program, args } = splitCommand(loadConfig().aiCommand);
|
|
84
|
-
const output = execFileSync(program,
|
|
83
|
+
const { program, args } = withEditTools(splitCommand(loadConfig().aiCommand));
|
|
84
|
+
const output = execFileSync(program, args, {
|
|
85
85
|
input: prompt,
|
|
86
86
|
encoding: 'utf-8',
|
|
87
87
|
maxBuffer: 10 * 1024 * 1024,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — small interactive prompt helpers.
|
|
3
|
+
*
|
|
4
|
+
* The rest of the CLI is non-interactive: it feeds prompts to the AI on
|
|
5
|
+
* a child process's stdin and never reads from the terminal. The
|
|
6
|
+
* source-of-truth barrier (see source-of-truth.ts) is the one place we
|
|
7
|
+
* genuinely need to ask the human a question, so these helpers read from
|
|
8
|
+
* the parent process's own stdin (the TTY) using node:readline.
|
|
9
|
+
*
|
|
10
|
+
* They are careful about two things:
|
|
11
|
+
* - Non-interactive runs (CI, piped stdin): callers should check
|
|
12
|
+
* isInteractive() first and fall back to a safe default instead of
|
|
13
|
+
* blocking forever waiting for input that will never come.
|
|
14
|
+
* - The child-process AI calls use a separate pipe for their prompt
|
|
15
|
+
* (execFileSync `input`), so reading the TTY here does not clash
|
|
16
|
+
* with them — they never touch process.stdin.
|
|
17
|
+
*/
|
|
18
|
+
import { createInterface } from 'node:readline/promises';
|
|
19
|
+
/**
|
|
20
|
+
* Is the process attached to an interactive terminal on BOTH stdin and
|
|
21
|
+
* stdout? If not (piped input, CI, cron), we must not block on a prompt.
|
|
22
|
+
*/
|
|
23
|
+
export function isInteractive() {
|
|
24
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
25
|
+
}
|
|
26
|
+
/** Ask a free-text question and return the trimmed answer. */
|
|
27
|
+
export async function promptLine(question) {
|
|
28
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
29
|
+
try {
|
|
30
|
+
const answer = await rl.question(question);
|
|
31
|
+
return answer.trim();
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
rl.close();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Ask a yes/no question. Returns true only for an explicit yes.
|
|
39
|
+
* `defaultYes` controls what an empty answer (just Enter) means.
|
|
40
|
+
*/
|
|
41
|
+
export async function promptConfirm(question, defaultYes = false) {
|
|
42
|
+
const hint = defaultYes ? '[Y/n]' : '[y/N]';
|
|
43
|
+
const answer = (await promptLine(`${question} ${hint} `)).toLowerCase();
|
|
44
|
+
if (answer === '')
|
|
45
|
+
return defaultYes;
|
|
46
|
+
return answer === 'y' || answer === 'yes';
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Present a numbered list of choices and return the 1-based index the
|
|
50
|
+
* user picked. Re-asks until a valid number is entered. `defaultIndex`
|
|
51
|
+
* (1-based) is used when the user just presses Enter.
|
|
52
|
+
*/
|
|
53
|
+
export async function promptChoice(title, options, defaultIndex = 1) {
|
|
54
|
+
console.log(title);
|
|
55
|
+
options.forEach((opt, i) => {
|
|
56
|
+
const marker = i + 1 === defaultIndex ? ' (default)' : '';
|
|
57
|
+
console.log(` [${i + 1}] ${opt}${marker}`);
|
|
58
|
+
});
|
|
59
|
+
// Loop until we get a valid selection.
|
|
60
|
+
for (;;) {
|
|
61
|
+
const raw = await promptLine(`Choose [1-${options.length}]: `);
|
|
62
|
+
if (raw === '')
|
|
63
|
+
return defaultIndex;
|
|
64
|
+
const n = Number(raw);
|
|
65
|
+
if (Number.isInteger(n) && n >= 1 && n <= options.length) {
|
|
66
|
+
return n;
|
|
67
|
+
}
|
|
68
|
+
console.log(`Please enter a number between 1 and ${options.length}.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — external reference support for doc generation.
|
|
3
|
+
*
|
|
4
|
+
* The journey docs are the source of truth for the tests: `generate`
|
|
5
|
+
* reads the docs and nothing else. So WHERE the docs come from decides
|
|
6
|
+
* everything. By default they are derived from the project's own code
|
|
7
|
+
* (via testforge.md), which bakes in any bugs the code has.
|
|
8
|
+
*
|
|
9
|
+
* This module lets the docs be drafted from an AUTHORITATIVE reference
|
|
10
|
+
* instead — a spec/requirements doc in the repo, a PDF, a website, or a
|
|
11
|
+
* Notion page that captures the human/client EXPECTED behavior:
|
|
12
|
+
*
|
|
13
|
+
* - analyzeReference() compares the reference against the current
|
|
14
|
+
* map (testforge.md) and any existing docs, and
|
|
15
|
+
* writes a comparison to testforge.diff.md.
|
|
16
|
+
* - draftDocsFromReference() drafts the journey docs with the reference
|
|
17
|
+
* as the source of truth for behavior, using the
|
|
18
|
+
* map/code only for technical detail.
|
|
19
|
+
*
|
|
20
|
+
* Both drive the configured AI CLI with file-read/write (and, for
|
|
21
|
+
* URLs/Notion, web) access. As with discover/map this is tool-specific
|
|
22
|
+
* and tuned for the Claude CLI.
|
|
23
|
+
*/
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import { loadConfig, splitCommand, withEditTools } from './config.js';
|
|
26
|
+
/** Where the comparison report is written. */
|
|
27
|
+
export const DIFF_PATH = 'testforge.diff.md';
|
|
28
|
+
const MAP_PATH = 'testforge.md';
|
|
29
|
+
/** Run the configured AI command with read/write/web access. */
|
|
30
|
+
function runAI(prompt) {
|
|
31
|
+
const { program, args } = withEditTools(splitCommand(loadConfig().aiCommand));
|
|
32
|
+
const output = execFileSync(program, args, {
|
|
33
|
+
input: prompt,
|
|
34
|
+
encoding: 'utf-8',
|
|
35
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
36
|
+
});
|
|
37
|
+
return output.trim();
|
|
38
|
+
}
|
|
39
|
+
/** Build the prompt for the compare-only (analyze) step. */
|
|
40
|
+
export function buildAnalyzePrompt(reference, docsDir) {
|
|
41
|
+
return [
|
|
42
|
+
`You are a senior QA engineer. The developer has given you an`,
|
|
43
|
+
`AUTHORITATIVE reference describing how this product is EXPECTED to`,
|
|
44
|
+
`behave. It comes from a human/client, NOT from the code, so where it`,
|
|
45
|
+
`disagrees with what the code implies, the reference is right.`,
|
|
46
|
+
``,
|
|
47
|
+
`In THIS step you only COMPARE — you do not write any journey docs.`,
|
|
48
|
+
``,
|
|
49
|
+
`Reference provided by the developer:`,
|
|
50
|
+
` ${reference}`,
|
|
51
|
+
``,
|
|
52
|
+
`This may be a path to a file in this project, a path to a document`,
|
|
53
|
+
`(e.g. a PDF), a URL to a website, or a link to a Notion page. Read`,
|
|
54
|
+
`it with your available tools. If you cannot access it at all, print`,
|
|
55
|
+
`a single line starting with "ERROR:" explaining why, and stop.`,
|
|
56
|
+
``,
|
|
57
|
+
`Then read the project map at "${MAP_PATH}" (the AI's code-derived view`,
|
|
58
|
+
`of the journeys and data layer) and any existing docs under`,
|
|
59
|
+
`"${docsDir}/".`,
|
|
60
|
+
``,
|
|
61
|
+
`Compare what the reference specifies against what the map/docs imply,`,
|
|
62
|
+
`focusing on things that matter for testing:`,
|
|
63
|
+
` - journeys/flows the reference describes that the map is missing`,
|
|
64
|
+
` - expected results / acceptance criteria that DIFFER`,
|
|
65
|
+
` - fields, routes, validation, or error cases the reference requires`,
|
|
66
|
+
` that the code-derived view omits or gets wrong`,
|
|
67
|
+
` - anything the code-derived view asserts that the reference contradicts`,
|
|
68
|
+
``,
|
|
69
|
+
`Write a comparison report to "${DIFF_PATH}" at the project root with`,
|
|
70
|
+
`these sections:`,
|
|
71
|
+
` # Source-of-truth comparison`,
|
|
72
|
+
` ## Reference (what you read, and where from)`,
|
|
73
|
+
` ## Summary (a few bullet lines)`,
|
|
74
|
+
` ## Differences (per journey: the code implies X vs the`,
|
|
75
|
+
` reference says Y, and which should win)`,
|
|
76
|
+
` ## Missing (journeys/cases only in the reference)`,
|
|
77
|
+
` ## Code-only (in the code-derived view but not the reference —`,
|
|
78
|
+
` likely assumptions to double-check)`,
|
|
79
|
+
``,
|
|
80
|
+
`Do NOT write any files under "${docsDir}/" in this step — only write`,
|
|
81
|
+
`"${DIFF_PATH}". When done, print a SHORT plain-text summary (about`,
|
|
82
|
+
`5-10 lines) of the key differences.`,
|
|
83
|
+
].join('\n');
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Build the prompt that drafts the journey docs FROM the reference,
|
|
87
|
+
* treating it as the source of truth for behavior. Mirrors the code-based
|
|
88
|
+
* discover format so the docs are interchangeable downstream.
|
|
89
|
+
*/
|
|
90
|
+
export function buildReferenceDiscoverPrompt(srcDir, docsDir, reference) {
|
|
91
|
+
return [
|
|
92
|
+
`You are a senior QA engineer creating per-journey documentation so a`,
|
|
93
|
+
`QA tool can generate tests from it. The developer has chosen an`,
|
|
94
|
+
`AUTHORITATIVE reference as the SOURCE OF TRUTH for how the product is`,
|
|
95
|
+
`EXPECTED to behave. The reference wins over the code: where the code`,
|
|
96
|
+
`does something different, document the reference's expected behavior`,
|
|
97
|
+
`(the code may be buggy — that is exactly what these tests must catch).`,
|
|
98
|
+
``,
|
|
99
|
+
`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.`,
|
|
102
|
+
``,
|
|
103
|
+
`Use the project map at "${MAP_PATH}" and the source under "${srcDir}"`,
|
|
104
|
+
`ONLY for TECHNICAL detail the reference does not spell out: routes,`,
|
|
105
|
+
`field names, models/collections, enums, and env var names. NEVER take`,
|
|
106
|
+
`expected behavior, acceptance criteria, or negative cases from the`,
|
|
107
|
+
`code — those come from the reference.`,
|
|
108
|
+
``,
|
|
109
|
+
`For EACH journey the reference describes, create one markdown file at`,
|
|
110
|
+
`"${docsDir}/<area>/<journey>.md" (area = folder such as api/web/ops,`,
|
|
111
|
+
`short kebab-case journey name; do NOT put the area in the file name).`,
|
|
112
|
+
`Also create a doc for any journey that exists in the map but NOT in`,
|
|
113
|
+
`the reference, drafted from the map, and add a line`,
|
|
114
|
+
`"# TODO: not covered by the reference — confirm expected behavior"`,
|
|
115
|
+
`at its top so it is clearly flagged.`,
|
|
116
|
+
``,
|
|
117
|
+
`Each file starts with this header (required):`,
|
|
118
|
+
``,
|
|
119
|
+
`# Journey: <Name>`,
|
|
120
|
+
``,
|
|
121
|
+
`Type: web (use "web" for a page/UI, "api" for an endpoint)`,
|
|
122
|
+
`Route: <path> (the URL path or endpoint, e.g. /login)`,
|
|
123
|
+
``,
|
|
124
|
+
`Coverage:`,
|
|
125
|
+
`- functional`,
|
|
126
|
+
`- security (add more if relevant: performance, accessibility, ...)`,
|
|
127
|
+
``,
|
|
128
|
+
`Then whichever of these sections apply: Fields, Actions, Expected,`,
|
|
129
|
+
`Negative Cases, Race Conditions, Webhooks, Security Notes, Data. The`,
|
|
130
|
+
`Expected / Negative Cases / acceptance criteria MUST reflect the`,
|
|
131
|
+
`reference. For the Data section use the map's data layer.`,
|
|
132
|
+
``,
|
|
133
|
+
`Rules:`,
|
|
134
|
+
`- Base behavior on the reference and technical detail on real code;`,
|
|
135
|
+
` do not invent either with false confidence.`,
|
|
136
|
+
`- Where the reference is silent on a needed detail, add a "# TODO:"`,
|
|
137
|
+
` line describing what the developer must verify.`,
|
|
138
|
+
`- Never include secrets — refer to env var names only.`,
|
|
139
|
+
`- Create the area subfolders as needed.`,
|
|
140
|
+
`- After creating the files, print the list of journeys you documented`,
|
|
141
|
+
` and remind the developer to review them.`,
|
|
142
|
+
].join('\n');
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Compare-only step. Reads the reference and the current map/docs, writes
|
|
146
|
+
* testforge.diff.md, and returns the AI's short summary. Throws if the AI
|
|
147
|
+
* reports it could not read the reference.
|
|
148
|
+
*/
|
|
149
|
+
export function analyzeReference(reference, docsDir) {
|
|
150
|
+
const summary = runAI(buildAnalyzePrompt(reference, docsDir));
|
|
151
|
+
if (/^ERROR:/m.test(summary)) {
|
|
152
|
+
throw new Error(`Could not read the reference "${reference}":\n${summary}`);
|
|
153
|
+
}
|
|
154
|
+
return summary;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Draft the journey docs from the reference. Returns the AI's summary of
|
|
158
|
+
* the journeys it documented.
|
|
159
|
+
*/
|
|
160
|
+
export function draftDocsFromReference(srcDir, docsDir, reference) {
|
|
161
|
+
return runAI(buildReferenceDiscoverPrompt(srcDir, docsDir, reference));
|
|
162
|
+
}
|
|
@@ -7,35 +7,101 @@
|
|
|
7
7
|
* falling back to the basic generator if AI fails
|
|
8
8
|
* - generates a SEED file for the journey's test data via AI
|
|
9
9
|
*
|
|
10
|
+
* Everything generated by AI passes a syntax gate BEFORE it is written
|
|
11
|
+
* (see validate.ts). If a generation is malformed we regenerate a
|
|
12
|
+
* couple of times; if it still won't parse we skip writing it and
|
|
13
|
+
* report that clearly rather than dropping junk on disk.
|
|
14
|
+
*
|
|
10
15
|
* Output locations come from config (testOutput per type, seedOutput),
|
|
11
16
|
* so tests/seeds land where the project expects them. Defaults to
|
|
12
17
|
* generated-tests/ when not configured.
|
|
13
18
|
*/
|
|
14
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
19
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, } from 'node:fs';
|
|
15
20
|
import { join, dirname } from 'node:path';
|
|
16
21
|
import { parseJourney } from './parser.js';
|
|
17
22
|
import { generateTestCases } from './generator.js';
|
|
18
23
|
import { writePlaywrightFile } from './playwright-writer.js';
|
|
19
24
|
import { generateWithAI } from './ai-generator.js';
|
|
20
25
|
import { generateSeed } from './seed-generator.js';
|
|
26
|
+
import { isSyntaxValid } from './validate.js';
|
|
21
27
|
import { hashContent, loadCache, saveCache } from './cache.js';
|
|
22
28
|
import { findMarkdownFiles } from './find-files.js';
|
|
23
29
|
import { loadConfig, testDirForType } from './config.js';
|
|
24
30
|
const CACHE_PATH = '.testforge/cache.json';
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
/** How many times to re-ask the AI when output won't parse. */
|
|
32
|
+
const MAX_GEN_ATTEMPTS = 2;
|
|
33
|
+
/**
|
|
34
|
+
* Run an AI generator, cleaning + syntax-gating its output, retrying a
|
|
35
|
+
* couple of times on malformed results. Returns the valid content, or
|
|
36
|
+
* null if every attempt failed to produce parseable code.
|
|
37
|
+
*/
|
|
38
|
+
function generateValidated(generate, ext) {
|
|
39
|
+
let content = '';
|
|
40
|
+
for (let attempt = 0; attempt < MAX_GEN_ATTEMPTS; attempt++) {
|
|
29
41
|
try {
|
|
30
|
-
|
|
42
|
+
content = generate();
|
|
31
43
|
}
|
|
32
44
|
catch {
|
|
33
|
-
//
|
|
45
|
+
// generation (or output cleaning) threw — retry
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (isSyntaxValid(content, ext)) {
|
|
49
|
+
return content;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Produce the test file content for one journey. Uses the AI (with a
|
|
56
|
+
* syntax gate + retry) and falls back to the deterministic basic
|
|
57
|
+
* generator if AI output can't be made valid.
|
|
58
|
+
*/
|
|
59
|
+
function buildTest(markdown, useAI, ext, testPath) {
|
|
60
|
+
const journey = parseJourney(markdown);
|
|
61
|
+
if (useAI) {
|
|
62
|
+
const ai = generateValidated(() => generateWithAI(journey, testPath), ext);
|
|
63
|
+
if (ai !== null) {
|
|
64
|
+
return { content: ai, status: 'ai' };
|
|
34
65
|
}
|
|
66
|
+
// fall through to the basic generator
|
|
35
67
|
}
|
|
36
68
|
const cases = generateTestCases(journey);
|
|
37
69
|
return { content: writePlaywrightFile(journey, cases), status: 'basic' };
|
|
38
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Turn a journey id into the alternate naming conventions we might
|
|
73
|
+
* collide with (kebab <-> camel). Used to spot a pre-existing test for
|
|
74
|
+
* the same journey written under a different filename style.
|
|
75
|
+
*/
|
|
76
|
+
function alternateIds(id) {
|
|
77
|
+
const kebab = id
|
|
78
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
79
|
+
.replace(/_/g, '-')
|
|
80
|
+
.toLowerCase();
|
|
81
|
+
const camel = id.replace(/[-_]([a-zA-Z0-9])/g, (_, c) => c.toUpperCase());
|
|
82
|
+
return [...new Set([kebab, camel])].filter((x) => x !== id);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Warn if a test for this same journey already exists under a different
|
|
86
|
+
* naming convention (e.g. urlShortener.test.js vs url-shortener.test.js),
|
|
87
|
+
* which would silently create a duplicate.
|
|
88
|
+
*/
|
|
89
|
+
function collisionWarning(testDir, id, ext) {
|
|
90
|
+
if (!existsSync(testDir))
|
|
91
|
+
return null;
|
|
92
|
+
const alts = new Set(alternateIds(id).map((a) => `${a}${ext}`));
|
|
93
|
+
let existing;
|
|
94
|
+
try {
|
|
95
|
+
existing = readdirSync(testDir);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const clashes = existing.filter((f) => alts.has(f));
|
|
101
|
+
if (clashes.length === 0)
|
|
102
|
+
return null;
|
|
103
|
+
return `an existing test with different naming already covers this journey: ${clashes.join(', ')} — new file may duplicate it`;
|
|
104
|
+
}
|
|
39
105
|
export function runGenerate(docsDir, useAI = true, force = false) {
|
|
40
106
|
const files = findMarkdownFiles(docsDir);
|
|
41
107
|
if (files.length === 0) {
|
|
@@ -44,6 +110,7 @@ export function runGenerate(docsDir, useAI = true, force = false) {
|
|
|
44
110
|
const config = loadConfig();
|
|
45
111
|
const ext = config.testExtension ?? '.test.ts';
|
|
46
112
|
const seedDir = config.seedOutput ?? 'generated-tests/seed';
|
|
113
|
+
const seedExt = ext.endsWith('.js') ? '.seed.js' : '.seed.ts';
|
|
47
114
|
const cache = loadCache(CACHE_PATH);
|
|
48
115
|
const results = [];
|
|
49
116
|
for (const file of files) {
|
|
@@ -53,9 +120,6 @@ export function runGenerate(docsDir, useAI = true, force = false) {
|
|
|
53
120
|
// Where this journey's test and seed go.
|
|
54
121
|
const testDir = testDirForType(config, journey.type);
|
|
55
122
|
const testPath = join(testDir, `${journey.id}${ext}`);
|
|
56
|
-
const seedExt = (config.testExtension ?? '.test.ts').endsWith('.js')
|
|
57
|
-
? '.seed.js'
|
|
58
|
-
: '.seed.ts';
|
|
59
123
|
const seedPath = join(seedDir, `${journey.id}${seedExt}`);
|
|
60
124
|
// Skip if unchanged.
|
|
61
125
|
if (!force && cache[file] === hash) {
|
|
@@ -64,26 +128,43 @@ export function runGenerate(docsDir, useAI = true, force = false) {
|
|
|
64
128
|
testPath,
|
|
65
129
|
seedPath,
|
|
66
130
|
status: 'skipped',
|
|
131
|
+
seedStatus: 'none',
|
|
67
132
|
});
|
|
68
133
|
continue;
|
|
69
134
|
}
|
|
70
|
-
|
|
71
|
-
const
|
|
135
|
+
const warnings = [];
|
|
136
|
+
const collision = collisionWarning(testDir, journey.id, ext);
|
|
137
|
+
if (collision)
|
|
138
|
+
warnings.push(collision);
|
|
139
|
+
// 1. Test file (syntax-gated before writing).
|
|
140
|
+
const { content, status } = buildTest(markdown, useAI, ext, testPath);
|
|
72
141
|
mkdirSync(dirname(testPath), { recursive: true });
|
|
73
142
|
writeFileSync(testPath, content, 'utf-8');
|
|
74
143
|
// 2. Seed file (only when using AI — basic mode has no seed concept).
|
|
144
|
+
let seedStatus = 'none';
|
|
75
145
|
if (useAI) {
|
|
76
|
-
|
|
77
|
-
|
|
146
|
+
const seed = generateValidated(() => generateSeed(markdown, seedPath), seedExt);
|
|
147
|
+
if (seed !== null) {
|
|
78
148
|
mkdirSync(dirname(seedPath), { recursive: true });
|
|
79
149
|
writeFileSync(seedPath, seed, 'utf-8');
|
|
150
|
+
seedStatus = 'ok';
|
|
80
151
|
}
|
|
81
|
-
|
|
82
|
-
//
|
|
152
|
+
else {
|
|
153
|
+
// Do NOT write a broken seed. Report it so the user sees which
|
|
154
|
+
// seeds were not produced.
|
|
155
|
+
seedStatus = 'failed';
|
|
156
|
+
warnings.push(`seed generation failed after ${MAX_GEN_ATTEMPTS} attempts — no seed written`);
|
|
83
157
|
}
|
|
84
158
|
}
|
|
85
159
|
cache[file] = hash;
|
|
86
|
-
results.push({
|
|
160
|
+
results.push({
|
|
161
|
+
journeyName: journey.name,
|
|
162
|
+
testPath,
|
|
163
|
+
seedPath,
|
|
164
|
+
status,
|
|
165
|
+
seedStatus,
|
|
166
|
+
warnings: warnings.length ? warnings : undefined,
|
|
167
|
+
});
|
|
87
168
|
}
|
|
88
169
|
saveCache(CACHE_PATH, cache);
|
|
89
170
|
return results;
|
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { execFileSync } from 'node:child_process';
|
|
18
18
|
import { readFileSync, existsSync } from 'node:fs';
|
|
19
|
-
import { loadConfig, splitCommand } from './config.js';
|
|
19
|
+
import { loadConfig, splitCommand, withTextOnlyTools } from './config.js';
|
|
20
|
+
import { cleanOutput } from './ai-generator.js';
|
|
20
21
|
const MAP_PATH = 'testforge.md';
|
|
21
22
|
/** Build the seed-generation prompt for one journey. */
|
|
22
|
-
export function buildSeedPrompt(journeyDoc) {
|
|
23
|
+
export function buildSeedPrompt(journeyDoc, seedPath) {
|
|
23
24
|
const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
|
|
24
25
|
const ext = loadConfig().testExtension ?? '.test.ts';
|
|
25
26
|
const isJs = ext.endsWith('.js');
|
|
@@ -50,6 +51,17 @@ export function buildSeedPrompt(journeyDoc) {
|
|
|
50
51
|
`- Use the database driver/ORM the project already uses (infer from`,
|
|
51
52
|
` the map / schema style).`,
|
|
52
53
|
``,
|
|
54
|
+
...(seedPath
|
|
55
|
+
? [
|
|
56
|
+
`This seed file will be saved at EXACTLY this path:`,
|
|
57
|
+
` ${seedPath}`,
|
|
58
|
+
`Compute every require()/import path (to app source, models, config,`,
|
|
59
|
+
`etc.) RELATIVE TO THAT EXACT location — count the directory depth`,
|
|
60
|
+
`from that path, do not assume a conventional depth. A wrong`,
|
|
61
|
+
`relative depth makes the seed fail to load.`,
|
|
62
|
+
``,
|
|
63
|
+
]
|
|
64
|
+
: []),
|
|
53
65
|
`Write the seed in ${language}.`,
|
|
54
66
|
`Output ONLY the seed code. No explanation, no markdown code fences.`,
|
|
55
67
|
`The very first characters must be code (a comment or import/require).`,
|
|
@@ -64,17 +76,18 @@ export function buildSeedPrompt(journeyDoc) {
|
|
|
64
76
|
].join('\n');
|
|
65
77
|
}
|
|
66
78
|
/** Generate a seed file's content for one journey doc, via the AI. */
|
|
67
|
-
export function generateSeed(journeyDoc) {
|
|
68
|
-
const prompt = buildSeedPrompt(journeyDoc);
|
|
69
|
-
|
|
79
|
+
export function generateSeed(journeyDoc, seedPath) {
|
|
80
|
+
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));
|
|
70
84
|
const output = execFileSync(program, args, {
|
|
71
85
|
input: prompt,
|
|
72
86
|
encoding: 'utf-8',
|
|
73
87
|
maxBuffer: 10 * 1024 * 1024,
|
|
74
88
|
});
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
.trim();
|
|
89
|
+
// Share the test generator's robust cleaning: strips leading AND
|
|
90
|
+
// trailing prose, and throws if no code is found (so the caller can
|
|
91
|
+
// retry or skip rather than writing junk to disk).
|
|
92
|
+
return cleanOutput(output);
|
|
80
93
|
}
|