@activate-core/test-forge 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -21
- package/dist/cli/index.js +97 -56
- package/dist/core/config.js +29 -0
- package/dist/core/discover.js +42 -4
- package/dist/core/index.js +1 -1
- package/dist/core/reference.js +162 -0
- package/dist/core/setup.js +4 -3
- package/dist/core/source-of-truth.js +63 -74
- package/dist/core/update.js +4 -2
- package/package.json +1 -1
- package/dist/core/generate-command.js +0 -55
- package/dist/core/reconcile.js +0 -157
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ testforge update # re-check and refresh only what changed
|
|
|
40
40
|
| Command | What it does |
|
|
41
41
|
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
42
42
|
| `testforge setup` | One-time setup: runs init, map, and discover in order, with progress. |
|
|
43
|
-
| `testforge generate` | Generates a test file and a seed file for each journey doc. Only changed docs are regenerated.
|
|
43
|
+
| `testforge generate` | Generates a test file and a seed file for each journey doc. Only changed docs are regenerated. |
|
|
44
44
|
| `testforge update` | Detects source-code changes and refreshes the map, docs, and tests when needed. Skips everything if nothing changed (no AI calls). |
|
|
45
45
|
|
|
46
46
|
### Advanced (run a single step)
|
|
@@ -75,18 +75,22 @@ change means no work and no AI calls; a change triggers a refresh.
|
|
|
75
75
|
|
|
76
76
|
## Source of truth
|
|
77
77
|
|
|
78
|
-
|
|
79
|
-
the
|
|
80
|
-
|
|
81
|
-
is mishandled, the AI faithfully documents the wrong behavior, and the
|
|
82
|
-
tests then lock that wrong behavior in.
|
|
78
|
+
The journey docs in `docs/` are the source of truth for the tests —
|
|
79
|
+
`generate` reads the docs and nothing else. So where the docs come from
|
|
80
|
+
decides everything.
|
|
83
81
|
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
By default the docs are derived from your **code** (the AI reads the
|
|
83
|
+
project, writes `testforge.md`, then drafts the docs from it). That is
|
|
84
|
+
fast, but it inherits whatever the code does: if a flow is buggy or a
|
|
85
|
+
scenario is mishandled, the AI faithfully documents the wrong behavior,
|
|
86
|
+
and the tests then lock that wrong behavior in.
|
|
87
|
+
|
|
88
|
+
So when the docs are drafted — during `testforge setup` (or `testforge
|
|
89
|
+
discover`) — testforge asks what they should be based on:
|
|
86
90
|
|
|
87
91
|
```
|
|
88
|
-
What should the
|
|
89
|
-
[1] testforge.md
|
|
92
|
+
What should the journey docs be based on?
|
|
93
|
+
[1] testforge.md (what the AI derived from your code) (default)
|
|
90
94
|
[2] An external reference (a file/PDF in scope, a website, or a Notion link)
|
|
91
95
|
```
|
|
92
96
|
|
|
@@ -94,27 +98,31 @@ Choose **[2]** to give testforge an authoritative reference of the
|
|
|
94
98
|
_expected_ behavior — a spec or requirements doc in the repo, a PDF, a
|
|
95
99
|
website, or a Notion page. testforge then:
|
|
96
100
|
|
|
97
|
-
1. reads the reference and compares it against
|
|
101
|
+
1. reads the reference and compares it against `testforge.md`,
|
|
98
102
|
2. writes a full comparison to **`testforge.diff.md`** and prints a short
|
|
99
103
|
summary of the differences,
|
|
100
|
-
3. asks you to confirm, and on confirmation **
|
|
101
|
-
reference
|
|
102
|
-
|
|
104
|
+
3. asks you to confirm, and on confirmation **drafts the docs from the
|
|
105
|
+
reference** — expected behavior comes from the reference, while routes,
|
|
106
|
+
fields, models, and env var names come from the code/map,
|
|
107
|
+
4. `generate` then turns those docs into tests as usual.
|
|
103
108
|
|
|
104
109
|
The reference becomes the source of truth — expected behavior from a human
|
|
105
|
-
or client, not just what the code happens to do.
|
|
106
|
-
|
|
110
|
+
or client, not just what the code happens to do. The choice is remembered
|
|
111
|
+
in `.testforge/source.json`, so a later `testforge update` refreshes the
|
|
112
|
+
docs from the same source without asking again. Your review of
|
|
113
|
+
`testforge.diff.md` stays as an audit of what differed and why.
|
|
107
114
|
|
|
108
115
|
**Non-interactive use (CI, scripts):**
|
|
109
116
|
|
|
110
117
|
```bash
|
|
111
|
-
testforge
|
|
112
|
-
testforge
|
|
113
|
-
testforge
|
|
118
|
+
testforge setup --source map # base the docs on testforge.md, no prompt
|
|
119
|
+
testforge setup --reference ./spec.md # base the docs on a reference, no prompts
|
|
120
|
+
testforge setup --reference https://… # a URL or Notion link works too
|
|
114
121
|
```
|
|
115
122
|
|
|
116
|
-
|
|
117
|
-
|
|
123
|
+
The same flags work on `testforge discover`. With no flags and no terminal
|
|
124
|
+
attached, testforge does not block — it falls back to `testforge.md` and
|
|
125
|
+
prints a note.
|
|
118
126
|
|
|
119
127
|
## Running the generated tests
|
|
120
128
|
|
package/dist/cli/index.js
CHANGED
|
@@ -3,15 +3,19 @@
|
|
|
3
3
|
* testforge — command-line entry point.
|
|
4
4
|
*
|
|
5
5
|
* Commands:
|
|
6
|
-
* testforge
|
|
6
|
+
* testforge setup init + map + discover. discover asks
|
|
7
|
+
* whether the docs come from testforge.md
|
|
8
|
+
* or an external reference (the source of
|
|
9
|
+
* truth for every test).
|
|
10
|
+
* testforge setup --reference <x> Base the docs on reference <x> (a file
|
|
11
|
+
* path, URL, or Notion link) — no prompt.
|
|
12
|
+
* testforge setup --source map Base the docs on testforge.md — no prompt.
|
|
13
|
+
* testforge generate Generate tests from the journey docs.
|
|
7
14
|
* testforge generate --basic Use the built-in basic generator only.
|
|
8
15
|
* testforge generate --force Regenerate everything, ignoring the cache.
|
|
9
|
-
* testforge generate --source docs Skip the source-of-truth prompt, use docs.
|
|
10
|
-
* testforge generate --reference <x> Reconcile docs against a reference first,
|
|
11
|
-
* where <x> is a file path, URL, or Notion link.
|
|
12
16
|
*/
|
|
13
17
|
import { VERSION } from '../core/index.js';
|
|
14
|
-
import {
|
|
18
|
+
import { runGenerate } from '../core/run-generate.js';
|
|
15
19
|
import { runDiscover } from '../core/discover.js';
|
|
16
20
|
import { runMap } from '../core/map.js';
|
|
17
21
|
import { runInit } from '../core/init.js';
|
|
@@ -31,65 +35,102 @@ function flagValue(name) {
|
|
|
31
35
|
return undefined;
|
|
32
36
|
return value;
|
|
33
37
|
}
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
/** Source-of-truth options shared by setup and discover. */
|
|
39
|
+
function sourceOptions() {
|
|
40
|
+
return { reference: flagValue('--reference'), source: flagValue('--source') };
|
|
36
41
|
}
|
|
37
|
-
|
|
38
|
-
runSetup('.');
|
|
39
|
-
}
|
|
40
|
-
else if (command === 'init') {
|
|
41
|
-
console.log('Inspecting your project to set up testforge...\n');
|
|
42
|
-
const output = runInit();
|
|
43
|
-
console.log(output);
|
|
44
|
-
console.log('\ntestforge.config.json written. Review it, then run `testforge map`.');
|
|
45
|
-
}
|
|
46
|
-
else if (command === 'map') {
|
|
47
|
-
console.log('Building a high-level map of your project with AI...');
|
|
48
|
-
console.log('This scans your source code and writes testforge.md\n');
|
|
49
|
-
const output = runMap('.');
|
|
50
|
-
console.log(output);
|
|
51
|
-
console.log('\nProject map written to testforge.md');
|
|
52
|
-
}
|
|
53
|
-
else if (command === 'discover') {
|
|
54
|
-
console.log('Scanning your project with AI to draft journey files...');
|
|
55
|
-
console.log('This reads your source code and writes drafts into docs/.\n');
|
|
56
|
-
const output = runDiscover('.', 'docs');
|
|
57
|
-
console.log(output);
|
|
58
|
-
console.log('\nDraft journeys written to docs/. Please review and edit them,');
|
|
59
|
-
console.log('then run `testforge generate`.');
|
|
60
|
-
}
|
|
61
|
-
else if (command === 'env') {
|
|
62
|
-
runEnvTemplate('docs', '.env.testing.example');
|
|
63
|
-
}
|
|
64
|
-
else if (command === 'generate') {
|
|
65
|
-
const useAI = !useBasic;
|
|
66
|
-
console.log(useAI
|
|
67
|
-
? 'Generating tests with AI for changed journeys...\n'
|
|
68
|
-
: 'Generating tests with the basic generator...\n');
|
|
69
|
-
runGenerateCommand({
|
|
70
|
-
docsDir: 'docs',
|
|
71
|
-
useAI,
|
|
72
|
-
force,
|
|
73
|
-
reference: flagValue('--reference'),
|
|
74
|
-
source: flagValue('--source'),
|
|
75
|
-
}).catch((err) => {
|
|
76
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
77
|
-
process.exit(1);
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
else {
|
|
42
|
+
function printUsage() {
|
|
81
43
|
console.log(`testforge v${VERSION}`);
|
|
82
44
|
console.log('');
|
|
83
45
|
console.log('Usage:');
|
|
84
|
-
console.log(' testforge
|
|
46
|
+
console.log(' testforge setup Run init + map + discover in order');
|
|
47
|
+
console.log(' testforge setup --reference <file|url> Base the docs on a reference');
|
|
48
|
+
console.log(' testforge setup --source map Base the docs on testforge.md');
|
|
49
|
+
console.log(' testforge generate Generate tests from the journey docs');
|
|
85
50
|
console.log(' testforge generate --basic Use the basic generator only');
|
|
86
51
|
console.log(' testforge generate --force Regenerate everything');
|
|
87
|
-
console.log(' testforge
|
|
88
|
-
console.log(' testforge generate --reference <file|url> Reconcile docs against a reference first');
|
|
89
|
-
console.log(' testforge discover Scan source code and draft journey files');
|
|
52
|
+
console.log(' testforge discover Draft journey docs (asks: map or reference)');
|
|
90
53
|
console.log(' testforge map Scan project, write journey + data map');
|
|
91
54
|
console.log(' testforge init Inspect project and write config');
|
|
92
|
-
console.log(' testforge setup Run init + map + discover in order');
|
|
93
55
|
console.log(' testforge update Re-check and update only what changed');
|
|
94
56
|
console.log(' testforge env Write .env.testing.example from docs');
|
|
95
57
|
}
|
|
58
|
+
async function main() {
|
|
59
|
+
if (command === 'update') {
|
|
60
|
+
await runUpdate('.');
|
|
61
|
+
}
|
|
62
|
+
else if (command === 'setup') {
|
|
63
|
+
await runSetup('.', sourceOptions());
|
|
64
|
+
}
|
|
65
|
+
else if (command === 'init') {
|
|
66
|
+
console.log('Inspecting your project to set up testforge...\n');
|
|
67
|
+
const output = runInit();
|
|
68
|
+
console.log(output);
|
|
69
|
+
console.log('\ntestforge.config.json written. Review it, then run `testforge map`.');
|
|
70
|
+
}
|
|
71
|
+
else if (command === 'map') {
|
|
72
|
+
console.log('Building a high-level map of your project with AI...');
|
|
73
|
+
console.log('This scans your source code and writes testforge.md\n');
|
|
74
|
+
const output = runMap('.');
|
|
75
|
+
console.log(output);
|
|
76
|
+
console.log('\nProject map written to testforge.md');
|
|
77
|
+
}
|
|
78
|
+
else if (command === 'discover') {
|
|
79
|
+
console.log('Drafting journey files with AI...');
|
|
80
|
+
console.log('This writes drafts into docs/ from your chosen source.\n');
|
|
81
|
+
const output = await runDiscover('.', 'docs', sourceOptions());
|
|
82
|
+
console.log(output);
|
|
83
|
+
console.log('\nDraft journeys written to docs/. Please review and edit them,');
|
|
84
|
+
console.log('then run `testforge generate`.');
|
|
85
|
+
}
|
|
86
|
+
else if (command === 'env') {
|
|
87
|
+
runEnvTemplate('docs', '.env.testing.example');
|
|
88
|
+
}
|
|
89
|
+
else if (command === 'generate') {
|
|
90
|
+
const useAI = !useBasic;
|
|
91
|
+
console.log(useAI
|
|
92
|
+
? 'Generating tests with AI for changed journeys...\n'
|
|
93
|
+
: 'Generating tests with the basic generator...\n');
|
|
94
|
+
const results = runGenerate('docs', useAI, force);
|
|
95
|
+
if (results.length === 0) {
|
|
96
|
+
console.log('No journey files found in docs/. Add a .md file and try again.');
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
for (const r of results) {
|
|
100
|
+
if (r.status === 'skipped') {
|
|
101
|
+
console.log(` [skip] ${r.journeyName} (unchanged)`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
const tag = r.status === 'ai' ? '[AI]' : '[basic]';
|
|
105
|
+
console.log(` ${tag} ${r.journeyName}`);
|
|
106
|
+
console.log(` test: ${r.testPath}`);
|
|
107
|
+
if (r.status === 'ai') {
|
|
108
|
+
if (r.seedStatus === 'ok') {
|
|
109
|
+
console.log(` seed: ${r.seedPath}`);
|
|
110
|
+
}
|
|
111
|
+
else if (r.seedStatus === 'failed') {
|
|
112
|
+
console.log(` seed: (not written — generation failed)`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const w of r.warnings ?? []) {
|
|
117
|
+
console.log(` ! ${w}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const generated = results.filter((r) => r.status !== 'skipped').length;
|
|
121
|
+
const skipped = results.filter((r) => r.status === 'skipped').length;
|
|
122
|
+
const seedFailures = results.filter((r) => r.seedStatus === 'failed').length;
|
|
123
|
+
console.log(`\nDone. ${generated} generated, ${skipped} skipped (unchanged).`);
|
|
124
|
+
if (seedFailures > 0) {
|
|
125
|
+
console.log(`Warning: ${seedFailures} seed file(s) could not be generated — see notes above.`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
printUsage();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
main().catch((err) => {
|
|
134
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
135
|
+
process.exit(1);
|
|
136
|
+
});
|
package/dist/core/config.js
CHANGED
|
@@ -127,6 +127,35 @@ export function withEditTools(cmd) {
|
|
|
127
127
|
args: [...cmd.args, '--permission-mode', 'acceptEdits'],
|
|
128
128
|
};
|
|
129
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Tools an external reference may need to be read: fetching a web page or
|
|
132
|
+
* a Notion doc. `acceptEdits` only auto-approves file edits, so a headless
|
|
133
|
+
* `claude -p` would otherwise be DENIED web/connector tools (no prompt to
|
|
134
|
+
* approve in non-interactive mode). We therefore pre-approve them.
|
|
135
|
+
*
|
|
136
|
+
* The connector must still be configured and authenticated in the user's
|
|
137
|
+
* own Claude CLI — this only grants permission to USE it headlessly. Both
|
|
138
|
+
* common Notion tool names are allow-listed (the claude.ai connector
|
|
139
|
+
* `claude_ai_Notion`, and a CLI-added server conventionally named
|
|
140
|
+
* `notion`). Non-Claude CLIs get the command unchanged.
|
|
141
|
+
*/
|
|
142
|
+
export function withReferenceTools(cmd) {
|
|
143
|
+
if (!isClaude(cmd.program))
|
|
144
|
+
return cmd;
|
|
145
|
+
const allow = [
|
|
146
|
+
'Read',
|
|
147
|
+
'Glob',
|
|
148
|
+
'Grep',
|
|
149
|
+
'WebFetch',
|
|
150
|
+
'WebSearch',
|
|
151
|
+
'mcp__notion__*',
|
|
152
|
+
'mcp__claude_ai_Notion__*',
|
|
153
|
+
].join(' ');
|
|
154
|
+
return {
|
|
155
|
+
program: cmd.program,
|
|
156
|
+
args: [...cmd.args, '--permission-mode', 'acceptEdits', '--allowedTools', allow],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
130
159
|
/**
|
|
131
160
|
* Decide where a journey's test file should go, based on its type
|
|
132
161
|
* and the config. Falls back to "generated-tests/<area>" when the
|
package/dist/core/discover.js
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { execFileSync } from 'node:child_process';
|
|
18
18
|
import { loadConfig, splitCommand, withEditTools } from './config.js';
|
|
19
|
+
import { promptConfirm } from './prompt.js';
|
|
20
|
+
import { resolveDocsSource, saveSourceChoice, } from './source-of-truth.js';
|
|
21
|
+
import { analyzeReference, draftDocsFromReference, DIFF_PATH, } from './reference.js';
|
|
19
22
|
/** Build the discovery prompt. */
|
|
20
23
|
export function buildDiscoverPrompt(srcDir, docsDir) {
|
|
21
24
|
return [
|
|
@@ -83,11 +86,10 @@ export function buildDiscoverPrompt(srcDir, docsDir) {
|
|
|
83
86
|
].join('\n');
|
|
84
87
|
}
|
|
85
88
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* summary of what it created).
|
|
89
|
+
* Draft the docs from the project's own code (the default). Invokes the
|
|
90
|
+
* AI CLI with permission to read source and write markdown files.
|
|
89
91
|
*/
|
|
90
|
-
|
|
92
|
+
function runCodeDiscover(srcDir, docsDir) {
|
|
91
93
|
const prompt = buildDiscoverPrompt(srcDir, docsDir);
|
|
92
94
|
// Use the configured AI command. Note: discovery needs the AI to
|
|
93
95
|
// read source files and write markdown files. The flags for that
|
|
@@ -101,3 +103,39 @@ export function runDiscover(srcDir, docsDir) {
|
|
|
101
103
|
});
|
|
102
104
|
return output.trim();
|
|
103
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Run discovery. First decides the source of truth for the docs (the
|
|
108
|
+
* code-derived map, or an external reference — see source-of-truth.ts),
|
|
109
|
+
* then drafts the journey docs from it. When a reference is chosen it is
|
|
110
|
+
* compared against the map first (writing testforge.diff.md), and, for a
|
|
111
|
+
* fresh interactive choice, confirmed before the docs are rewritten.
|
|
112
|
+
*
|
|
113
|
+
* Async because the source-of-truth barrier may prompt the developer.
|
|
114
|
+
* Returns the AI's text summary of what it created.
|
|
115
|
+
*/
|
|
116
|
+
export async function runDiscover(srcDir, docsDir, opts = {}) {
|
|
117
|
+
const source = await resolveDocsSource(opts);
|
|
118
|
+
if (source.mode === 'reference' && source.reference) {
|
|
119
|
+
console.log(`\nComparing the reference against testforge.md: ${source.reference}`);
|
|
120
|
+
console.log('(reading the reference and the map — this may take a moment)\n');
|
|
121
|
+
const summary = analyzeReference(source.reference, docsDir);
|
|
122
|
+
console.log(summary);
|
|
123
|
+
console.log(`\nFull comparison written to ${DIFF_PATH}.`);
|
|
124
|
+
// Only a freshly-typed interactive choice gets a confirmation step;
|
|
125
|
+
// an explicit flag or a remembered choice proceeds directly.
|
|
126
|
+
if (source.origin === 'prompt') {
|
|
127
|
+
const ok = await promptConfirm('\nDraft the docs from this reference (make it the source of truth)?', true);
|
|
128
|
+
if (!ok) {
|
|
129
|
+
console.log('Basing docs on testforge.md instead.\n');
|
|
130
|
+
saveSourceChoice({ mode: 'map' });
|
|
131
|
+
return runCodeDiscover(srcDir, docsDir);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
console.log('\nDrafting docs from the reference...\n');
|
|
135
|
+
const out = draftDocsFromReference(srcDir, docsDir, source.reference);
|
|
136
|
+
saveSourceChoice({ mode: 'reference', reference: source.reference });
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
saveSourceChoice({ mode: 'map' });
|
|
140
|
+
return runCodeDiscover(srcDir, docsDir);
|
|
141
|
+
}
|
package/dist/core/index.js
CHANGED
|
@@ -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, withReferenceTools } 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 (+ Notion) access. */
|
|
30
|
+
function runAI(prompt) {
|
|
31
|
+
const { program, args } = withReferenceTools(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
|
+
}
|
package/dist/core/setup.js
CHANGED
|
@@ -16,7 +16,7 @@ import { runInit } from './init.js';
|
|
|
16
16
|
import { runMap } from './map.js';
|
|
17
17
|
import { runDiscover } from './discover.js';
|
|
18
18
|
import { runEnvTemplate } from './env-template.js';
|
|
19
|
-
export function runSetup(srcDir) {
|
|
19
|
+
export async function runSetup(srcDir, opts = {}) {
|
|
20
20
|
// If the project already looks set up, don't redo it — point the user
|
|
21
21
|
// to `update` instead, which only refreshes what actually changed.
|
|
22
22
|
const alreadySetUp = existsSync('testforge.config.json') &&
|
|
@@ -49,9 +49,10 @@ export function runSetup(srcDir) {
|
|
|
49
49
|
else {
|
|
50
50
|
console.log(' warning: testforge.md was not created\n');
|
|
51
51
|
}
|
|
52
|
-
// Step 3 — discover.
|
|
52
|
+
// Step 3 — discover. This is where the source-of-truth barrier lives:
|
|
53
|
+
// the docs may be based on testforge.md or on an external reference.
|
|
53
54
|
console.log('Step 3/3: Drafting a doc for each journey...');
|
|
54
|
-
runDiscover(srcDir, 'docs');
|
|
55
|
+
await runDiscover(srcDir, 'docs', opts);
|
|
55
56
|
console.log(' done — docs/\n');
|
|
56
57
|
// Remember the source state so the first `update` can detect "no change".
|
|
57
58
|
const cache = loadCache('.testforge/cache.json');
|
|
@@ -1,95 +1,84 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* testforge —
|
|
2
|
+
* testforge — deciding where the journey docs come from.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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.
|
|
4
|
+
* The docs are the source of truth for the tests, so before drafting
|
|
5
|
+
* them (in discover / setup) testforge asks WHAT to base them on:
|
|
9
6
|
*
|
|
7
|
+
* 1. testforge.md — the AI's code-derived view. Fast, but only as
|
|
8
|
+
* correct as the code it read.
|
|
10
9
|
* 2. An external reference — a spec/doc/PDF in the repo, a website, or
|
|
11
|
-
* a Notion page
|
|
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.
|
|
10
|
+
* a Notion page capturing the human/client EXPECTED behavior.
|
|
16
11
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
12
|
+
* The choice is remembered in .testforge/source.json so later refreshes
|
|
13
|
+
* (`update`) keep using the same source without asking again — otherwise
|
|
14
|
+
* an update would silently regenerate reference-based docs from the code.
|
|
20
15
|
*
|
|
21
|
-
* Non-interactive callers
|
|
22
|
-
*
|
|
23
|
-
* -
|
|
24
|
-
* -
|
|
25
|
-
*
|
|
26
|
-
* - neither, and no TTY → fall back to docs with a printed note.
|
|
16
|
+
* Non-interactive callers are never blocked:
|
|
17
|
+
* - source: 'map' → use testforge.md, skip the prompt.
|
|
18
|
+
* - reference: <locator> → use that reference, skip the prompt.
|
|
19
|
+
* - noPrompt (e.g. update) → reuse the remembered choice, else the map.
|
|
20
|
+
* - no TTY → fall back to the map with a printed note.
|
|
27
21
|
*/
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
22
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
23
|
+
import { dirname } from 'node:path';
|
|
24
|
+
import { isInteractive, promptChoice, promptLine } from './prompt.js';
|
|
25
|
+
const SOURCE_PATH = '.testforge/source.json';
|
|
26
|
+
/** Read the remembered source choice, or null if none/invalid. */
|
|
27
|
+
export function loadSourceChoice() {
|
|
28
|
+
if (!existsSync(SOURCE_PATH))
|
|
29
|
+
return null;
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(readFileSync(SOURCE_PATH, 'utf-8'));
|
|
32
|
+
if (parsed && (parsed.mode === 'map' || parsed.mode === 'reference')) {
|
|
33
|
+
return { mode: parsed.mode, reference: parsed.reference };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// ignore a corrupt file — treat as "no choice remembered"
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
/** Remember the source choice for later refreshes. */
|
|
42
|
+
export function saveSourceChoice(choice) {
|
|
43
|
+
mkdirSync(dirname(SOURCE_PATH), { recursive: true });
|
|
44
|
+
writeFileSync(SOURCE_PATH, JSON.stringify(choice, null, 2), 'utf-8');
|
|
41
45
|
}
|
|
42
46
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* chosen source; the caller then runs the normal generate pipeline.
|
|
47
|
+
* Decide where the docs should come from for this run. May prompt the
|
|
48
|
+
* developer interactively; never blocks in non-interactive contexts.
|
|
46
49
|
*/
|
|
47
|
-
export async function
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return;
|
|
52
|
-
// Explicit reference via flag: non-interactive analyze + apply.
|
|
50
|
+
export async function resolveDocsSource(opts = {}) {
|
|
51
|
+
if (opts.source === 'map') {
|
|
52
|
+
return { mode: 'map', origin: 'flag' };
|
|
53
|
+
}
|
|
53
54
|
if (opts.reference) {
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
return { mode: 'reference', reference: opts.reference, origin: 'flag' };
|
|
56
|
+
}
|
|
57
|
+
// Reuse a previously remembered choice (so `update` stays consistent).
|
|
58
|
+
const stored = loadSourceChoice();
|
|
59
|
+
if (stored) {
|
|
60
|
+
return { ...stored, origin: 'stored' };
|
|
56
61
|
}
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
// Nothing chosen and nowhere to ask — safe default.
|
|
63
|
+
if (opts.noPrompt || !isInteractive()) {
|
|
64
|
+
if (!opts.noPrompt) {
|
|
65
|
+
console.log('Basing docs on testforge.md (non-interactive; pass ' +
|
|
66
|
+
'--reference <file|url> to use an external reference).');
|
|
67
|
+
}
|
|
68
|
+
return { mode: 'map', origin: 'default' };
|
|
63
69
|
}
|
|
64
|
-
// Interactive: ask what
|
|
65
|
-
const choice = await promptChoice('What should the
|
|
66
|
-
'testforge.md
|
|
70
|
+
// Interactive: ask what the docs should be based on.
|
|
71
|
+
const choice = await promptChoice('What should the journey docs be based on?', [
|
|
72
|
+
'testforge.md (what the AI derived from your code)',
|
|
67
73
|
'An external reference (a file/PDF in scope, a website, or a Notion link)',
|
|
68
74
|
], 1);
|
|
69
75
|
if (choice === 1) {
|
|
70
|
-
|
|
71
|
-
return;
|
|
76
|
+
return { mode: 'map', origin: 'prompt' };
|
|
72
77
|
}
|
|
73
78
|
const reference = await promptLine('\nProvide the reference (a file path, a URL, or a Notion link): ');
|
|
74
79
|
if (!reference) {
|
|
75
|
-
console.log('No reference given —
|
|
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;
|
|
80
|
+
console.log('No reference given — basing docs on testforge.md.\n');
|
|
81
|
+
return { mode: 'map', origin: 'prompt' };
|
|
90
82
|
}
|
|
91
|
-
|
|
92
|
-
const applied = applyReconciliation(reference, docsDir);
|
|
93
|
-
console.log(applied);
|
|
94
|
-
console.log('\nDocs updated. Generating tests from the reconciled docs.\n');
|
|
83
|
+
return { mode: 'reference', reference, origin: 'prompt' };
|
|
95
84
|
}
|
package/dist/core/update.js
CHANGED
|
@@ -16,7 +16,7 @@ import { hashProjectSource, loadCache, saveCache } from './cache.js';
|
|
|
16
16
|
import { runEnvTemplate } from './env-template.js';
|
|
17
17
|
const CACHE_PATH = '.testforge/cache.json';
|
|
18
18
|
const SOURCE_KEY = '__source__';
|
|
19
|
-
export function runUpdate(srcDir) {
|
|
19
|
+
export async function runUpdate(srcDir) {
|
|
20
20
|
if (!existsSync('testforge.md') || !existsSync('testforge.config.json')) {
|
|
21
21
|
console.log("This project isn't set up yet.");
|
|
22
22
|
console.log('Run `testforge setup` first.');
|
|
@@ -35,8 +35,10 @@ export function runUpdate(srcDir) {
|
|
|
35
35
|
console.log('Re-mapping the project...');
|
|
36
36
|
runMap(srcDir);
|
|
37
37
|
console.log(' done — testforge.md\n');
|
|
38
|
+
// Reuse the source of truth chosen at setup (map or a reference)
|
|
39
|
+
// without prompting — an update is a refresh, not a re-decision.
|
|
38
40
|
console.log('Updating journey docs...');
|
|
39
|
-
runDiscover(srcDir, 'docs');
|
|
41
|
+
await runDiscover(srcDir, 'docs', { noPrompt: true });
|
|
40
42
|
console.log(' done — docs/\n');
|
|
41
43
|
console.log('Regenerating tests for changed journeys...');
|
|
42
44
|
const results = runGenerate('docs', true, false);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@activate-core/test-forge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "AI-powered QA automation. Scans a project, drafts journey docs, and generates seeded Jest/Playwright tests. Works in any project, with any AI CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* testforge — the `generate` command (CLI orchestration).
|
|
3
|
-
*
|
|
4
|
-
* This ties together the source-of-truth barrier and the generate
|
|
5
|
-
* pipeline, and prints results for the terminal. It is async because the
|
|
6
|
-
* barrier may prompt the developer interactively (see source-of-truth.ts).
|
|
7
|
-
*
|
|
8
|
-
* Kept separate from the pure runGenerate() engine so that engine stays
|
|
9
|
-
* synchronous and easy to call from update.ts, while the human-facing
|
|
10
|
-
* command handles prompting and output.
|
|
11
|
-
*/
|
|
12
|
-
import { runGenerate } from './run-generate.js';
|
|
13
|
-
import { resolveSourceOfTruth } from './source-of-truth.js';
|
|
14
|
-
export async function runGenerateCommand(opts) {
|
|
15
|
-
// 1. Decide (and possibly reconcile) the source of truth first.
|
|
16
|
-
await resolveSourceOfTruth({
|
|
17
|
-
docsDir: opts.docsDir,
|
|
18
|
-
reference: opts.reference,
|
|
19
|
-
source: opts.source,
|
|
20
|
-
});
|
|
21
|
-
// 2. Generate from whatever the docs now say.
|
|
22
|
-
const results = runGenerate(opts.docsDir, opts.useAI, opts.force);
|
|
23
|
-
if (results.length === 0) {
|
|
24
|
-
console.log(`No journey files found in ${opts.docsDir}/. Add a .md file and try again.`);
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
for (const r of results) {
|
|
28
|
-
if (r.status === 'skipped') {
|
|
29
|
-
console.log(` [skip] ${r.journeyName} (unchanged)`);
|
|
30
|
-
}
|
|
31
|
-
else {
|
|
32
|
-
const tag = r.status === 'ai' ? '[AI]' : '[basic]';
|
|
33
|
-
console.log(` ${tag} ${r.journeyName}`);
|
|
34
|
-
console.log(` test: ${r.testPath}`);
|
|
35
|
-
if (r.status === 'ai') {
|
|
36
|
-
if (r.seedStatus === 'ok') {
|
|
37
|
-
console.log(` seed: ${r.seedPath}`);
|
|
38
|
-
}
|
|
39
|
-
else if (r.seedStatus === 'failed') {
|
|
40
|
-
console.log(` seed: (not written — generation failed)`);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
for (const w of r.warnings ?? []) {
|
|
45
|
-
console.log(` ! ${w}`);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
const generated = results.filter((r) => r.status !== 'skipped').length;
|
|
49
|
-
const skipped = results.filter((r) => r.status === 'skipped').length;
|
|
50
|
-
const seedFailures = results.filter((r) => r.seedStatus === 'failed').length;
|
|
51
|
-
console.log(`\nDone. ${generated} generated, ${skipped} skipped (unchanged).`);
|
|
52
|
-
if (seedFailures > 0) {
|
|
53
|
-
console.log(`Warning: ${seedFailures} seed file(s) could not be generated — see notes above.`);
|
|
54
|
-
}
|
|
55
|
-
}
|
package/dist/core/reconcile.js
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
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
|
-
}
|