@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
package/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# testforge
|
|
2
|
+
|
|
3
|
+
AI-powered QA automation. Point it at a project and it scans the code,
|
|
4
|
+
drafts a journey doc for each feature, and generates seeded Jest and
|
|
5
|
+
Playwright tests that match the project's own structure.
|
|
6
|
+
|
|
7
|
+
Works in any project, with any AI CLI (defaults to Claude).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -D testforge
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
testforge calls a local AI CLI to do its work. By default it uses
|
|
16
|
+
`claude -p`, so make sure the Claude CLI is installed and logged in.
|
|
17
|
+
To use a different CLI, see [Configuration](#).
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
From your project root:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
testforge setup # one-time: inspect project, map it, draft journey docs
|
|
25
|
+
testforge generate # generate tests + seed files from the docs
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
After `setup`, review the drafted docs in `docs/` and fix anything the
|
|
29
|
+
AI got wrong — they are the source of truth for the tests. Then run
|
|
30
|
+
`generate`.
|
|
31
|
+
|
|
32
|
+
When your code changes later:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
testforge update # re-check and refresh only what changed
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Commands
|
|
39
|
+
|
|
40
|
+
| Command | What it does |
|
|
41
|
+
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
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. |
|
|
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
|
+
|
|
46
|
+
### Advanced (run a single step)
|
|
47
|
+
|
|
48
|
+
| Command | What it does |
|
|
49
|
+
| -------------------- | ----------------------------------------------------------- |
|
|
50
|
+
| `testforge init` | Inspect the project and write `testforge.config.json` only. |
|
|
51
|
+
| `testforge map` | Rebuild the project map (`testforge.md`) only. |
|
|
52
|
+
| `testforge discover` | Redraft the per-journey docs in `docs/` only. |
|
|
53
|
+
| `testforge env` | Rebuild `.env.testing.example` from the docs only. |
|
|
54
|
+
|
|
55
|
+
## How it works
|
|
56
|
+
|
|
57
|
+
**setup** prepares the project in three stages:
|
|
58
|
+
|
|
59
|
+
1. **init** — detects the layout (monorepo or single app, where tests
|
|
60
|
+
live, JS or TS) and writes `testforge.config.json`.
|
|
61
|
+
2. **map** — writes `testforge.md`: a short list of journeys and a
|
|
62
|
+
summary of the data layer (database, models, key fields). The AI
|
|
63
|
+
reads this instead of re-scanning all the code each time.
|
|
64
|
+
3. **discover** — writes one rich doc per journey into `docs/` — fields,
|
|
65
|
+
actions, expected results, edge cases, security notes, and the data a
|
|
66
|
+
test needs.
|
|
67
|
+
|
|
68
|
+
**generate** turns each doc into a test. A journey with `Type: api`
|
|
69
|
+
becomes a Jest test (HTTP + database checks); `Type: web` becomes a
|
|
70
|
+
Playwright test. Other areas use the framework set in config. It also
|
|
71
|
+
generates a seed file that provisions the test data each test needs.
|
|
72
|
+
|
|
73
|
+
**update** hashes your source code and compares it to the last run. No
|
|
74
|
+
change means no work and no AI calls; a change triggers a refresh.
|
|
75
|
+
|
|
76
|
+
## Running the generated tests
|
|
77
|
+
|
|
78
|
+
testforge generates the tests and seed files — you run them with your
|
|
79
|
+
own test runner, the way your project already does. testforge does not
|
|
80
|
+
run tests for you; that keeps it out of your test setup and CI.
|
|
81
|
+
|
|
82
|
+
A typical run:
|
|
83
|
+
|
|
84
|
+
1. **Provide test credentials.** The tests and seeds read the database
|
|
85
|
+
URL and any secrets from environment variables — never hardcoded.
|
|
86
|
+
|
|
87
|
+
`testforge setup` writes a `.env.testing.example` for you. It lists
|
|
88
|
+
every env var the journeys need, grouped by area (api / web / ops),
|
|
89
|
+
and annotates each one with the journeys that use it — so you know
|
|
90
|
+
exactly which credentials a feature needs, and can skip the ones for
|
|
91
|
+
features you don't run. For example:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# ========================================
|
|
95
|
+
# API
|
|
96
|
+
# ========================================
|
|
97
|
+
DB_URL= # used by: (all)
|
|
98
|
+
TOKEN_SECRET= # used by: auth, user
|
|
99
|
+
STRIPE_SECRET_KEY= # used by: payments, subscription
|
|
100
|
+
|
|
101
|
+
# ========================================
|
|
102
|
+
# WEB
|
|
103
|
+
# ========================================
|
|
104
|
+
StripePublicKey= # used by: payments
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Copy it to `.env.testing`, fill in real values, and keep that file
|
|
108
|
+
out of version control (add it to `.gitignore`). Re-running `setup`
|
|
109
|
+
or `update` refreshes the example as your code changes.
|
|
110
|
+
|
|
111
|
+
Note: some credentials aren't static — e.g. a user access/refresh
|
|
112
|
+
token from a login flow. Those belong in your own test setup (a
|
|
113
|
+
global setup or seed step), not in the env file.
|
|
114
|
+
|
|
115
|
+
Keep this file out of version control (add it to `.gitignore`). The
|
|
116
|
+
exact variable names come from your own code — the journey docs and
|
|
117
|
+
the project map list the env var names each journey needs.
|
|
118
|
+
|
|
119
|
+
2. **Run the seeds** to put known test data in the database. Run the
|
|
120
|
+
seed files in `seedOutput` (see config) with your runtime, e.g.:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
npx tsx path/to/seed/<journey>.seed.ts # or: node <journey>.seed.js
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
3. **Run the tests** with the project's own command, e.g.:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
# api (Jest)
|
|
130
|
+
NODE_ENV=testing npx jest
|
|
131
|
+
|
|
132
|
+
# web (Playwright)
|
|
133
|
+
npx playwright test
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The test runner produces its own pass/fail report.
|
|
137
|
+
|
|
138
|
+
## Configuration
|
|
139
|
+
|
|
140
|
+
`testforge setup` writes `testforge.config.json` for you. You can edit it:
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"aiCommand": "claude -p",
|
|
145
|
+
"testOutput": {
|
|
146
|
+
"api": "packages/api/test",
|
|
147
|
+
"web": "packages/webapp/tests",
|
|
148
|
+
"ops": "packages/ops/tests"
|
|
149
|
+
},
|
|
150
|
+
"framework": {
|
|
151
|
+
"api": "jest",
|
|
152
|
+
"web": "playwright",
|
|
153
|
+
"ops": "playwright"
|
|
154
|
+
},
|
|
155
|
+
"seedOutput": "packages/api/test/helpers/seed",
|
|
156
|
+
"testExtension": ".test.ts"
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
- **aiCommand** — the CLI used to call the AI. Any tool that reads a
|
|
161
|
+
prompt from stdin and prints the reply works, so testforge is not tied
|
|
162
|
+
to one provider.
|
|
163
|
+
- **testOutput** — where tests go, per area. Areas are not fixed — use
|
|
164
|
+
whatever your project has (`api`, `web`, `ops`, `lambda`, ...).
|
|
165
|
+
- **framework** — which test framework each area uses.
|
|
166
|
+
- **seedOutput** — where seed files go.
|
|
167
|
+
- **testExtension** — `.test.ts` or `.test.js`.
|
|
168
|
+
|
|
169
|
+
## Notes
|
|
170
|
+
|
|
171
|
+
- Generated tests and docs are a strong draft, not final. Review them —
|
|
172
|
+
the AI leaves `TODO` comments where it could not be certain.
|
|
173
|
+
- Secrets are never written to docs, configs, or tests. Connections use
|
|
174
|
+
environment variable names only; real values come from your
|
|
175
|
+
environment at run time.
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* testforge — command-line entry point.
|
|
4
|
+
*
|
|
5
|
+
* Commands:
|
|
6
|
+
* testforge generate Generate tests for changed journeys (AI).
|
|
7
|
+
* testforge generate --basic Use the built-in basic generator only.
|
|
8
|
+
* testforge generate --force Regenerate everything, ignoring the cache.
|
|
9
|
+
*/
|
|
10
|
+
import { VERSION } from '../core/index.js';
|
|
11
|
+
import { runGenerate } from '../core/run-generate.js';
|
|
12
|
+
import { runDiscover } from '../core/discover.js';
|
|
13
|
+
import { runMap } from '../core/map.js';
|
|
14
|
+
import { runInit } from '../core/init.js';
|
|
15
|
+
import { runSetup } from '../core/setup.js';
|
|
16
|
+
import { runUpdate } from '../core/update.js';
|
|
17
|
+
import { runEnvTemplate } from '../core/env-template.js';
|
|
18
|
+
const command = process.argv[2];
|
|
19
|
+
const useBasic = process.argv.includes('--basic');
|
|
20
|
+
const force = process.argv.includes('--force');
|
|
21
|
+
if (command === 'update') {
|
|
22
|
+
runUpdate('.');
|
|
23
|
+
}
|
|
24
|
+
else if (command === 'setup') {
|
|
25
|
+
runSetup('.');
|
|
26
|
+
}
|
|
27
|
+
else if (command === 'init') {
|
|
28
|
+
console.log('Inspecting your project to set up testforge...\n');
|
|
29
|
+
const output = runInit();
|
|
30
|
+
console.log(output);
|
|
31
|
+
console.log('\ntestforge.config.json written. Review it, then run `testforge map`.');
|
|
32
|
+
}
|
|
33
|
+
else if (command === 'map') {
|
|
34
|
+
console.log('Building a high-level map of your project with AI...');
|
|
35
|
+
console.log('This scans your source code and writes testforge.md\n');
|
|
36
|
+
const output = runMap('.');
|
|
37
|
+
console.log(output);
|
|
38
|
+
console.log('\nProject map written to testforge.md');
|
|
39
|
+
}
|
|
40
|
+
else if (command === 'discover') {
|
|
41
|
+
console.log('Scanning your project with AI to draft journey files...');
|
|
42
|
+
console.log('This reads your source code and writes drafts into docs/.\n');
|
|
43
|
+
const output = runDiscover('.', 'docs');
|
|
44
|
+
console.log(output);
|
|
45
|
+
console.log('\nDraft journeys written to docs/. Please review and edit them,');
|
|
46
|
+
console.log('then run `testforge generate`.');
|
|
47
|
+
}
|
|
48
|
+
else if (command === 'env') {
|
|
49
|
+
runEnvTemplate('docs', '.env.testing.example');
|
|
50
|
+
}
|
|
51
|
+
else if (command === 'generate') {
|
|
52
|
+
const useAI = !useBasic;
|
|
53
|
+
console.log(useAI
|
|
54
|
+
? 'Generating tests with AI for changed journeys...\n'
|
|
55
|
+
: 'Generating tests with the basic generator...\n');
|
|
56
|
+
const results = runGenerate('docs', useAI, force);
|
|
57
|
+
if (results.length === 0) {
|
|
58
|
+
console.log('No journey files found in docs/. Add a .md file and try again.');
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
for (const r of results) {
|
|
62
|
+
if (r.status === 'skipped') {
|
|
63
|
+
console.log(` [skip] ${r.journeyName} (unchanged)`);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const tag = r.status === 'ai' ? '[AI]' : '[basic]';
|
|
67
|
+
console.log(` ${tag} ${r.journeyName}`);
|
|
68
|
+
console.log(` test: ${r.testPath}`);
|
|
69
|
+
if (r.status === 'ai')
|
|
70
|
+
console.log(` seed: ${r.seedPath}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const generated = results.filter((r) => r.status !== 'skipped').length;
|
|
74
|
+
const skipped = results.filter((r) => r.status === 'skipped').length;
|
|
75
|
+
console.log(`\nDone. ${generated} generated, ${skipped} skipped (unchanged).`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
console.log(`testforge v${VERSION}`);
|
|
80
|
+
console.log('');
|
|
81
|
+
console.log('Usage:');
|
|
82
|
+
console.log(' testforge generate Generate tests for changed journeys');
|
|
83
|
+
console.log(' testforge generate --basic Use the basic generator only');
|
|
84
|
+
console.log(' testforge generate --force Regenerate everything');
|
|
85
|
+
console.log(' testforge discover Scan source code and draft journey files');
|
|
86
|
+
console.log(' testforge map Scan project, write journey + data map');
|
|
87
|
+
console.log(' testforge init Inspect project and write config');
|
|
88
|
+
console.log(' testforge setup Run init + map + discover in order');
|
|
89
|
+
console.log(' testforge update Re-check and update only what changed');
|
|
90
|
+
console.log(' testforge env Write .env.testing.example from docs');
|
|
91
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — AI-powered test generator.
|
|
3
|
+
*
|
|
4
|
+
* Instead of using a fixed list of hardcoded scenarios, this asks
|
|
5
|
+
* the locally installed Claude Code CLI to act as a senior QA
|
|
6
|
+
* engineer: it reads the journey and decides what to test, then
|
|
7
|
+
* writes a complete Playwright test file.
|
|
8
|
+
*
|
|
9
|
+
* Requirements: the `claude` CLI must be installed and logged in.
|
|
10
|
+
* We call it in print mode (`claude -p`) and feed the prompt via
|
|
11
|
+
* stdin so long prompts and special characters are handled safely.
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
15
|
+
import { loadConfig, splitCommand } from './config.js';
|
|
16
|
+
const MAP_PATH = 'testforge.md';
|
|
17
|
+
/** Build the prompt we send to the AI for one journey. */
|
|
18
|
+
export function buildPrompt(journey, map) {
|
|
19
|
+
const config = loadConfig();
|
|
20
|
+
const fw = config.framework?.[journey.type];
|
|
21
|
+
const isApi = journey.type === 'api' || fw === 'jest' || fw === 'supertest';
|
|
22
|
+
const framework = fw
|
|
23
|
+
? `${fw} for a "${journey.type}" test`
|
|
24
|
+
: isApi
|
|
25
|
+
? 'Jest (with supertest for HTTP) for an API/backend test'
|
|
26
|
+
: 'an appropriate test framework for this kind of journey';
|
|
27
|
+
const ext = config.testExtension ?? '.test.ts';
|
|
28
|
+
const isJs = ext.endsWith('.js');
|
|
29
|
+
const language = isJs
|
|
30
|
+
? 'plain JavaScript (CommonJS: use require/module.exports, NOT import; NO TypeScript type annotations)'
|
|
31
|
+
: 'TypeScript';
|
|
32
|
+
return [
|
|
33
|
+
`You are a senior QA engineer. Generate a complete ${framework}`,
|
|
34
|
+
`for the user journey described below.`,
|
|
35
|
+
``,
|
|
36
|
+
`Think like an experienced tester whose goal is to break the`,
|
|
37
|
+
`application. Cover, where relevant: happy path, missing/empty`,
|
|
38
|
+
`required fields, invalid formats and boundary values (very long,`,
|
|
39
|
+
`very short, special characters, unicode), and — if security`,
|
|
40
|
+
`coverage is requested — security attacks (XSS, SQL/NoSQL injection,`,
|
|
41
|
+
`auth bypass, idempotency/duplicate charge, and others you judge`,
|
|
42
|
+
`useful). For payment/webhook journeys, test idempotency, amount/`,
|
|
43
|
+
`price tampering, and signature/verification where relevant.`,
|
|
44
|
+
``,
|
|
45
|
+
isApi
|
|
46
|
+
? [
|
|
47
|
+
`Because this is an API journey, write Jest tests that call the`,
|
|
48
|
+
`endpoint over HTTP, assert status codes, response body and`,
|
|
49
|
+
`schema, auth/authorization, and invalid payloads.`,
|
|
50
|
+
`If the journey's Data section names models/collections, VERIFY`,
|
|
51
|
+
`the database state after calls (e.g. a record was created, no`,
|
|
52
|
+
`duplicate exists, correct fields) using the database/ORM the`,
|
|
53
|
+
`project uses. Connect via the env var named in the map (e.g.`,
|
|
54
|
+
`process.env.DB_URL) — never hardcode a connection string.`,
|
|
55
|
+
].join('\n')
|
|
56
|
+
: [
|
|
57
|
+
`Because this is a web journey, write Playwright tests that drive`,
|
|
58
|
+
`the UI. Use placeholder selectors with a TODO comment where the`,
|
|
59
|
+
`real page structure is unknown — do not invent selectors as if`,
|
|
60
|
+
`they are real.`,
|
|
61
|
+
].join('\n'),
|
|
62
|
+
``,
|
|
63
|
+
`This test relies on seeded test data (a separate seed file provisions`,
|
|
64
|
+
`the database before tests run). Assume the seed has created the`,
|
|
65
|
+
`records described in the journey's Data section, using stable test`,
|
|
66
|
+
`ids prefixed with "qa-"/"QA-TEST". Reference those known test ids`,
|
|
67
|
+
`rather than creating fresh data inside the test where possible. After`,
|
|
68
|
+
`a test mutates data, clean up what it created so reruns stay valid.`,
|
|
69
|
+
``,
|
|
70
|
+
`Rules for your output:`,
|
|
71
|
+
`- Write the test in ${language}.`,
|
|
72
|
+
`- Output ONLY the raw test code. Your entire response must be valid`,
|
|
73
|
+
` ${isJs ? 'JavaScript' : 'TypeScript'} that can be saved directly to a file and run.`,
|
|
74
|
+
`- The VERY FIRST characters of your response must be the first line of`,
|
|
75
|
+
` code. Do NOT write any sentence, preamble, acknowledgement, or`,
|
|
76
|
+
` explanation before or after the code.`,
|
|
77
|
+
`- Do NOT ask for approval or describe what you are doing.`,
|
|
78
|
+
`- Do NOT wrap the code in markdown fences.`,
|
|
79
|
+
`- Tag the top with a comment: // @generated by testforge`,
|
|
80
|
+
`- Where you must assume something uncertain, add a "// TODO:" note`,
|
|
81
|
+
` rather than inventing it confidently.`,
|
|
82
|
+
``,
|
|
83
|
+
`--- PROJECT MAP (data layer) ---`,
|
|
84
|
+
map,
|
|
85
|
+
``,
|
|
86
|
+
`--- JOURNEY ---`,
|
|
87
|
+
`It may include sections beyond fields — actions, expected results,`,
|
|
88
|
+
`negative cases, race conditions, webhooks, security notes, and a`,
|
|
89
|
+
`Data section. Use ALL of it. Respect any "# TODO:" notes.`,
|
|
90
|
+
``,
|
|
91
|
+
journey.rawMarkdown,
|
|
92
|
+
].join('\n');
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Call the configured AI command to generate a test file for one
|
|
96
|
+
* journey. Reads the project map (testforge.md) for data-layer
|
|
97
|
+
* context. Returns the test file content.
|
|
98
|
+
*/
|
|
99
|
+
export function generateWithAI(journey) {
|
|
100
|
+
const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
|
|
101
|
+
const prompt = buildPrompt(journey, map);
|
|
102
|
+
const { program, args } = splitCommand(loadConfig().aiCommand);
|
|
103
|
+
const output = execFileSync(program, args, {
|
|
104
|
+
input: prompt,
|
|
105
|
+
encoding: 'utf-8',
|
|
106
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
107
|
+
});
|
|
108
|
+
return cleanOutput(output);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Extract clean code from the AI's reply.
|
|
112
|
+
*
|
|
113
|
+
* 1. If there's a fenced code block, take its contents.
|
|
114
|
+
* 2. Otherwise, drop any leading prose by starting from the first line
|
|
115
|
+
* that looks like code (a comment, import, require, or known
|
|
116
|
+
* statement keyword). This rescues cases where the AI adds a
|
|
117
|
+
* sentence before the code despite instructions.
|
|
118
|
+
*/
|
|
119
|
+
function cleanOutput(text) {
|
|
120
|
+
// 1. Prefer a fenced code block if present.
|
|
121
|
+
const fence = text.match(/```[a-zA-Z]*\n([\s\S]*?)\n```/);
|
|
122
|
+
if (fence) {
|
|
123
|
+
return fence[1].trim();
|
|
124
|
+
}
|
|
125
|
+
// 2. No fence — find the first line that looks like real code.
|
|
126
|
+
const lines = text.split('\n');
|
|
127
|
+
const codeStart = lines.findIndex((line) => {
|
|
128
|
+
const t = line.trim();
|
|
129
|
+
return (t.startsWith('//') ||
|
|
130
|
+
t.startsWith('/*') ||
|
|
131
|
+
t.startsWith('import ') ||
|
|
132
|
+
t.startsWith('const ') ||
|
|
133
|
+
t.startsWith('require(') ||
|
|
134
|
+
t.startsWith("'use strict'") ||
|
|
135
|
+
t.startsWith('"use strict"'));
|
|
136
|
+
});
|
|
137
|
+
if (codeStart > 0) {
|
|
138
|
+
return lines.slice(codeStart).join('\n').trim();
|
|
139
|
+
}
|
|
140
|
+
return text.trim();
|
|
141
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — change detection cache.
|
|
3
|
+
*
|
|
4
|
+
* Remembers a fingerprint (hash) of each journey file from the last
|
|
5
|
+
* run. On the next run we compare hashes: if a file's hash is the
|
|
6
|
+
* same, it has not changed and we can skip regenerating it. If it is
|
|
7
|
+
* different (edited) or missing (new file), we regenerate.
|
|
8
|
+
*
|
|
9
|
+
* The cache is stored as a small JSON file in the project so it
|
|
10
|
+
* survives between runs.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, } from 'node:fs';
|
|
14
|
+
import { dirname, join, extname } from 'node:path';
|
|
15
|
+
/** Make a short fingerprint of a file's content. */
|
|
16
|
+
export function hashContent(content) {
|
|
17
|
+
return createHash('sha256').update(content).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
/** Load the saved cache, or an empty one if it does not exist yet. */
|
|
20
|
+
export function loadCache(cachePath) {
|
|
21
|
+
if (!existsSync(cachePath)) {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(cachePath, 'utf-8'));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Corrupted cache — treat as empty so we just regenerate everything.
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Save the cache back to disk. */
|
|
33
|
+
export function saveCache(cachePath, cache) {
|
|
34
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
35
|
+
writeFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Build a single hash representing the current state of a project's
|
|
39
|
+
* source code. Reads source files (by extension), concatenates their
|
|
40
|
+
* paths + contents, and hashes the result. If any file changes, is
|
|
41
|
+
* added, or removed, the hash changes — deterministically, with no AI
|
|
42
|
+
* involved.
|
|
43
|
+
*
|
|
44
|
+
* Skips non-source folders (node_modules, .git, dist, etc.) and
|
|
45
|
+
* testforge's own output, so unrelated churn is ignored.
|
|
46
|
+
*/
|
|
47
|
+
const SOURCE_EXTENSIONS = [
|
|
48
|
+
'.js',
|
|
49
|
+
'.jsx',
|
|
50
|
+
'.ts',
|
|
51
|
+
'.tsx',
|
|
52
|
+
'.py',
|
|
53
|
+
'.go',
|
|
54
|
+
'.rb',
|
|
55
|
+
'.php',
|
|
56
|
+
'.java',
|
|
57
|
+
];
|
|
58
|
+
const SKIP_DIRS = new Set([
|
|
59
|
+
'node_modules',
|
|
60
|
+
'.git',
|
|
61
|
+
'dist',
|
|
62
|
+
'build',
|
|
63
|
+
'coverage',
|
|
64
|
+
'.next',
|
|
65
|
+
'docs',
|
|
66
|
+
'.testforge',
|
|
67
|
+
'generated-tests',
|
|
68
|
+
]);
|
|
69
|
+
export function hashProjectSource(rootDir) {
|
|
70
|
+
const parts = [];
|
|
71
|
+
function walk(dir) {
|
|
72
|
+
let entries;
|
|
73
|
+
try {
|
|
74
|
+
entries = readdirSync(dir);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
for (const entry of entries.sort()) {
|
|
80
|
+
const full = join(dir, entry);
|
|
81
|
+
let stat;
|
|
82
|
+
try {
|
|
83
|
+
stat = statSync(full);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (stat.isDirectory()) {
|
|
89
|
+
if (SKIP_DIRS.has(entry) || entry.startsWith('.'))
|
|
90
|
+
continue;
|
|
91
|
+
walk(full);
|
|
92
|
+
}
|
|
93
|
+
else if (SOURCE_EXTENSIONS.includes(extname(entry))) {
|
|
94
|
+
try {
|
|
95
|
+
parts.push(full + '\n' + readFileSync(full, 'utf-8'));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// unreadable file — skip
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
walk(rootDir);
|
|
104
|
+
return hashContent(parts.join('\n---\n'));
|
|
105
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — configuration.
|
|
3
|
+
*
|
|
4
|
+
* testforge does not depend on any one AI provider. Instead, the user
|
|
5
|
+
* tells it which command to run to talk to their AI. That command must
|
|
6
|
+
* read a prompt from stdin and print the AI's reply to stdout.
|
|
7
|
+
*
|
|
8
|
+
* Configuration lives in testforge.config.json at the project root,
|
|
9
|
+
* for example:
|
|
10
|
+
*
|
|
11
|
+
* {
|
|
12
|
+
* "aiCommand": "claude -p"
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* If no config file exists, we default to "claude -p" so users with
|
|
16
|
+
* Claude installed work out of the box. Users of other tools (Gemini,
|
|
17
|
+
* Codex, etc.) set aiCommand to their own CLI.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
const CONFIG_PATH = 'testforge.config.json';
|
|
22
|
+
const DEFAULT_CONFIG = {
|
|
23
|
+
aiCommand: 'claude -p',
|
|
24
|
+
testOutput: {},
|
|
25
|
+
framework: { api: 'jest', web: 'playwright' },
|
|
26
|
+
seedOutput: 'generated-tests/seed',
|
|
27
|
+
testExtension: '.test.ts',
|
|
28
|
+
testCommand: {},
|
|
29
|
+
envFile: '.env.testing',
|
|
30
|
+
};
|
|
31
|
+
/** Load config from the project, falling back to defaults. */
|
|
32
|
+
export function loadConfig() {
|
|
33
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
34
|
+
return DEFAULT_CONFIG;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
|
38
|
+
return { ...DEFAULT_CONFIG, ...parsed };
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Bad config file — fall back to defaults rather than crashing.
|
|
42
|
+
return DEFAULT_CONFIG;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Split an AI command string into a program and its arguments.
|
|
47
|
+
* Example: "claude -p" -> { program: "claude", args: ["-p"] }
|
|
48
|
+
*/
|
|
49
|
+
export function splitCommand(command) {
|
|
50
|
+
const parts = command.trim().split(/\s+/);
|
|
51
|
+
const program = parts[0];
|
|
52
|
+
const args = parts.slice(1);
|
|
53
|
+
return { program, args };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Decide where a journey's test file should go, based on its type
|
|
57
|
+
* and the config. Falls back to "generated-tests/<area>" when the
|
|
58
|
+
* type is not configured.
|
|
59
|
+
*
|
|
60
|
+
* "area" is the journey type today (web / api). This keeps web and
|
|
61
|
+
* api tests in separate, configurable locations to match a project's
|
|
62
|
+
* existing layout.
|
|
63
|
+
*/
|
|
64
|
+
export function testDirForType(config, type) {
|
|
65
|
+
const configured = config.testOutput?.[type];
|
|
66
|
+
if (configured) {
|
|
67
|
+
return configured;
|
|
68
|
+
}
|
|
69
|
+
return join('generated-tests', type);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Parse a simple .env file into a key/value object. Ignores blank
|
|
73
|
+
* lines and comments. Does not expand or validate — just reads what is
|
|
74
|
+
* there so we can pass it to a child process. Returns {} if the file
|
|
75
|
+
* is missing.
|
|
76
|
+
*/
|
|
77
|
+
export function loadEnvFile(path) {
|
|
78
|
+
if (!existsSync(path)) {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
const out = {};
|
|
82
|
+
const text = readFileSync(path, 'utf-8');
|
|
83
|
+
for (const line of text.split('\n')) {
|
|
84
|
+
const trimmed = line.trim();
|
|
85
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
86
|
+
continue;
|
|
87
|
+
const eq = trimmed.indexOf('=');
|
|
88
|
+
if (eq === -1)
|
|
89
|
+
continue;
|
|
90
|
+
const key = trimmed.slice(0, eq).trim();
|
|
91
|
+
let value = trimmed.slice(eq + 1).trim();
|
|
92
|
+
// strip surrounding quotes if present
|
|
93
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
94
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
95
|
+
value = value.slice(1, -1);
|
|
96
|
+
}
|
|
97
|
+
out[key] = value;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|