@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.
@@ -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
- `This test relies on seeded test data (a separate seed file provisions`,
136
- `the database before tests run). Assume the seed has created the`,
137
- `records described in the journey's Data section, using stable test`,
138
- `ids prefixed with "qa-"/"QA-TEST". Reference those known test ids`,
139
- `rather than creating fresh data inside the test where possible. After`,
140
- `a test mutates data, clean up what it created so reruns stay valid.`,
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.5.1",
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
+ }