@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.
@@ -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. We look the framework up by AREA, but clamp anything
46
- // that is not jest/supertest/playwright (e.g. a project's cypress setup)
47
- // to the sensible default for the journey's Type — so no other framework
48
- // ever leaks into generated output.
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
- `This test relies on seeded test data (a separate seed file provisions`,
140
- `the database before tests run). Assume the seed has created the`,
141
- `records described in the journey's Data section, using stable test`,
142
- `ids prefixed with "qa-"/"QA-TEST". Reference those known test ids`,
143
- `rather than creating fresh data inside the test where possible. After`,
144
- `a test mutates data, clean up what it created so reruns stay valid.`,
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);
@@ -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
- // Deep mode: each journey becomes a FOLDER of focused test files.
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 testDir = join(areaDir, journey.id);
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
- const filePath = join(testDir, `${pf.slug}${ext}`);
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.5.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
+ }