@activate-core/test-forge 0.1.1 → 0.2.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 +50 -2
- package/dist/cli/index.js +29 -25
- package/dist/core/ai-generator.js +79 -26
- package/dist/core/config.js +80 -5
- package/dist/core/discover.js +3 -3
- package/dist/core/env-template.js +91 -5
- package/dist/core/generate-command.js +55 -0
- 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/reconcile.js +157 -0
- package/dist/core/run-generate.js +98 -17
- package/dist/core/seed-generator.js +23 -10
- package/dist/core/setup.js +1 -1
- package/dist/core/source-of-truth.js +95 -0
- package/dist/core/update.js +1 -1
- package/dist/core/validate.js +82 -0
- package/package.json +1 -1
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,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — reconcile docs against an external reference.
|
|
3
|
+
*
|
|
4
|
+
* By default testforge's source of truth is what the AI derived from the
|
|
5
|
+
* project's own code (testforge.md + the journey docs under docs/). That
|
|
6
|
+
* is convenient, but it inherits any bugs or wrong assumptions in the
|
|
7
|
+
* code: if the code mishandles a scenario, the AI documents the wrong
|
|
8
|
+
* behavior, and every test then locks that wrong behavior in.
|
|
9
|
+
*
|
|
10
|
+
* This module lets a human point testforge at an AUTHORITATIVE reference
|
|
11
|
+
* of the expected behavior — a spec file in the repo, a document/PDF, a
|
|
12
|
+
* website, or a Notion page — and:
|
|
13
|
+
*
|
|
14
|
+
* 1. analyzeReference() — compares the reference against the current
|
|
15
|
+
* testforge.md/docs, writes a comparison report to testforge.diff.md,
|
|
16
|
+
* and prints a short summary. It changes nothing else.
|
|
17
|
+
*
|
|
18
|
+
* 2. applyReconciliation() — once the human confirms, rewrites the
|
|
19
|
+
* journey docs so they reflect the reference as the source of truth
|
|
20
|
+
* (fixing wrong expectations, adding missing journeys/cases).
|
|
21
|
+
*
|
|
22
|
+
* Both steps drive the configured AI CLI with file-read/write (and, for
|
|
23
|
+
* URLs/Notion, web) access. As with discover/map, this is tool-specific;
|
|
24
|
+
* it is tuned for the Claude CLI and other CLIs may need their own setup.
|
|
25
|
+
*/
|
|
26
|
+
import { execFileSync } from 'node:child_process';
|
|
27
|
+
import { loadConfig, splitCommand, withEditTools } from './config.js';
|
|
28
|
+
/** Where the comparison report is written. */
|
|
29
|
+
export const DIFF_PATH = 'testforge.diff.md';
|
|
30
|
+
const MAP_PATH = 'testforge.md';
|
|
31
|
+
/** Run the configured AI command with read/write/web access. */
|
|
32
|
+
function runAI(prompt) {
|
|
33
|
+
const { program, args } = withEditTools(splitCommand(loadConfig().aiCommand));
|
|
34
|
+
const output = execFileSync(program, args, {
|
|
35
|
+
input: prompt,
|
|
36
|
+
encoding: 'utf-8',
|
|
37
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
38
|
+
});
|
|
39
|
+
return output.trim();
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The journey-doc format, kept in sync with discover.ts so newly created
|
|
43
|
+
* docs match the ones setup produced. Referenced from the apply prompt.
|
|
44
|
+
*/
|
|
45
|
+
const DOC_FORMAT = [
|
|
46
|
+
`# Journey: <Name>`,
|
|
47
|
+
``,
|
|
48
|
+
`Type: web (use "web" for a page/UI, "api" for an endpoint)`,
|
|
49
|
+
`Route: <path> (the URL path or endpoint, e.g. /login)`,
|
|
50
|
+
``,
|
|
51
|
+
`Coverage:`,
|
|
52
|
+
`- functional`,
|
|
53
|
+
`- security (add more if relevant: performance, accessibility, ...)`,
|
|
54
|
+
``,
|
|
55
|
+
`Then whichever sections apply: Fields, Actions, Expected, Negative`,
|
|
56
|
+
`Cases, Race Conditions, Webhooks, Security Notes, Data.`,
|
|
57
|
+
].join('\n');
|
|
58
|
+
/** Build the prompt for the compare-only (analyze) step. */
|
|
59
|
+
export function buildAnalyzePrompt(reference, docsDir) {
|
|
60
|
+
return [
|
|
61
|
+
`You are a senior QA engineer. The developer has given you an`,
|
|
62
|
+
`AUTHORITATIVE reference describing how this product is EXPECTED to`,
|
|
63
|
+
`behave. This reference comes from a human/client, NOT from the code,`,
|
|
64
|
+
`so where it disagrees with the current docs the reference is right`,
|
|
65
|
+
`and the docs (which were derived from possibly-buggy code) are wrong.`,
|
|
66
|
+
``,
|
|
67
|
+
`In THIS step you only COMPARE. Do not change the docs.`,
|
|
68
|
+
``,
|
|
69
|
+
`Reference provided by the developer:`,
|
|
70
|
+
` ${reference}`,
|
|
71
|
+
``,
|
|
72
|
+
`This may be a path to a file in this project, a path to a document`,
|
|
73
|
+
`(e.g. a PDF), a URL to a website, or a link to a Notion page. Read`,
|
|
74
|
+
`it with your available tools (read the file, fetch the URL/page). If`,
|
|
75
|
+
`you cannot access it at all, print a line starting with "ERROR:"`,
|
|
76
|
+
`explaining why, and stop.`,
|
|
77
|
+
``,
|
|
78
|
+
`Then read:`,
|
|
79
|
+
` - the project map at "${MAP_PATH}" (if it exists)`,
|
|
80
|
+
` - every journey doc under "${docsDir}/"`,
|
|
81
|
+
``,
|
|
82
|
+
`Compare what the reference specifies against what the map and docs`,
|
|
83
|
+
`currently say, focusing on things that matter for testing:`,
|
|
84
|
+
` - journeys/flows the reference describes that are MISSING from docs`,
|
|
85
|
+
` - expected results / acceptance criteria that DIFFER`,
|
|
86
|
+
` - fields, routes, validation, or error cases the reference requires`,
|
|
87
|
+
` that the docs omit or get wrong`,
|
|
88
|
+
` - anything the docs assert that the reference contradicts`,
|
|
89
|
+
``,
|
|
90
|
+
`Write a comparison report to "${DIFF_PATH}" at the project root with`,
|
|
91
|
+
`these sections:`,
|
|
92
|
+
` # Source-of-truth comparison`,
|
|
93
|
+
` ## Reference (what you read, and where from)`,
|
|
94
|
+
` ## Summary (a few bullet lines)`,
|
|
95
|
+
` ## Differences (per journey: docs say X vs reference says Y,`,
|
|
96
|
+
` and which should win)`,
|
|
97
|
+
` ## Missing in docs (journeys/cases only in the reference)`,
|
|
98
|
+
` ## Docs-only (in the docs but not the reference — may be`,
|
|
99
|
+
` code-derived assumptions to double-check)`,
|
|
100
|
+
``,
|
|
101
|
+
`Do NOT modify "${MAP_PATH}" or anything under "${docsDir}/" in this`,
|
|
102
|
+
`step — only write "${DIFF_PATH}". When done, print a SHORT plain-text`,
|
|
103
|
+
`summary (about 5-10 lines) of the key differences.`,
|
|
104
|
+
].join('\n');
|
|
105
|
+
}
|
|
106
|
+
/** Build the prompt for the apply (reconcile) step. */
|
|
107
|
+
export function buildApplyPrompt(reference, docsDir) {
|
|
108
|
+
return [
|
|
109
|
+
`You are a senior QA engineer. The developer has CONFIRMED that the`,
|
|
110
|
+
`reference below is the SOURCE OF TRUTH for tests, overriding what was`,
|
|
111
|
+
`previously derived from the code.`,
|
|
112
|
+
``,
|
|
113
|
+
`Reference: ${reference}`,
|
|
114
|
+
`The comparison has already been computed — read "${DIFF_PATH}" for the`,
|
|
115
|
+
`differences you must resolve.`,
|
|
116
|
+
``,
|
|
117
|
+
`Update the journey docs under "${docsDir}/" so they reflect the`,
|
|
118
|
+
`reference as the source of truth:`,
|
|
119
|
+
` - Fix expected results / acceptance criteria to match the reference`,
|
|
120
|
+
` wherever they differ.`,
|
|
121
|
+
` - Add sections or cases the reference requires that are missing.`,
|
|
122
|
+
` - For a journey the reference describes that has NO doc yet, create`,
|
|
123
|
+
` "${docsDir}/<area>/<journey>.md" (area = folder, short kebab-case`,
|
|
124
|
+
` journey name) using this format:`,
|
|
125
|
+
``,
|
|
126
|
+
DOC_FORMAT,
|
|
127
|
+
``,
|
|
128
|
+
` - Where a doc made an assumption from the code that the reference`,
|
|
129
|
+
` does not mention, KEEP it, but if it conflicts with the reference`,
|
|
130
|
+
` add a line starting with "# TODO:" describing the conflict.`,
|
|
131
|
+
` - If the reference reveals models/fields the map missed, update`,
|
|
132
|
+
` "${MAP_PATH}" too.`,
|
|
133
|
+
``,
|
|
134
|
+
`Never include secrets — refer to env var names only. Preserve the`,
|
|
135
|
+
`existing doc format. When done, print a SHORT summary listing which`,
|
|
136
|
+
`docs you created or changed.`,
|
|
137
|
+
].join('\n');
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Compare-only step. Reads the reference and the current docs/map, writes
|
|
141
|
+
* testforge.diff.md, and returns the AI's short summary. Changes nothing
|
|
142
|
+
* else. Throws if the AI reports it could not read the reference.
|
|
143
|
+
*/
|
|
144
|
+
export function analyzeReference(reference, docsDir) {
|
|
145
|
+
const summary = runAI(buildAnalyzePrompt(reference, docsDir));
|
|
146
|
+
if (/^ERROR:/m.test(summary)) {
|
|
147
|
+
throw new Error(`Could not read the reference "${reference}":\n${summary}`);
|
|
148
|
+
}
|
|
149
|
+
return summary;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Apply step. Rewrites the docs (and map if needed) so they reflect the
|
|
153
|
+
* reference. Returns the AI's short summary of what it changed.
|
|
154
|
+
*/
|
|
155
|
+
export function applyReconciliation(reference, docsDir) {
|
|
156
|
+
return runAI(buildApplyPrompt(reference, docsDir));
|
|
157
|
+
}
|
|
@@ -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
|
}
|
package/dist/core/setup.js
CHANGED
|
@@ -60,7 +60,7 @@ export function runSetup(srcDir) {
|
|
|
60
60
|
// Build an env template so the developer knows which credentials each
|
|
61
61
|
// area/journey needs for running the tests.
|
|
62
62
|
console.log('Building env template...');
|
|
63
|
-
runEnvTemplate('docs', '.env.testing.example');
|
|
63
|
+
runEnvTemplate('docs', '.env.testing.example', srcDir);
|
|
64
64
|
console.log('');
|
|
65
65
|
console.log('Setup complete.');
|
|
66
66
|
console.log('Review the drafts in docs/, fix anything the AI got wrong,');
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — the "source of truth" barrier for `generate`.
|
|
3
|
+
*
|
|
4
|
+
* Before generating tests, testforge asks the developer WHAT the tests
|
|
5
|
+
* should be based on. Two answers are possible:
|
|
6
|
+
*
|
|
7
|
+
* 1. testforge.md + docs/ — what the AI derived from the project's own
|
|
8
|
+
* code. Fast, but only as correct as the code it read.
|
|
9
|
+
*
|
|
10
|
+
* 2. An external reference — a spec/doc/PDF in the repo, a website, or
|
|
11
|
+
* a Notion page — that captures the HUMAN/CLIENT expected behavior.
|
|
12
|
+
* testforge compares it against the current docs (writing a diff for
|
|
13
|
+
* the developer to inspect), and, on confirmation, reconciles the
|
|
14
|
+
* docs so the reference wins. Tests are then generated from those
|
|
15
|
+
* reconciled docs.
|
|
16
|
+
*
|
|
17
|
+
* This keeps the generate pipeline itself unchanged (it still reads
|
|
18
|
+
* docs/) — the barrier just decides whether the docs get reconciled
|
|
19
|
+
* against a reference first.
|
|
20
|
+
*
|
|
21
|
+
* Non-interactive callers (CI, cron, piped stdin) must not be blocked on
|
|
22
|
+
* a prompt, so:
|
|
23
|
+
* - `source: 'docs'` → skip the barrier, use docs as-is.
|
|
24
|
+
* - `reference: <locator>` → use that reference, analyze + apply with
|
|
25
|
+
* no prompts (implicit confirmation).
|
|
26
|
+
* - neither, and no TTY → fall back to docs with a printed note.
|
|
27
|
+
*/
|
|
28
|
+
import { isInteractive, promptChoice, promptConfirm, promptLine, } from './prompt.js';
|
|
29
|
+
import { analyzeReference, applyReconciliation, DIFF_PATH } from './reconcile.js';
|
|
30
|
+
/** Run the analyze step and reconcile the docs against the reference. */
|
|
31
|
+
function reconcileToReference(reference, docsDir) {
|
|
32
|
+
console.log(`\nAnalyzing reference against current docs: ${reference}`);
|
|
33
|
+
console.log('(reading the reference, the map and docs/ — this may take a moment)\n');
|
|
34
|
+
const summary = analyzeReference(reference, docsDir);
|
|
35
|
+
console.log(summary);
|
|
36
|
+
console.log(`\nFull comparison written to ${DIFF_PATH}.`);
|
|
37
|
+
console.log('\nReconciling docs so the reference is the source of truth...\n');
|
|
38
|
+
const applied = applyReconciliation(reference, docsDir);
|
|
39
|
+
console.log(applied);
|
|
40
|
+
console.log('\nDocs updated. Generating tests from the reconciled docs.\n');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the source of truth before generation. May reconcile the docs
|
|
44
|
+
* in place (when a reference is chosen). Returns once docs/ reflects the
|
|
45
|
+
* chosen source; the caller then runs the normal generate pipeline.
|
|
46
|
+
*/
|
|
47
|
+
export async function resolveSourceOfTruth(opts) {
|
|
48
|
+
const { docsDir } = opts;
|
|
49
|
+
// Explicit "use the docs" — nothing to do.
|
|
50
|
+
if (opts.source === 'docs')
|
|
51
|
+
return;
|
|
52
|
+
// Explicit reference via flag: non-interactive analyze + apply.
|
|
53
|
+
if (opts.reference) {
|
|
54
|
+
reconcileToReference(opts.reference, docsDir);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// No explicit choice and no terminal to ask on — do the safe,
|
|
58
|
+
// non-blocking thing: use the docs as they are.
|
|
59
|
+
if (!isInteractive()) {
|
|
60
|
+
console.log('Using testforge.md + docs/ as the source of truth ' +
|
|
61
|
+
'(non-interactive; pass --reference <file|url> to use an external reference).');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// Interactive: ask what to base the tests on.
|
|
65
|
+
const choice = await promptChoice('What should the tests be based on?', [
|
|
66
|
+
'testforge.md + docs/ (what the AI derived from your code)',
|
|
67
|
+
'An external reference (a file/PDF in scope, a website, or a Notion link)',
|
|
68
|
+
], 1);
|
|
69
|
+
if (choice === 1) {
|
|
70
|
+
console.log('Using testforge.md + docs/ as the source of truth.\n');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const reference = await promptLine('\nProvide the reference (a file path, a URL, or a Notion link): ');
|
|
74
|
+
if (!reference) {
|
|
75
|
+
console.log('No reference given — falling back to testforge.md + docs/.\n');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// Analyze first so the developer can see the differences, then confirm
|
|
79
|
+
// before we rewrite any docs.
|
|
80
|
+
console.log(`\nAnalyzing reference against current docs: ${reference}`);
|
|
81
|
+
console.log('(reading the reference, the map and docs/ — this may take a moment)\n');
|
|
82
|
+
const summary = analyzeReference(reference, docsDir);
|
|
83
|
+
console.log(summary);
|
|
84
|
+
console.log(`\nFull comparison written to ${DIFF_PATH}.`);
|
|
85
|
+
const apply = await promptConfirm('\nApply these changes to docs/ (make the reference the source of truth)?', false);
|
|
86
|
+
if (!apply) {
|
|
87
|
+
console.log(`\nKeeping the current docs. The comparison is saved in ${DIFF_PATH} ` +
|
|
88
|
+
'for you to review. Generating tests from the existing docs.\n');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
console.log('\nReconciling docs so the reference is the source of truth...\n');
|
|
92
|
+
const applied = applyReconciliation(reference, docsDir);
|
|
93
|
+
console.log(applied);
|
|
94
|
+
console.log('\nDocs updated. Generating tests from the reconciled docs.\n');
|
|
95
|
+
}
|
package/dist/core/update.js
CHANGED
|
@@ -46,7 +46,7 @@ export function runUpdate(srcDir) {
|
|
|
46
46
|
cache[SOURCE_KEY] = newHash;
|
|
47
47
|
saveCache(CACHE_PATH, cache);
|
|
48
48
|
console.log('Refreshing env template...');
|
|
49
|
-
runEnvTemplate('docs', '.env.testing.example');
|
|
49
|
+
runEnvTemplate('docs', '.env.testing.example', srcDir);
|
|
50
50
|
console.log('');
|
|
51
51
|
console.log('Update complete.');
|
|
52
52
|
}
|