@activate-core/test-forge 0.5.0 → 0.6.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/core/ai-generator.js +29 -19
- package/dist/core/config.js +13 -0
- package/dist/core/harness.js +135 -0
- package/dist/core/run-generate.js +52 -8
- package/package.json +2 -2
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { execFileSync, spawn } from 'node:child_process';
|
|
14
14
|
import { readFileSync, existsSync } from 'node:fs';
|
|
15
|
-
import { loadConfig, splitCommand, withReadOnlyTools } from './config.js';
|
|
15
|
+
import { loadConfig, splitCommand, withReadOnlyTools, resolveFramework, } from './config.js';
|
|
16
16
|
const MAP_PATH = 'testforge.md';
|
|
17
17
|
/**
|
|
18
18
|
* Spawn a command, feed `input` on stdin, resolve with its stdout.
|
|
@@ -39,17 +39,13 @@ function runWithStdin(program, args, input) {
|
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
41
|
/** Build the prompt we send to the AI for one journey. */
|
|
42
|
-
export function buildPrompt(journey, map, testPath, focus) {
|
|
42
|
+
export function buildPrompt(journey, map, testPath, focus, harnessPath) {
|
|
43
43
|
const config = loadConfig();
|
|
44
44
|
// Opinionated: testforge only generates Jest (api/backend) or Playwright
|
|
45
|
-
// (web/UI) tests
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
const configured = (config.framework?.[journey.area] ?? '').toLowerCase();
|
|
50
|
-
const isApi = configured === 'jest' ||
|
|
51
|
-
configured === 'supertest' ||
|
|
52
|
-
(configured !== 'playwright' && journey.type === 'api');
|
|
45
|
+
// (web/UI) tests — any other framework (e.g. a project's Cypress setup)
|
|
46
|
+
// is clamped to the right one for the journey. Single source of truth in
|
|
47
|
+
// resolveFramework, shared with the output-layout decision.
|
|
48
|
+
const isApi = resolveFramework(config, journey.area, journey.type) === 'jest';
|
|
53
49
|
const framework = isApi
|
|
54
50
|
? 'Jest (with supertest for HTTP) for an API/backend test'
|
|
55
51
|
: 'Playwright for a web/UI test';
|
|
@@ -136,13 +132,27 @@ export function buildPrompt(journey, map, testPath, focus) {
|
|
|
136
132
|
``,
|
|
137
133
|
]
|
|
138
134
|
: []),
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
135
|
+
...(harnessPath
|
|
136
|
+
? [
|
|
137
|
+
`SHARED HARNESS — a testkit already exists at:`,
|
|
138
|
+
` ${harnessPath}`,
|
|
139
|
+
`Import it (compute the relative path from THIS file) and use it`,
|
|
140
|
+
`for ALL setup: connect/disconnect, reset(), seed(), TEST_IDS, and`,
|
|
141
|
+
`(Playwright) apiContext()/login(). Read that file to confirm its`,
|
|
142
|
+
`exact exports before using them. Do NOT create, import, or invent`,
|
|
143
|
+
`any other seed/fixture/helper/auth module — use ONLY this testkit,`,
|
|
144
|
+
`so every file in the suite shares one consistent harness.`,
|
|
145
|
+
``,
|
|
146
|
+
]
|
|
147
|
+
: [
|
|
148
|
+
`This test relies on seeded test data (a separate seed file`,
|
|
149
|
+
`provisions the database before tests run). Assume the seed created`,
|
|
150
|
+
`the records described in the journey's Data section, using stable`,
|
|
151
|
+
`test ids prefixed with "qa-"/"QA-TEST". Reference those known ids`,
|
|
152
|
+
`rather than creating fresh data inside the test where possible.`,
|
|
153
|
+
`After a test mutates data, clean up what it created.`,
|
|
154
|
+
``,
|
|
155
|
+
]),
|
|
146
156
|
`Rules for your output:`,
|
|
147
157
|
`- Write the test in ${language}.`,
|
|
148
158
|
`- Output ONLY the raw test code. Your entire response must be valid`,
|
|
@@ -191,9 +201,9 @@ export function generateWithAI(journey, testPath, focus) {
|
|
|
191
201
|
* journey's suite can be generated concurrently (each spawns its own
|
|
192
202
|
* `claude` process). Same prompt, same read-only exploration.
|
|
193
203
|
*/
|
|
194
|
-
export async function generateWithAIAsync(journey, testPath, focus) {
|
|
204
|
+
export async function generateWithAIAsync(journey, testPath, focus, harnessPath) {
|
|
195
205
|
const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
|
|
196
|
-
const prompt = buildPrompt(journey, map, testPath, focus);
|
|
206
|
+
const prompt = buildPrompt(journey, map, testPath, focus, harnessPath);
|
|
197
207
|
const { program, args } = withReadOnlyTools(splitCommand(loadConfig().aiCommand));
|
|
198
208
|
const stdout = await runWithStdin(program, args, prompt);
|
|
199
209
|
return cleanOutput(stdout);
|
package/dist/core/config.js
CHANGED
|
@@ -99,6 +99,19 @@ export function splitCommand(command) {
|
|
|
99
99
|
const args = tokens.slice(1);
|
|
100
100
|
return { program, args };
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Resolve the test framework for a journey — clamped to the only two
|
|
104
|
+
* testforge supports. A web/UI journey is always Playwright (even if the
|
|
105
|
+
* project uses Cypress); an endpoint journey is Jest. This single source
|
|
106
|
+
* of truth drives BOTH the generation prompt and the output layout.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveFramework(config, area, type) {
|
|
109
|
+
const configured = (config.framework?.[area] ?? '').toLowerCase();
|
|
110
|
+
const isApi = configured === 'jest' ||
|
|
111
|
+
configured === 'supertest' ||
|
|
112
|
+
(configured !== 'playwright' && type === 'api');
|
|
113
|
+
return isApi ? 'jest' : 'playwright';
|
|
114
|
+
}
|
|
102
115
|
/** Is this command Claude's CLI (so Claude-specific flags apply)? */
|
|
103
116
|
function isClaude(program) {
|
|
104
117
|
const base = program.split(/[\\/]/).pop() ?? program;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — shared test harness scaffold.
|
|
3
|
+
*
|
|
4
|
+
* Without a shared harness, every generated test file invents its OWN
|
|
5
|
+
* seed/auth/db helper path — and they never agree, so nothing runs. This
|
|
6
|
+
* module generates ONE "testkit" per area that all of that area's tests
|
|
7
|
+
* import from a single known path, mirroring the hand-authored helpers/
|
|
8
|
+
* layout (client + seed builders + reset + auth) that a real suite uses.
|
|
9
|
+
*
|
|
10
|
+
* The testkit is generated by reading the project's REAL data layer and
|
|
11
|
+
* auth (read-only tools), so it connects and seeds against the actual
|
|
12
|
+
* schema — not a guess.
|
|
13
|
+
*/
|
|
14
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { loadConfig, splitCommand, withReadOnlyTools } from './config.js';
|
|
17
|
+
import { cleanOutput } from './ai-generator.js';
|
|
18
|
+
const MAP_PATH = 'testforge.md';
|
|
19
|
+
/**
|
|
20
|
+
* The fixed contract every testkit exposes and every generated test
|
|
21
|
+
* relies on. Both the harness generator and the per-file test generator
|
|
22
|
+
* are given this exact shape, so imports and usage line up.
|
|
23
|
+
*/
|
|
24
|
+
export const HARNESS_CONTRACT = [
|
|
25
|
+
`The testkit module MUST export exactly this stable API (CommonJS`,
|
|
26
|
+
`module.exports), implemented against the project's real data layer:`,
|
|
27
|
+
` - connect(): Promise<void> open the DB connection (env DB var`,
|
|
28
|
+
` from the map, e.g. process.env.DB_URL)`,
|
|
29
|
+
` - disconnect(): Promise<void> close it`,
|
|
30
|
+
` - reset(): Promise<void> remove all test data (only records`,
|
|
31
|
+
` with the "qa-"/"QA-TEST" prefix)`,
|
|
32
|
+
` - seed(spec?): Promise<object> idempotently upsert the test records`,
|
|
33
|
+
` the tests need (using the real models`,
|
|
34
|
+
` /collections + enums), returning the`,
|
|
35
|
+
` created ids/handles`,
|
|
36
|
+
` - TEST_IDS: object the stable well-known test ids/handles`,
|
|
37
|
+
`Plus, for Playwright suites, ALSO export:`,
|
|
38
|
+
` - apiContext(request): a helper that returns an authed API request`,
|
|
39
|
+
` context (Bearer token from an env var — NEVER hardcode a secret)`,
|
|
40
|
+
` - login(page): Promise<void> drive/seed an authenticated UI session`,
|
|
41
|
+
`Use only env var NAMES for secrets/connection — never literal values.`,
|
|
42
|
+
].join('\n');
|
|
43
|
+
/** Where an area's shared testkit lives, and how tests import it. */
|
|
44
|
+
export function harnessInfo(areaDir, framework, ext) {
|
|
45
|
+
// Jest tests are flat inside <areaDir> (e.g. .../test/service), so the
|
|
46
|
+
// helpers sit one level up (.../test/helpers). Playwright tests live in
|
|
47
|
+
// per-journey folders under <areaDir> (e.g. .../tests), so helpers are
|
|
48
|
+
// <areaDir>/helpers.
|
|
49
|
+
const base = framework === 'jest' ? dirnameOf(areaDir) : areaDir;
|
|
50
|
+
const kitExt = ext.replace(/\.test|\.spec/, '') || '.js';
|
|
51
|
+
return { dir: `${base}/helpers`, path: `${base}/helpers/testkit${kitExt}` };
|
|
52
|
+
}
|
|
53
|
+
/** Tiny dirname without importing path just for one call. */
|
|
54
|
+
function dirnameOf(p) {
|
|
55
|
+
const i = p.replace(/\/+$/, '').lastIndexOf('/');
|
|
56
|
+
return i === -1 ? '.' : p.slice(0, i);
|
|
57
|
+
}
|
|
58
|
+
/** Build the prompt that generates one area's testkit. */
|
|
59
|
+
function buildHarnessPrompt(harnessPath, framework) {
|
|
60
|
+
const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
|
|
61
|
+
return [
|
|
62
|
+
`You are a senior SDET writing the SHARED test harness ("testkit")`,
|
|
63
|
+
`that every test in this area will import. It must be correct against`,
|
|
64
|
+
`the project's REAL data layer and auth.`,
|
|
65
|
+
``,
|
|
66
|
+
`You are inside the repository with read-only tools (Read, Grep, Glob,`,
|
|
67
|
+
`LS). READ the real DB config, the models/schemas, and the auth`,
|
|
68
|
+
`middleware, then implement the testkit against them.`,
|
|
69
|
+
``,
|
|
70
|
+
`This is a ${framework === 'jest' ? 'Jest/backend' : 'Playwright/UI'} area.`,
|
|
71
|
+
HARNESS_CONTRACT,
|
|
72
|
+
``,
|
|
73
|
+
`The file will be saved at EXACTLY: ${harnessPath}`,
|
|
74
|
+
`Compute any require() paths to app source RELATIVE to that location.`,
|
|
75
|
+
``,
|
|
76
|
+
`Output ONLY the testkit code (CommonJS). No prose, no markdown fences.`,
|
|
77
|
+
`The very first characters must be code. Do NOT write any file.`,
|
|
78
|
+
``,
|
|
79
|
+
`--- PROJECT MAP (data layer) ---`,
|
|
80
|
+
map,
|
|
81
|
+
].join('\n');
|
|
82
|
+
}
|
|
83
|
+
/** Generate one area's testkit content (sync). */
|
|
84
|
+
export function generateHarness(harnessPath, framework) {
|
|
85
|
+
const prompt = buildHarnessPrompt(harnessPath, framework);
|
|
86
|
+
const { program, args } = withReadOnlyTools(splitCommand(loadConfig().aiCommand));
|
|
87
|
+
const output = execFileSync(program, args, {
|
|
88
|
+
input: prompt,
|
|
89
|
+
encoding: 'utf-8',
|
|
90
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
91
|
+
});
|
|
92
|
+
return cleanOutput(output);
|
|
93
|
+
}
|
|
94
|
+
/** Async twin, so a harness can generate alongside other work. */
|
|
95
|
+
export function generateHarnessAsync(harnessPath, framework) {
|
|
96
|
+
const prompt = buildHarnessPrompt(harnessPath, framework);
|
|
97
|
+
const { program, args } = withReadOnlyTools(splitCommand(loadConfig().aiCommand));
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
const child = spawn(program, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
100
|
+
let out = '';
|
|
101
|
+
let err = '';
|
|
102
|
+
child.stdout.setEncoding('utf-8');
|
|
103
|
+
child.stdout.on('data', (d) => (out += d));
|
|
104
|
+
child.stderr.on('data', (d) => (err += d));
|
|
105
|
+
child.on('error', reject);
|
|
106
|
+
child.on('close', (code) => code === 0
|
|
107
|
+
? resolve(cleanOutput(out))
|
|
108
|
+
: reject(new Error(`harness gen exited ${code}: ${err.slice(0, 300)}`)));
|
|
109
|
+
child.stdin.end(prompt);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* A minimal Playwright config so a webapp/ops area that currently uses
|
|
114
|
+
* Cypress can actually run the generated specs. Points at the area's test
|
|
115
|
+
* dir and treats *.test.js as specs.
|
|
116
|
+
*/
|
|
117
|
+
export function playwrightConfig(testDirRelToPackage) {
|
|
118
|
+
return [
|
|
119
|
+
`// @generated by testforge`,
|
|
120
|
+
`// Minimal Playwright config so testforge's generated specs run.`,
|
|
121
|
+
`// Install once: npm i -D @playwright/test && npx playwright install`,
|
|
122
|
+
`const { defineConfig } = require('@playwright/test');`,
|
|
123
|
+
``,
|
|
124
|
+
`module.exports = defineConfig({`,
|
|
125
|
+
` testDir: '${testDirRelToPackage}',`,
|
|
126
|
+
` testMatch: /.*\\.(test|spec)\\.[jt]s$/,`,
|
|
127
|
+
` timeout: 60000,`,
|
|
128
|
+
` use: {`,
|
|
129
|
+
` baseURL: process.env.BASE_URL || 'http://localhost:3000',`,
|
|
130
|
+
` trace: 'on-first-retry',`,
|
|
131
|
+
` },`,
|
|
132
|
+
`});`,
|
|
133
|
+
``,
|
|
134
|
+
].join('\n');
|
|
135
|
+
}
|
|
@@ -25,9 +25,10 @@ import { generateWithAI, generateWithAIAsync } from './ai-generator.js';
|
|
|
25
25
|
import { generateSeed } from './seed-generator.js';
|
|
26
26
|
import { planSuite } from './suite-planner.js';
|
|
27
27
|
import { isSyntaxValid } from './validate.js';
|
|
28
|
+
import { harnessInfo, generateHarnessAsync, playwrightConfig, } from './harness.js';
|
|
28
29
|
import { hashContent, loadCache, saveCache } from './cache.js';
|
|
29
30
|
import { findMarkdownFiles } from './find-files.js';
|
|
30
|
-
import { loadConfig, testDirForType } from './config.js';
|
|
31
|
+
import { loadConfig, testDirForType, resolveFramework } from './config.js';
|
|
31
32
|
const CACHE_PATH = '.testforge/cache.json';
|
|
32
33
|
/** How many times to re-ask the AI when output won't parse. */
|
|
33
34
|
const MAX_GEN_ATTEMPTS = 2;
|
|
@@ -160,6 +161,32 @@ export async function runGenerate(docsDir, useAI = true, force = false, only) {
|
|
|
160
161
|
const seedExt = ext.endsWith('.js') ? '.seed.js' : '.seed.ts';
|
|
161
162
|
const cache = loadCache(CACHE_PATH);
|
|
162
163
|
const results = [];
|
|
164
|
+
// Shared harness per area — generated once, then every test in that
|
|
165
|
+
// area imports it (so files stop inventing their own helper paths).
|
|
166
|
+
const harnessByArea = new Map();
|
|
167
|
+
async function ensureHarness(areaDir, area, framework) {
|
|
168
|
+
if (harnessByArea.has(area))
|
|
169
|
+
return harnessByArea.get(area) ?? undefined;
|
|
170
|
+
const info = harnessInfo(areaDir, framework, ext);
|
|
171
|
+
const kit = await generateValidatedAsync(() => generateHarnessAsync(info.path, framework), ext.endsWith('.js') ? '.js' : '.ts');
|
|
172
|
+
if (kit === null) {
|
|
173
|
+
harnessByArea.set(area, null);
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
mkdirSync(dirname(info.path), { recursive: true });
|
|
177
|
+
writeFileSync(info.path, kit, 'utf-8');
|
|
178
|
+
// Playwright areas need a config so the specs can actually run.
|
|
179
|
+
if (framework === 'playwright') {
|
|
180
|
+
const pkgRoot = dirname(areaDir);
|
|
181
|
+
const cfg = join(pkgRoot, 'playwright.config.js');
|
|
182
|
+
if (!existsSync(cfg)) {
|
|
183
|
+
const rel = areaDir.slice(pkgRoot.length + 1) || '.';
|
|
184
|
+
writeFileSync(cfg, playwrightConfig(`./${rel}`), 'utf-8');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
harnessByArea.set(area, info.path);
|
|
188
|
+
return info.path;
|
|
189
|
+
}
|
|
163
190
|
for (const file of files) {
|
|
164
191
|
const markdown = readFileSync(join(docsDir, file), 'utf-8');
|
|
165
192
|
const hash = hashContent(markdown);
|
|
@@ -172,9 +199,17 @@ export async function runGenerate(docsDir, useAI = true, force = false, only) {
|
|
|
172
199
|
// --journey <x>: generate just the one matching journey.
|
|
173
200
|
if (only && !matchesJourney(file, journey.id, only))
|
|
174
201
|
continue;
|
|
175
|
-
//
|
|
202
|
+
// Layout depends on the framework's test runner:
|
|
203
|
+
// - Playwright (web/ops) recurses, so each journey gets its own
|
|
204
|
+
// FOLDER of focused specs (testDir/<journey>/<slug>).
|
|
205
|
+
// - Jest is often pointed at a flat glob (e.g. the-seam's
|
|
206
|
+
// `**/service/*.test.js`, which does NOT recurse), so api/lambda
|
|
207
|
+
// files stay FLAT in the test dir, journey-prefixed to stay unique
|
|
208
|
+
// (testDir/<journey>-<slug>). Otherwise they'd silently not run.
|
|
176
209
|
const areaDir = testDirForType(config, journey.area);
|
|
177
|
-
const
|
|
210
|
+
const framework = resolveFramework(config, journey.area, journey.type);
|
|
211
|
+
const flat = framework === 'jest';
|
|
212
|
+
const testDir = flat ? areaDir : join(areaDir, journey.id);
|
|
178
213
|
const seedPath = join(seedDir, `${journey.id}${seedExt}`);
|
|
179
214
|
// Skip if unchanged.
|
|
180
215
|
if (!force && cache[file] === hash) {
|
|
@@ -192,6 +227,14 @@ export async function runGenerate(docsDir, useAI = true, force = false, only) {
|
|
|
192
227
|
const collision = collisionWarning(areaDir, journey.id, ext);
|
|
193
228
|
if (collision)
|
|
194
229
|
warnings.push(collision);
|
|
230
|
+
// Ensure this area's shared harness exists (once), so every file
|
|
231
|
+
// imports the same testkit instead of inventing its own.
|
|
232
|
+
const harnessPath = useAI
|
|
233
|
+
? await ensureHarness(areaDir, journey.area, framework)
|
|
234
|
+
: undefined;
|
|
235
|
+
if (useAI && !harnessPath) {
|
|
236
|
+
warnings.push('shared harness generation failed — tests may import missing helpers');
|
|
237
|
+
}
|
|
195
238
|
// 1. Plan the suite (deep mode), then generate one focused file per
|
|
196
239
|
// concern. In basic mode there is no AI planner — one file only.
|
|
197
240
|
const planned = useAI
|
|
@@ -201,14 +244,15 @@ export async function runGenerate(docsDir, useAI = true, force = false, only) {
|
|
|
201
244
|
// call is independent, so a 15-file journey finishes in ~4 waves
|
|
202
245
|
// instead of 15 serial calls.
|
|
203
246
|
const generated = await pool(planned, GEN_CONCURRENCY, async (pf) => {
|
|
204
|
-
|
|
247
|
+
// Flat (jest): prefix the file with the journey so many journeys can
|
|
248
|
+
// share one dir without clashing. Folder (playwright): the journey
|
|
249
|
+
// folder already namespaces it, so the slug alone is enough.
|
|
250
|
+
const base = flat ? `${journey.id}-${pf.slug}` : pf.slug;
|
|
251
|
+
const filePath = join(testDir, `${base}${ext}`);
|
|
205
252
|
let content;
|
|
206
253
|
let status;
|
|
207
254
|
if (useAI) {
|
|
208
|
-
const ai = await generateValidatedAsync(() => generateWithAIAsync(journey, filePath, {
|
|
209
|
-
title: pf.title,
|
|
210
|
-
covers: pf.covers,
|
|
211
|
-
}), ext);
|
|
255
|
+
const ai = await generateValidatedAsync(() => generateWithAIAsync(journey, filePath, { title: pf.title, covers: pf.covers }, harnessPath), ext);
|
|
212
256
|
if (ai !== null) {
|
|
213
257
|
content = ai;
|
|
214
258
|
status = 'ai';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@activate-core/test-forge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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": {
|
|
@@ -24,4 +24,4 @@
|
|
|
24
24
|
"tsx": "^4.22.4",
|
|
25
25
|
"typescript": "^5.4.0"
|
|
26
26
|
}
|
|
27
|
-
}
|
|
27
|
+
}
|