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