@activate-core/test-forge 0.1.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 +179 -0
- package/dist/cli/index.js +91 -0
- package/dist/core/ai-generator.js +141 -0
- package/dist/core/cache.js +105 -0
- package/dist/core/config.js +100 -0
- package/dist/core/discover.js +103 -0
- package/dist/core/env-template.js +107 -0
- package/dist/core/find-files.js +34 -0
- package/dist/core/generator.js +90 -0
- package/dist/core/index.js +7 -0
- package/dist/core/init.js +67 -0
- package/dist/core/map.js +90 -0
- package/dist/core/model.js +9 -0
- package/dist/core/parser.js +121 -0
- package/dist/core/playwright-writer.js +47 -0
- package/dist/core/run-generate.js +90 -0
- package/dist/core/security-payloads.js +24 -0
- package/dist/core/seed-generator.js +80 -0
- package/dist/core/setup.js +68 -0
- package/dist/core/update.js +52 -0
- package/package.json +27 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — journey discovery.
|
|
3
|
+
*
|
|
4
|
+
* Scans a project's source code using the local Claude CLI and asks
|
|
5
|
+
* it to identify user journeys (login, signup, checkout, etc.), then
|
|
6
|
+
* write a draft journey markdown file for each one into docs/.
|
|
7
|
+
*
|
|
8
|
+
* Important: discovery produces a DRAFT, not the final truth. The AI
|
|
9
|
+
* will miss some journeys and may guess wrong on others. A developer
|
|
10
|
+
* is expected to review and fix the generated markdown before relying
|
|
11
|
+
* on it (this matches the "human review" step of the workflow).
|
|
12
|
+
*
|
|
13
|
+
* We let the Claude CLI read the files itself (it has file-reading
|
|
14
|
+
* tools) rather than stuffing all source code into the prompt, which
|
|
15
|
+
* keeps this simple and works for projects of any size.
|
|
16
|
+
*/
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import { loadConfig, splitCommand } from './config.js';
|
|
19
|
+
/** Build the discovery prompt. */
|
|
20
|
+
export function buildDiscoverPrompt(srcDir, docsDir) {
|
|
21
|
+
return [
|
|
22
|
+
`You are a senior QA engineer creating per-journey documentation`,
|
|
23
|
+
`for a project, so a QA tool can generate tests from it.`,
|
|
24
|
+
``,
|
|
25
|
+
`First, read the project map at "testforge.md" if it exists. It`,
|
|
26
|
+
`lists the project's journeys (grouped by area) and its data layer`,
|
|
27
|
+
`(database, models, key fields, enums). Use the map as your guide`,
|
|
28
|
+
`to what exists, so you do not have to re-scan the whole codebase`,
|
|
29
|
+
`from scratch. Read specific source files under "${srcDir}" only`,
|
|
30
|
+
`when you need detail for a particular journey.`,
|
|
31
|
+
``,
|
|
32
|
+
`For EACH journey in the map, create one markdown file inside an`,
|
|
33
|
+
`area subfolder of "${docsDir}". The area is the journey's area from`,
|
|
34
|
+
`the map (for example "api", "web", "ops" — whatever areas the`,
|
|
35
|
+
`project has). Put the file at "${docsDir}/<area>/<journey>.md" using`,
|
|
36
|
+
`a short kebab-case journey name. For example:`,
|
|
37
|
+
` "${docsDir}/api/payments.md"`,
|
|
38
|
+
` "${docsDir}/web/sign-in.md"`,
|
|
39
|
+
` "${docsDir}/ops/login.md"`,
|
|
40
|
+
`Do NOT put area in the file name (no "api-payments.md") — the area`,
|
|
41
|
+
`is the folder. Create the area subfolders as needed.`,
|
|
42
|
+
``,
|
|
43
|
+
`Each file should be a RICH description of the journey. Start with`,
|
|
44
|
+
`this header (required):`,
|
|
45
|
+
``,
|
|
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`,
|
|
54
|
+
` (add more if relevant: performance, accessibility, session,`,
|
|
55
|
+
` race-condition, payment, webhook, data-integrity)`,
|
|
56
|
+
``,
|
|
57
|
+
`Then include whichever of these sections actually apply (skip the`,
|
|
58
|
+
`ones that do not):`,
|
|
59
|
+
``,
|
|
60
|
+
`Fields: each input as: <name> (<type>, required|optional)`,
|
|
61
|
+
`Actions: numbered steps the user/caller takes`,
|
|
62
|
+
`Expected: what should happen on success`,
|
|
63
|
+
`Negative Cases: things that should be rejected or fail safely`,
|
|
64
|
+
`Race Conditions: concurrency risks (e.g. double-submit, duplicate charge)`,
|
|
65
|
+
`Webhooks: any webhooks involved and what must hold (idempotency, signatures)`,
|
|
66
|
+
`Security Notes: specific threats to test for this journey`,
|
|
67
|
+
`Data: which models/collections this journey reads or writes,`,
|
|
68
|
+
` and the key fields/enum values a test would need to`,
|
|
69
|
+
` seed or verify (use the data layer from the map)`,
|
|
70
|
+
``,
|
|
71
|
+
`Rules:`,
|
|
72
|
+
`- Base everything on real evidence in the code and the map. Do not`,
|
|
73
|
+
` invent routes, fields, or behavior with false confidence.`,
|
|
74
|
+
`- Where you are unsure, add a line starting with "# TODO:"`,
|
|
75
|
+
` describing what the developer must verify.`,
|
|
76
|
+
`- For the Data section, never include secrets — refer to env var`,
|
|
77
|
+
` names only (as in the map).`,
|
|
78
|
+
`- Capture real risks you notice (e.g. a charge that could happen`,
|
|
79
|
+
` twice, a missing webhook signature check, an unvalidated amount).`,
|
|
80
|
+
`- Create the docs directory if it does not exist.`,
|
|
81
|
+
`- After creating the files, print the list of journeys you documented`,
|
|
82
|
+
` and remind the developer to review them.`,
|
|
83
|
+
].join('\n');
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Run discovery. This invokes the Claude CLI with permission to read
|
|
87
|
+
* and write files in the project. Returns the CLI's text output (the
|
|
88
|
+
* summary of what it created).
|
|
89
|
+
*/
|
|
90
|
+
export function runDiscover(srcDir, docsDir) {
|
|
91
|
+
const prompt = buildDiscoverPrompt(srcDir, docsDir);
|
|
92
|
+
// Use the configured AI command. Note: discovery needs the AI to
|
|
93
|
+
// read source files and write markdown files. The flags for that
|
|
94
|
+
// are tool-specific; for Claude we add acceptEdits. Other tools may
|
|
95
|
+
// need different setup (see README).
|
|
96
|
+
const { program, args } = splitCommand(loadConfig().aiCommand);
|
|
97
|
+
const output = execFileSync(program, [...args, '--permission-mode', 'acceptEdits'], {
|
|
98
|
+
input: prompt,
|
|
99
|
+
encoding: 'utf-8',
|
|
100
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
101
|
+
});
|
|
102
|
+
return output.trim();
|
|
103
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — env template builder.
|
|
3
|
+
*
|
|
4
|
+
* Scans the journey docs (and the project map) for environment variable
|
|
5
|
+
* NAMES that the journeys reference, and writes a single
|
|
6
|
+
* .env.testing.example. Variables are grouped by area (api / web / ops
|
|
7
|
+
* / ...) and each one is annotated with the journeys that use it, so a
|
|
8
|
+
* developer knows exactly which credentials they need for which feature
|
|
9
|
+
* — and can skip the ones for features they don't run.
|
|
10
|
+
*
|
|
11
|
+
* Only names are collected, never values. The developer fills in real
|
|
12
|
+
* values in their own .env.testing (which stays out of version control).
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
15
|
+
import { findMarkdownFiles } from './find-files.js';
|
|
16
|
+
/** Pull env var names from a chunk of text. */
|
|
17
|
+
function extractEnvVars(text) {
|
|
18
|
+
const vars = new Set();
|
|
19
|
+
// process.env.XXX
|
|
20
|
+
for (const m of text.matchAll(/process\.env\.([A-Z0-9_]+)/gi)) {
|
|
21
|
+
vars.add(m[1]);
|
|
22
|
+
}
|
|
23
|
+
// backtick-wrapped ALL_CAPS names in "env var" lines
|
|
24
|
+
for (const line of text.split('\n')) {
|
|
25
|
+
if (/env var|secret|credential/i.test(line)) {
|
|
26
|
+
for (const m of line.matchAll(/`([A-Z][A-Z0-9_]{2,})`/g)) {
|
|
27
|
+
vars.add(m[1]);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return vars;
|
|
32
|
+
}
|
|
33
|
+
/** Derive "area" and "journey" from a doc file name like web-payments.md */
|
|
34
|
+
function areaAndJourney(file) {
|
|
35
|
+
// file is a relative path like "api/payments.md" (folder = area).
|
|
36
|
+
const noExt = file.replace(/\.md$/, '');
|
|
37
|
+
const parts = noExt.split('/');
|
|
38
|
+
if (parts.length >= 2) {
|
|
39
|
+
return { area: parts[0], journey: parts.slice(1).join('/') };
|
|
40
|
+
}
|
|
41
|
+
// Fallback: flat file with no area folder.
|
|
42
|
+
return { area: 'general', journey: noExt };
|
|
43
|
+
}
|
|
44
|
+
export function runEnvTemplate(docsDir, outPath) {
|
|
45
|
+
if (!existsSync(docsDir)) {
|
|
46
|
+
console.log(`No docs found at "${docsDir}". Run \`testforge setup\` first.`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const files = findMarkdownFiles(docsDir);
|
|
50
|
+
// area -> var -> set of journeys
|
|
51
|
+
const byArea = new Map();
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
const { area, journey } = areaAndJourney(file);
|
|
54
|
+
const vars = extractEnvVars(readFileSync(`${docsDir}/${file}`, 'utf-8'));
|
|
55
|
+
if (vars.size === 0)
|
|
56
|
+
continue;
|
|
57
|
+
if (!byArea.has(area))
|
|
58
|
+
byArea.set(area, new Map());
|
|
59
|
+
const areaMap = byArea.get(area);
|
|
60
|
+
for (const v of vars) {
|
|
61
|
+
if (!areaMap.has(v))
|
|
62
|
+
areaMap.set(v, new Set());
|
|
63
|
+
areaMap.get(v).add(journey);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Also include the project map's vars (no area — call it "shared").
|
|
67
|
+
if (existsSync('testforge.md')) {
|
|
68
|
+
const mapVars = extractEnvVars(readFileSync('testforge.md', 'utf-8'));
|
|
69
|
+
if (mapVars.size > 0) {
|
|
70
|
+
if (!byArea.has('shared'))
|
|
71
|
+
byArea.set('shared', new Map());
|
|
72
|
+
const shared = byArea.get('shared');
|
|
73
|
+
for (const v of mapVars) {
|
|
74
|
+
if (!shared.has(v))
|
|
75
|
+
shared.set(v, new Set(['project map']));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (byArea.size === 0) {
|
|
80
|
+
console.log('No environment variables found in the docs.');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Build the file.
|
|
84
|
+
const lines = [
|
|
85
|
+
'# .env.testing.example',
|
|
86
|
+
'#',
|
|
87
|
+
'# Test credentials the generated tests/seeds need, grouped by area.',
|
|
88
|
+
'# Copy this to .env.testing and fill in real values.',
|
|
89
|
+
'# Only variables for the features you actually run are required.',
|
|
90
|
+
'# Keep .env.testing OUT of version control.',
|
|
91
|
+
'',
|
|
92
|
+
];
|
|
93
|
+
for (const area of [...byArea.keys()].sort()) {
|
|
94
|
+
lines.push(`# ${'='.repeat(40)}`);
|
|
95
|
+
lines.push(`# ${area.toUpperCase()}`);
|
|
96
|
+
lines.push(`# ${'='.repeat(40)}`);
|
|
97
|
+
const areaMap = byArea.get(area);
|
|
98
|
+
for (const v of [...areaMap.keys()].sort()) {
|
|
99
|
+
const journeys = [...areaMap.get(v)].sort().join(', ');
|
|
100
|
+
lines.push(`${v}= # used by: ${journeys}`);
|
|
101
|
+
}
|
|
102
|
+
lines.push('');
|
|
103
|
+
}
|
|
104
|
+
writeFileSync(outPath, lines.join('\n'), 'utf-8');
|
|
105
|
+
console.log(`Env template written to ${outPath}`);
|
|
106
|
+
console.log('Copy it to .env.testing and fill in real values.');
|
|
107
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — recursive markdown finder.
|
|
3
|
+
*
|
|
4
|
+
* Walks a docs directory and finds every .md file inside it, however
|
|
5
|
+
* deeply nested. For each file it returns the path relative to the
|
|
6
|
+
* docs root, so the same folder structure can be recreated in the
|
|
7
|
+
* output. For example, docs/api/create.md -> "api/create.md".
|
|
8
|
+
*
|
|
9
|
+
* This is what lets a project keep separate journeys for, say, an
|
|
10
|
+
* api/ folder and a webapp/ folder, and get matching test folders.
|
|
11
|
+
*/
|
|
12
|
+
import { readdirSync } from 'node:fs';
|
|
13
|
+
import { join, relative } from 'node:path';
|
|
14
|
+
/**
|
|
15
|
+
* Return the relative paths of all .md files under rootDir.
|
|
16
|
+
* Example result: ["api/create.md", "webapp/sign-in.md"]
|
|
17
|
+
*/
|
|
18
|
+
export function findMarkdownFiles(rootDir) {
|
|
19
|
+
const results = [];
|
|
20
|
+
function walk(dir) {
|
|
21
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
const full = join(dir, entry.name);
|
|
24
|
+
if (entry.isDirectory()) {
|
|
25
|
+
walk(full); // go deeper into sub-folders
|
|
26
|
+
}
|
|
27
|
+
else if (entry.name.endsWith('.md')) {
|
|
28
|
+
results.push(relative(rootDir, full));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
walk(rootDir);
|
|
33
|
+
return results;
|
|
34
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — functional test case generator.
|
|
3
|
+
*
|
|
4
|
+
* Takes a parsed Journey and produces a list of test cases.
|
|
5
|
+
* For now this generates functional cases only (positive and
|
|
6
|
+
* negative) based on each field. Security, performance, and
|
|
7
|
+
* actual Playwright/Jest code come in later steps.
|
|
8
|
+
*
|
|
9
|
+
* The idea: a developer writes a few lines of markdown, and one
|
|
10
|
+
* field expands into several meaningful test cases automatically.
|
|
11
|
+
*/
|
|
12
|
+
import { SECURITY_PAYLOADS } from './security-payloads.js';
|
|
13
|
+
/** Generate test cases for a single field. */
|
|
14
|
+
function casesForField(journeyId, field) {
|
|
15
|
+
const cases = [];
|
|
16
|
+
// If the field is required, an empty value should fail.
|
|
17
|
+
if (field.required) {
|
|
18
|
+
cases.push({
|
|
19
|
+
journeyId,
|
|
20
|
+
title: `${field.name}: empty value should be rejected`,
|
|
21
|
+
expect: 'fail',
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
// Type-specific cases.
|
|
25
|
+
if (field.type === 'email') {
|
|
26
|
+
cases.push({
|
|
27
|
+
journeyId,
|
|
28
|
+
title: `${field.name}: valid email should be accepted`,
|
|
29
|
+
expect: 'pass',
|
|
30
|
+
});
|
|
31
|
+
cases.push({
|
|
32
|
+
journeyId,
|
|
33
|
+
title: `${field.name}: invalid email format should be rejected`,
|
|
34
|
+
expect: 'fail',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (field.type === 'password') {
|
|
38
|
+
cases.push({
|
|
39
|
+
journeyId,
|
|
40
|
+
title: `${field.name}: valid password should be accepted`,
|
|
41
|
+
expect: 'pass',
|
|
42
|
+
});
|
|
43
|
+
cases.push({
|
|
44
|
+
journeyId,
|
|
45
|
+
title: `${field.name}: too short password should be rejected`,
|
|
46
|
+
expect: 'fail',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (field.type === 'text' || field.type === 'number') {
|
|
50
|
+
cases.push({
|
|
51
|
+
journeyId,
|
|
52
|
+
title: `${field.name}: valid value should be accepted`,
|
|
53
|
+
expect: 'pass',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return cases;
|
|
57
|
+
}
|
|
58
|
+
/** Generate all functional test cases for a journey. */
|
|
59
|
+
export function generateTestCases(journey) {
|
|
60
|
+
const cases = [];
|
|
61
|
+
// One happy-path case: everything filled correctly.
|
|
62
|
+
cases.push({
|
|
63
|
+
journeyId: journey.id,
|
|
64
|
+
title: `${journey.name}: successful flow with all valid inputs`,
|
|
65
|
+
expect: 'pass',
|
|
66
|
+
});
|
|
67
|
+
// Then per-field cases.
|
|
68
|
+
for (const field of journey.fields) {
|
|
69
|
+
cases.push(...casesForField(journey.id, field));
|
|
70
|
+
}
|
|
71
|
+
// Security cases — only if this journey asked for security coverage.
|
|
72
|
+
if (journey.coverage.includes('security')) {
|
|
73
|
+
for (const field of journey.fields) {
|
|
74
|
+
// Only attack text-like fields (skip numbers).
|
|
75
|
+
const textLike = field.type === 'text' ||
|
|
76
|
+
field.type === 'email' ||
|
|
77
|
+
field.type === 'password';
|
|
78
|
+
if (!textLike)
|
|
79
|
+
continue;
|
|
80
|
+
for (const attack of SECURITY_PAYLOADS) {
|
|
81
|
+
cases.push({
|
|
82
|
+
journeyId: journey.id,
|
|
83
|
+
title: `${field.name}: ${attack.name} should be rejected`,
|
|
84
|
+
expect: 'fail',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return cases;
|
|
90
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — init command.
|
|
3
|
+
*
|
|
4
|
+
* Scans the project with the AI and writes a testforge.config.json
|
|
5
|
+
* tailored to the project's layout: where api vs web tests should go,
|
|
6
|
+
* where seed files belong, and the test file extension the project
|
|
7
|
+
* uses. The goal is that a user never hand-writes this config — init
|
|
8
|
+
* figures it out from the existing structure.
|
|
9
|
+
*
|
|
10
|
+
* Nothing is hardcoded for a specific framework or layout; the AI
|
|
11
|
+
* infers it from what the project actually looks like.
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import { loadConfig, splitCommand } from './config.js';
|
|
15
|
+
/** Build the prompt that asks the AI to write testforge.config.json. */
|
|
16
|
+
export function buildInitPrompt() {
|
|
17
|
+
return [
|
|
18
|
+
`You are setting up the QA tool "testforge" in this project. Inspect`,
|
|
19
|
+
`the project's structure and write a configuration file named`,
|
|
20
|
+
`"testforge.config.json" at the project root.`,
|
|
21
|
+
``,
|
|
22
|
+
`Look at how the project is organised and where tests already live`,
|
|
23
|
+
`(or should live), so generated tests land in the right place and`,
|
|
24
|
+
`match the project's conventions. Determine:`,
|
|
25
|
+
``,
|
|
26
|
+
`- testOutput: an object mapping each AREA of the project to a`,
|
|
27
|
+
` folder. Areas are not a fixed list — use whatever this project`,
|
|
28
|
+
` actually has (e.g. "api", "web", "ops", "lambda", "worker").`,
|
|
29
|
+
` Point each to where that area's tests belong (e.g. in a monorepo,`,
|
|
30
|
+
` api -> "packages/api/test", web -> "packages/webapp/tests", ops ->`,
|
|
31
|
+
` "packages/ops/tests"; in a simple app, one area mapping to "tests").`,
|
|
32
|
+
`- framework: an object mapping each area to its test framework`,
|
|
33
|
+
` (e.g. { "api": "jest", "web": "playwright", "ops": "playwright" }),`,
|
|
34
|
+
` based on what the project already uses for that area.`,
|
|
35
|
+
` the api tests, e.g. "packages/api/test/helpers/seed").`,
|
|
36
|
+
`- testExtension: ".test.ts" or ".test.js" — match what existing`,
|
|
37
|
+
` tests use, or the project's language (TypeScript vs JavaScript).`,
|
|
38
|
+
`- aiCommand: keep it as "claude -p" unless there is a clear reason`,
|
|
39
|
+
` to change it.`,
|
|
40
|
+
``,
|
|
41
|
+
`Write valid JSON only, in this shape:`,
|
|
42
|
+
``,
|
|
43
|
+
`{`,
|
|
44
|
+
` "aiCommand": "claude -p",`,
|
|
45
|
+
` "testOutput": { "<area>": "<path>" },`,
|
|
46
|
+
` "framework": { "<area>": "<jest|playwright|...>" },`,
|
|
47
|
+
` "seedOutput": "<path>",`,
|
|
48
|
+
` "testExtension": ".test.ts"`,
|
|
49
|
+
`}`,
|
|
50
|
+
``,
|
|
51
|
+
`Base every path on real evidence in the project (existing test`,
|
|
52
|
+
`folders, package layout, tsconfig presence). Create the file.`,
|
|
53
|
+
`After writing it, print a short summary of the choices you made and`,
|
|
54
|
+
`why, so the developer can confirm or adjust.`,
|
|
55
|
+
].join('\n');
|
|
56
|
+
}
|
|
57
|
+
/** Run init via the configured AI command. */
|
|
58
|
+
export function runInit() {
|
|
59
|
+
const prompt = buildInitPrompt();
|
|
60
|
+
const { program, args } = splitCommand(loadConfig().aiCommand);
|
|
61
|
+
const output = execFileSync(program, [...args, '--permission-mode', 'acceptEdits'], {
|
|
62
|
+
input: prompt,
|
|
63
|
+
encoding: 'utf-8',
|
|
64
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
65
|
+
});
|
|
66
|
+
return output.trim();
|
|
67
|
+
}
|
package/dist/core/map.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — project map builder.
|
|
3
|
+
*
|
|
4
|
+
* Scans the project once with the AI and writes a short, high-level
|
|
5
|
+
* map to testforge.md at the project root (similar to CLAUDE.md).
|
|
6
|
+
*
|
|
7
|
+
* The map has two parts:
|
|
8
|
+
* 1. Journeys — a short list of features/areas, one line each.
|
|
9
|
+
* 2. Data layer — how the project stores data (db type, models,
|
|
10
|
+
* key fields, enums), so tests can later seed and verify data.
|
|
11
|
+
*
|
|
12
|
+
* Purpose: the AI reads this small map to understand the project
|
|
13
|
+
* instead of re-reading the whole codebase every time. Refreshed
|
|
14
|
+
* only when the code changes.
|
|
15
|
+
*
|
|
16
|
+
* Nothing about any specific database is hardcoded — the AI detects
|
|
17
|
+
* whatever the project actually uses. Secrets are never captured;
|
|
18
|
+
* only env var names.
|
|
19
|
+
*/
|
|
20
|
+
import { execFileSync } from 'node:child_process';
|
|
21
|
+
import { loadConfig, splitCommand } from './config.js';
|
|
22
|
+
/** Build the prompt that asks the AI to produce the project map. */
|
|
23
|
+
export function buildMapPrompt(srcDir) {
|
|
24
|
+
return [
|
|
25
|
+
`You are a senior engineer mapping a codebase so a QA tool can`,
|
|
26
|
+
`plan tests and seed test data. Read the source code under`,
|
|
27
|
+
`"${srcDir}" and produce a SHORT, high-level map of the project.`,
|
|
28
|
+
``,
|
|
29
|
+
`The map has two parts: journeys and the data layer.`,
|
|
30
|
+
``,
|
|
31
|
+
`PART 1 — Journeys.`,
|
|
32
|
+
`Group journeys by area based on the folder structure (for example`,
|
|
33
|
+
`"api" and "webapp"). Under each area, list each journey as a single`,
|
|
34
|
+
`line: a short name and a brief one-line description.`,
|
|
35
|
+
``,
|
|
36
|
+
`PART 2 — Data layer.`,
|
|
37
|
+
`Figure out, from the code, how this project stores data, so tests`,
|
|
38
|
+
`can later seed and verify it. Capture:`,
|
|
39
|
+
`- Database type (e.g. MongoDB, MySQL, PostgreSQL) — only what you`,
|
|
40
|
+
` find real evidence for.`,
|
|
41
|
+
`- The models / collections / tables, with their key fields.`,
|
|
42
|
+
`- Important enum-like values a field can take (e.g. a status field`,
|
|
43
|
+
` that is "unpaid" or "paid").`,
|
|
44
|
+
`- Any relationships that matter (e.g. an order references a user id).`,
|
|
45
|
+
`- The env var(s) used for the connection, by NAME only (e.g.`,
|
|
46
|
+
` DATABASE_URL). Never copy actual connection strings or secrets.`,
|
|
47
|
+
`If the project has no database, say "No database detected" and skip`,
|
|
48
|
+
`this part.`,
|
|
49
|
+
``,
|
|
50
|
+
`Write the map to the file "testforge.md" using this format:`,
|
|
51
|
+
``,
|
|
52
|
+
`# Project Map`,
|
|
53
|
+
``,
|
|
54
|
+
`## Journeys`,
|
|
55
|
+
``,
|
|
56
|
+
`### api`,
|
|
57
|
+
`- project — create and manage projects`,
|
|
58
|
+
`- login — user authentication`,
|
|
59
|
+
``,
|
|
60
|
+
`### webapp`,
|
|
61
|
+
`- dashboard — overview of the user's projects`,
|
|
62
|
+
``,
|
|
63
|
+
`## Data Layer`,
|
|
64
|
+
``,
|
|
65
|
+
`- Database: <type>, connection from env var <NAME>`,
|
|
66
|
+
`- Models / collections:`,
|
|
67
|
+
` - <name>: <key fields>; <important enums or relations>`,
|
|
68
|
+
``,
|
|
69
|
+
`Rules:`,
|
|
70
|
+
`- Keep journeys SHORT — one line each. Detailed fields/steps belong`,
|
|
71
|
+
` in per-journey docs, not here.`,
|
|
72
|
+
`- For the data layer, capture enough that a test could build a valid`,
|
|
73
|
+
` record (right collection/table, right key fields, right enum`,
|
|
74
|
+
` values), but stay concise — no full schemas dumped verbatim.`,
|
|
75
|
+
`- Base everything on real evidence in the code. Do not invent.`,
|
|
76
|
+
`- Never include secrets or real connection strings — env var names only.`,
|
|
77
|
+
`- After writing the file, print a short summary of what you found.`,
|
|
78
|
+
].join('\n');
|
|
79
|
+
}
|
|
80
|
+
/** Run the map builder via the configured AI command. */
|
|
81
|
+
export function runMap(srcDir) {
|
|
82
|
+
const prompt = buildMapPrompt(srcDir);
|
|
83
|
+
const { program, args } = splitCommand(loadConfig().aiCommand);
|
|
84
|
+
const output = execFileSync(program, [...args, '--permission-mode', 'acceptEdits'], {
|
|
85
|
+
input: prompt,
|
|
86
|
+
encoding: 'utf-8',
|
|
87
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
88
|
+
});
|
|
89
|
+
return output.trim();
|
|
90
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — core data model.
|
|
3
|
+
*
|
|
4
|
+
* These types describe what a "user journey" looks like internally.
|
|
5
|
+
* Markdown files are parsed into a Journey, and test generators read
|
|
6
|
+
* from a Journey to produce test cases. This is the single shared
|
|
7
|
+
* shape that every part of the framework depends on.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — markdown journey parser.
|
|
3
|
+
*
|
|
4
|
+
* Reads a journey markdown file line by line and builds a Journey
|
|
5
|
+
* object from it. The expected markdown format is:
|
|
6
|
+
*
|
|
7
|
+
* # Journey: Sign In
|
|
8
|
+
*
|
|
9
|
+
* Type: web
|
|
10
|
+
* Route: /login
|
|
11
|
+
*
|
|
12
|
+
* Coverage:
|
|
13
|
+
* - functional
|
|
14
|
+
* - security
|
|
15
|
+
*
|
|
16
|
+
* Fields:
|
|
17
|
+
* - email (email, required)
|
|
18
|
+
* - password (password, required)
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Turn a journey name into a safe file-name id.
|
|
22
|
+
* "Sign In" -> "sign-in"
|
|
23
|
+
* "Resume Builder (Create / Edit Resume)" -> "resume-builder-create-edit-resume"
|
|
24
|
+
*
|
|
25
|
+
* We keep only letters, numbers, and spaces, then turn spaces into
|
|
26
|
+
* dashes. This removes brackets, slashes, and other characters that
|
|
27
|
+
* are not safe in file names.
|
|
28
|
+
*/
|
|
29
|
+
function toId(name) {
|
|
30
|
+
return name
|
|
31
|
+
.trim()
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.replace(/[^a-z0-9\s-]/g, '') // drop anything that is not a letter, number, space, or dash
|
|
34
|
+
.replace(/\s+/g, '-') // spaces -> dashes
|
|
35
|
+
.replace(/-+/g, '-') // collapse multiple dashes into one
|
|
36
|
+
.replace(/^-|-$/g, ''); // trim leading/trailing dashes
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Parse a single field line like "email (email, required)".
|
|
40
|
+
*
|
|
41
|
+
* Returns null if the line does not match the simple field shape.
|
|
42
|
+
* Rich journey files often have comments, extra notes, or formats
|
|
43
|
+
* the basic parser does not understand — we skip those here rather
|
|
44
|
+
* than failing, because the full markdown is still passed to the AI
|
|
45
|
+
* via rawMarkdown. The parsed fields are only used by the basic
|
|
46
|
+
* fallback generator.
|
|
47
|
+
*/
|
|
48
|
+
function parseField(line) {
|
|
49
|
+
// Ignore comment lines (starting with #) and anything without brackets.
|
|
50
|
+
const text = line.replace(/^-\s*/, '').trim();
|
|
51
|
+
if (text.startsWith('#')) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const match = text.match(/^(\w+)\s*\(([^)]*)\)/);
|
|
55
|
+
if (!match) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const name = match[1];
|
|
59
|
+
const parts = match[2].split(',').map((p) => p.trim());
|
|
60
|
+
// Only accept known field types; otherwise skip this line.
|
|
61
|
+
const known = ['text', 'email', 'password', 'number'];
|
|
62
|
+
const type = known.includes(parts[0])
|
|
63
|
+
? parts[0]
|
|
64
|
+
: 'text'; // default unknown types to text
|
|
65
|
+
const required = parts.includes('required');
|
|
66
|
+
return { name, type, required };
|
|
67
|
+
}
|
|
68
|
+
/** Parse a full markdown string into a Journey object. */
|
|
69
|
+
export function parseJourney(markdown) {
|
|
70
|
+
const lines = markdown.split('\n').map((l) => l.trim());
|
|
71
|
+
let name = '';
|
|
72
|
+
let type = 'web';
|
|
73
|
+
let route = '';
|
|
74
|
+
const coverage = [];
|
|
75
|
+
const fields = [];
|
|
76
|
+
// Track which list we are currently reading ("coverage" or "fields").
|
|
77
|
+
let section = null;
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
if (line === '')
|
|
80
|
+
continue;
|
|
81
|
+
if (line.startsWith('# Journey:')) {
|
|
82
|
+
name = line.replace('# Journey:', '').trim();
|
|
83
|
+
section = null;
|
|
84
|
+
}
|
|
85
|
+
else if (line.startsWith('Type:')) {
|
|
86
|
+
type = line.replace('Type:', '').trim();
|
|
87
|
+
section = null;
|
|
88
|
+
}
|
|
89
|
+
else if (line.startsWith('Route:')) {
|
|
90
|
+
route = line.replace('Route:', '').trim();
|
|
91
|
+
section = null;
|
|
92
|
+
}
|
|
93
|
+
else if (line.startsWith('Coverage:')) {
|
|
94
|
+
section = 'coverage';
|
|
95
|
+
}
|
|
96
|
+
else if (line.startsWith('Fields:')) {
|
|
97
|
+
section = 'fields';
|
|
98
|
+
}
|
|
99
|
+
else if (line.startsWith('-')) {
|
|
100
|
+
// A list item. Where it goes depends on the current section.
|
|
101
|
+
if (section === 'coverage') {
|
|
102
|
+
coverage.push(line.replace(/^-\s*/, '').trim());
|
|
103
|
+
}
|
|
104
|
+
else if (section === 'fields') {
|
|
105
|
+
const field = parseField(line);
|
|
106
|
+
if (field) {
|
|
107
|
+
fields.push(field);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
id: toId(name),
|
|
114
|
+
name,
|
|
115
|
+
type,
|
|
116
|
+
route,
|
|
117
|
+
coverage,
|
|
118
|
+
fields,
|
|
119
|
+
rawMarkdown: markdown,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — Playwright test file writer.
|
|
3
|
+
*
|
|
4
|
+
* Takes a Journey and its generated test cases and produces the
|
|
5
|
+
* text of a runnable Playwright test file (.test.ts). It does not
|
|
6
|
+
* write to disk itself; it returns the file content as a string so
|
|
7
|
+
* the caller decides where to save it.
|
|
8
|
+
*
|
|
9
|
+
* The generated tests use placeholder selectors and values, marked
|
|
10
|
+
* with TODO comments, because testforge cannot know the real page
|
|
11
|
+
* structure. A developer fills those in. Every test is tagged
|
|
12
|
+
* @generated so it is easy to spot auto-created tests.
|
|
13
|
+
*/
|
|
14
|
+
/** Build the import header and describe block opening. */
|
|
15
|
+
function header(journey) {
|
|
16
|
+
return [
|
|
17
|
+
`import { test, expect } from "@playwright/test";`,
|
|
18
|
+
``,
|
|
19
|
+
`// @generated by testforge — review before relying on these tests.`,
|
|
20
|
+
`test.describe("${journey.name}", () => {`,
|
|
21
|
+
``,
|
|
22
|
+
].join('\n');
|
|
23
|
+
}
|
|
24
|
+
/** Build a single Playwright test block from one test case. */
|
|
25
|
+
function testBlock(journey, testCase) {
|
|
26
|
+
const lines = [];
|
|
27
|
+
lines.push(` // @generated`);
|
|
28
|
+
lines.push(` test("${testCase.title}", async ({ page }) => {`);
|
|
29
|
+
lines.push(` await page.goto("${journey.route}");`);
|
|
30
|
+
lines.push(``);
|
|
31
|
+
lines.push(` // TODO: fill in the steps for this test.`);
|
|
32
|
+
if (testCase.expect === 'pass') {
|
|
33
|
+
lines.push(` // This case is expected to succeed.`);
|
|
34
|
+
lines.push(` // TODO: assert the success outcome (e.g. redirect or success message).`);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
lines.push(` // This case is expected to fail / be rejected.`);
|
|
38
|
+
lines.push(` // TODO: assert the error outcome (e.g. validation message shown).`);
|
|
39
|
+
}
|
|
40
|
+
lines.push(` });`);
|
|
41
|
+
return lines.join('\n');
|
|
42
|
+
}
|
|
43
|
+
/** Build the full Playwright test file content for a journey. */
|
|
44
|
+
export function writePlaywrightFile(journey, cases) {
|
|
45
|
+
const blocks = cases.map((c) => testBlock(journey, c)).join('\n\n');
|
|
46
|
+
return header(journey) + blocks + `\n\n});\n`;
|
|
47
|
+
}
|