@activate-core/test-forge 0.5.1 → 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 +24 -10
- package/dist/core/harness.js +135 -0
- package/dist/core/run-generate.js +36 -4
- package/package.json +2 -2
|
@@ -39,7 +39,7 @@ 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
45
|
// (web/UI) tests — any other framework (e.g. a project's Cypress setup)
|
|
@@ -132,13 +132,27 @@ export function buildPrompt(journey, map, testPath, focus) {
|
|
|
132
132
|
``,
|
|
133
133
|
]
|
|
134
134
|
: []),
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
]),
|
|
142
156
|
`Rules for your output:`,
|
|
143
157
|
`- Write the test in ${language}.`,
|
|
144
158
|
`- Output ONLY the raw test code. Your entire response must be valid`,
|
|
@@ -187,9 +201,9 @@ export function generateWithAI(journey, testPath, focus) {
|
|
|
187
201
|
* journey's suite can be generated concurrently (each spawns its own
|
|
188
202
|
* `claude` process). Same prompt, same read-only exploration.
|
|
189
203
|
*/
|
|
190
|
-
export async function generateWithAIAsync(journey, testPath, focus) {
|
|
204
|
+
export async function generateWithAIAsync(journey, testPath, focus, harnessPath) {
|
|
191
205
|
const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
|
|
192
|
-
const prompt = buildPrompt(journey, map, testPath, focus);
|
|
206
|
+
const prompt = buildPrompt(journey, map, testPath, focus, harnessPath);
|
|
193
207
|
const { program, args } = withReadOnlyTools(splitCommand(loadConfig().aiCommand));
|
|
194
208
|
const stdout = await runWithStdin(program, args, prompt);
|
|
195
209
|
return cleanOutput(stdout);
|
|
@@ -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,6 +25,7 @@ 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
31
|
import { loadConfig, testDirForType, resolveFramework } from './config.js';
|
|
@@ -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);
|
|
@@ -200,6 +227,14 @@ export async function runGenerate(docsDir, useAI = true, force = false, only) {
|
|
|
200
227
|
const collision = collisionWarning(areaDir, journey.id, ext);
|
|
201
228
|
if (collision)
|
|
202
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
|
+
}
|
|
203
238
|
// 1. Plan the suite (deep mode), then generate one focused file per
|
|
204
239
|
// concern. In basic mode there is no AI planner — one file only.
|
|
205
240
|
const planned = useAI
|
|
@@ -217,10 +252,7 @@ export async function runGenerate(docsDir, useAI = true, force = false, only) {
|
|
|
217
252
|
let content;
|
|
218
253
|
let status;
|
|
219
254
|
if (useAI) {
|
|
220
|
-
const ai = await generateValidatedAsync(() => generateWithAIAsync(journey, filePath, {
|
|
221
|
-
title: pf.title,
|
|
222
|
-
covers: pf.covers,
|
|
223
|
-
}), ext);
|
|
255
|
+
const ai = await generateValidatedAsync(() => generateWithAIAsync(journey, filePath, { title: pf.title, covers: pf.covers }, harnessPath), ext);
|
|
224
256
|
if (ai !== null) {
|
|
225
257
|
content = ai;
|
|
226
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
|
+
}
|