@activate-core/test-forge 0.6.1 → 0.8.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/cli/index.js CHANGED
@@ -15,13 +15,15 @@
15
15
  * testforge generate --force Regenerate everything, ignoring the cache.
16
16
  */
17
17
  import { VERSION } from '../core/index.js';
18
- import { runGenerate } from '../core/run-generate.js';
18
+ import { runGenerate, runHarness } from '../core/run-generate.js';
19
19
  import { runDiscover } from '../core/discover.js';
20
20
  import { runMap } from '../core/map.js';
21
21
  import { runInit } from '../core/init.js';
22
22
  import { runSetup } from '../core/setup.js';
23
23
  import { runUpdate } from '../core/update.js';
24
24
  import { runEnvTemplate } from '../core/env-template.js';
25
+ import { loadConfig } from '../core/config.js';
26
+ import { dirname } from 'node:path';
25
27
  const command = process.argv[2];
26
28
  const useBasic = process.argv.includes('--basic');
27
29
  const force = process.argv.includes('--force');
@@ -39,6 +41,22 @@ function flagValue(name) {
39
41
  function sourceOptions() {
40
42
  return { reference: flagValue('--reference'), source: flagValue('--source') };
41
43
  }
44
+ /**
45
+ * The output dirs to scan for env vars the GENERATED suite reads (test
46
+ * output dirs + their sibling helpers/ + the seed dir). Shared by the
47
+ * `generate` and `env` commands so the example always reflects the suite.
48
+ */
49
+ function generatedScanDirs() {
50
+ const cfg = loadConfig();
51
+ const dirs = new Set();
52
+ for (const out of Object.values(cfg.testOutput ?? {})) {
53
+ dirs.add(out);
54
+ dirs.add(dirname(out));
55
+ }
56
+ if (cfg.seedOutput)
57
+ dirs.add(cfg.seedOutput);
58
+ return [...dirs];
59
+ }
42
60
  function printUsage() {
43
61
  console.log(`testforge v${VERSION}`);
44
62
  console.log('');
@@ -57,6 +75,7 @@ function printUsage() {
57
75
  console.log(' testforge init Inspect project and write config');
58
76
  console.log(' testforge update Re-check and update only what changed');
59
77
  console.log(' testforge env Write .env.testing.example from docs');
78
+ console.log(' testforge harness [--area <a>] (Re)generate the shared testkit only');
60
79
  }
61
80
  async function main() {
62
81
  if (command === 'update') {
@@ -87,7 +106,20 @@ async function main() {
87
106
  console.log('then run `testforge generate`.');
88
107
  }
89
108
  else if (command === 'env') {
90
- runEnvTemplate('docs', '.env.testing.example');
109
+ runEnvTemplate('docs', '.env.testing.example', '.', generatedScanDirs());
110
+ }
111
+ else if (command === 'harness') {
112
+ console.log('(Re)generating the shared test harness (testkit)...\n');
113
+ const written = await runHarness(flagValue('--area'));
114
+ if (written.length === 0) {
115
+ console.log('No harness generated. Check the --area or run `generate` first.');
116
+ }
117
+ else {
118
+ for (const p of written)
119
+ console.log(` testkit: ${p}`);
120
+ console.log('\nDone. Existing tests import this testkit unchanged; its setup()' +
121
+ ' seeds + mints/populates the auth tokens they read.');
122
+ }
91
123
  }
92
124
  else if (command === 'generate') {
93
125
  const useAI = !useBasic;
@@ -130,6 +162,11 @@ async function main() {
130
162
  if (seedFailures > 0) {
131
163
  console.log(`Warning: ${seedFailures} seed file(s) could not be generated — see notes above.`);
132
164
  }
165
+ // Auto-refresh the env template so running `generate` on its own (not
166
+ // just `setup`) tells the developer exactly which credentials the
167
+ // generated suite needs — including vars the tests themselves read.
168
+ console.log('');
169
+ runEnvTemplate('docs', '.env.testing.example', '.', generatedScanDirs());
133
170
  }
134
171
  }
135
172
  else {
@@ -55,7 +55,8 @@ const SOURCE_EXT = /\.(c|m)?[jt]sx?$/i;
55
55
  * skipping vendored/build/test output, with a file cap so a huge repo
56
56
  * can't stall the run.
57
57
  */
58
- function scanSourceEnvVars(srcDir, maxFiles = 4000) {
58
+ function scanSourceEnvVars(srcDir, opts = {}) {
59
+ const { includeTests = false, maxFiles = 4000 } = opts;
59
60
  const vars = new Set();
60
61
  let seen = 0;
61
62
  const walk = (dir) => {
@@ -78,8 +79,10 @@ function scanSourceEnvVars(srcDir, maxFiles = 4000) {
78
79
  walk(full);
79
80
  }
80
81
  else if (SOURCE_EXT.test(entry.name)) {
81
- // Skip test files — we want app source, not existing tests.
82
- if (/\.(test|spec)\./i.test(entry.name))
82
+ // App-source scan skips test files; the generated-suite scan
83
+ // (includeTests) WANTS them — that is where the tests' own env
84
+ // vars (tokens, ids, base URLs) live.
85
+ if (!includeTests && /\.(test|spec)\./i.test(entry.name))
83
86
  continue;
84
87
  seen++;
85
88
  try {
@@ -108,7 +111,7 @@ function areaAndJourney(file) {
108
111
  // Fallback: flat file with no area folder.
109
112
  return { area: 'general', journey: noExt };
110
113
  }
111
- export function runEnvTemplate(docsDir, outPath, srcDir = '.') {
114
+ export function runEnvTemplate(docsDir, outPath, srcDir = '.', testDirs = []) {
112
115
  if (!existsSync(docsDir)) {
113
116
  console.log(`No docs found at "${docsDir}". Run \`testforge setup\` first.`);
114
117
  return;
@@ -153,7 +156,21 @@ export function runEnvTemplate(docsDir, outPath, srcDir = '.') {
153
156
  }
154
157
  const sourceVars = scanSourceEnvVars(srcDir);
155
158
  const bootVars = [...sourceVars].filter((v) => !knownFromDocs.has(v)).sort();
156
- if (byArea.size === 0 && bootVars.length === 0) {
159
+ // Vars the GENERATED suite (tests + seeds + testkit) actually reads —
160
+ // scanned from the output dirs, INCLUDING test files. This is what
161
+ // captures tokens/ids/base-URLs the generated code needs but that no
162
+ // doc/source mentioned.
163
+ const suiteScan = new Set();
164
+ for (const d of testDirs) {
165
+ for (const v of scanSourceEnvVars(d, { includeTests: true })) {
166
+ suiteScan.add(v);
167
+ }
168
+ }
169
+ const alreadyShown = new Set([...knownFromDocs, ...bootVars]);
170
+ const suiteVars = [...suiteScan]
171
+ .filter((v) => !alreadyShown.has(v))
172
+ .sort();
173
+ if (byArea.size === 0 && bootVars.length === 0 && suiteVars.length === 0) {
157
174
  console.log('No environment variables found in the docs or source.');
158
175
  return;
159
176
  }
@@ -167,6 +184,15 @@ export function runEnvTemplate(docsDir, outPath, srcDir = '.') {
167
184
  '# Keep .env.testing OUT of version control.',
168
185
  '',
169
186
  ];
187
+ if (suiteVars.length > 0) {
188
+ lines.push(`# ${'='.repeat(40)}`);
189
+ lines.push('# GENERATED SUITE (env the generated tests/testkit read)');
190
+ lines.push(`# ${'='.repeat(40)}`);
191
+ for (const v of suiteVars) {
192
+ lines.push(`${v}= # used by: generated tests`);
193
+ }
194
+ lines.push('');
195
+ }
170
196
  if (bootVars.length > 0) {
171
197
  lines.push(`# ${'='.repeat(40)}`);
172
198
  lines.push('# APP (read directly from source — REQUIRED to boot)');
@@ -34,11 +34,37 @@ export const HARNESS_CONTRACT = [
34
34
  ` /collections + enums), returning the`,
35
35
  ` created ids/handles`,
36
36
  ` - TEST_IDS: object the stable well-known test ids/handles`,
37
+ ` - setup(): Promise<void> THE one-call entry point (idempotent,`,
38
+ ` safe to call from every beforeAll AND`,
39
+ ` from a Playwright globalSetup). It:`,
40
+ ` 1. connect()s + seed()s;`,
41
+ ` 2. MINTS a fresh auth access token at`,
42
+ ` runtime — read the project's REAL`,
43
+ ` login/refresh auth (controllers/`,
44
+ ` middleware) and either exchange a`,
45
+ ` seed token (env, e.g. a refresh`,
46
+ ` token) or log in the seeded qa-`,
47
+ ` user to obtain it;`,
48
+ ` 3. CACHES the minted token to a temp`,
49
+ ` file so parallel workers reuse it`,
50
+ ` (one mint per run, no churn);`,
51
+ ` 4. POPULATES process.env for EVERY`,
52
+ ` token/id env var the suite reads`,
53
+ ` — GREP the sibling test files for`,
54
+ ` process.env.*TOKEN*/*ID* names and`,
55
+ ` set each to the minted/seeded`,
56
+ ` value, so tests that read`,
57
+ ` process.env work with NO hand-`,
58
+ ` pasted tokens.`,
59
+ ` - token(role?): string the current access token accessor`,
37
60
  `Plus, for Playwright suites, ALSO export:`,
38
61
  ` - apiContext(request): a helper that returns an authed API request`,
39
- ` context (Bearer token from an env var — NEVER hardcode a secret)`,
62
+ ` context (Bearer = the minted token — NEVER hardcode a secret)`,
40
63
  ` - login(page): Promise<void> drive/seed an authenticated UI session`,
41
64
  `Use only env var NAMES for secrets/connection — never literal values.`,
65
+ `The ONLY credentials a developer should have to provide are the DB`,
66
+ `connection, a Stripe test key, and ONE seed/refresh token (or seeded`,
67
+ `login) — everything else setup() derives at runtime.`,
42
68
  ].join('\n');
43
69
  /** Where an area's shared testkit lives, and how tests import it. */
44
70
  export function harnessInfo(areaDir, framework, ext) {
@@ -64,8 +90,12 @@ function buildHarnessPrompt(harnessPath, framework) {
64
90
  `the project's REAL data layer and auth.`,
65
91
  ``,
66
92
  `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.`,
93
+ `LS). READ the real DB config, the models/schemas, AND the real auth`,
94
+ `flow (login / refresh-token / OTP controllers + middleware) so setup()`,
95
+ `mints tokens the way the app actually does. Also GREP the sibling test`,
96
+ `files next to this testkit for every process.env.<NAME> they read`,
97
+ `(especially *TOKEN*, *_ID, base URLs) and make setup() POPULATE those`,
98
+ `exact names — the tests must run without any hand-pasted token.`,
69
99
  ``,
70
100
  `This is a ${framework === 'jest' ? 'Jest/backend' : 'Playwright/UI'} area.`,
71
101
  HARNESS_CONTRACT,
@@ -112,9 +142,10 @@ export function generateHarnessAsync(harnessPath, framework) {
112
142
  /**
113
143
  * A minimal Playwright config so a webapp/ops area that currently uses
114
144
  * Cypress can actually run the generated specs. Points at the area's test
115
- * dir and treats *.test.js as specs.
145
+ * dir, treats *.test.js as specs, and runs the testkit's setup() once via
146
+ * globalSetup (mints/populates tokens before any worker starts).
116
147
  */
117
- export function playwrightConfig(testDirRelToPackage) {
148
+ export function playwrightConfig(testDirRelToPackage, globalSetupRelToPackage) {
118
149
  return [
119
150
  `// @generated by testforge`,
120
151
  `// Minimal Playwright config so testforge's generated specs run.`,
@@ -124,6 +155,7 @@ export function playwrightConfig(testDirRelToPackage) {
124
155
  `module.exports = defineConfig({`,
125
156
  ` testDir: '${testDirRelToPackage}',`,
126
157
  ` testMatch: /.*\\.(test|spec)\\.[jt]s$/,`,
158
+ ` globalSetup: require.resolve('${globalSetupRelToPackage}'),`,
127
159
  ` timeout: 60000,`,
128
160
  ` use: {`,
129
161
  ` baseURL: process.env.BASE_URL || 'http://localhost:3000',`,
@@ -133,3 +165,21 @@ export function playwrightConfig(testDirRelToPackage) {
133
165
  ``,
134
166
  ].join('\n');
135
167
  }
168
+ /**
169
+ * Playwright globalSetup file: runs the testkit's one-call setup() once
170
+ * before the suite (connect + seed + mint/populate tokens). Sits next to
171
+ * the testkit and just delegates to it.
172
+ */
173
+ export function globalSetupContent() {
174
+ return [
175
+ `// @generated by testforge`,
176
+ `// Runs once before the Playwright suite: seeds data and mints/`,
177
+ `// populates the auth tokens the specs read (via the testkit).`,
178
+ `const testkit = require('./testkit');`,
179
+ ``,
180
+ `module.exports = async () => {`,
181
+ ` await testkit.setup();`,
182
+ `};`,
183
+ ``,
184
+ ].join('\n');
185
+ }
@@ -25,7 +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
+ import { harnessInfo, generateHarnessAsync, generateHarness, playwrightConfig, globalSetupContent, } from './harness.js';
29
29
  import { hashContent, loadCache, saveCache } from './cache.js';
30
30
  import { findMarkdownFiles } from './find-files.js';
31
31
  import { loadConfig, testDirForType, resolveFramework } from './config.js';
@@ -140,6 +140,53 @@ function collisionWarning(testDir, id, ext) {
140
140
  return null;
141
141
  return `an existing test with different naming already covers this journey: ${clashes.join(', ')} — new file may duplicate it`;
142
142
  }
143
+ /**
144
+ * (Re)generate ONLY the shared harness for one or all areas — without
145
+ * touching any test file. Use this after upgrading the harness (e.g. to
146
+ * add token-minting): the new testkit populates process.env for the token
147
+ * vars the existing tests read, so they run unchanged. Cheap: one AI call
148
+ * per area, no test regeneration.
149
+ */
150
+ export async function runHarness(only) {
151
+ const config = loadConfig();
152
+ const ext = config.testExtension ?? '.test.ts';
153
+ const areas = Object.keys(config.testOutput ?? {});
154
+ const written = [];
155
+ for (const area of areas) {
156
+ if (only && area !== only)
157
+ continue;
158
+ const areaDir = testDirForType(config, area);
159
+ const framework = resolveFramework(config, area, area);
160
+ const info = harnessInfo(areaDir, framework, ext);
161
+ let kit;
162
+ try {
163
+ kit = generateHarness(info.path, framework);
164
+ }
165
+ catch {
166
+ console.log(` harness generation failed for area "${area}" — skipped`);
167
+ continue;
168
+ }
169
+ if (!isSyntaxValid(kit, ext.endsWith('.js') ? '.js' : '.ts')) {
170
+ console.log(` harness for area "${area}" did not parse — skipped`);
171
+ continue;
172
+ }
173
+ mkdirSync(dirname(info.path), { recursive: true });
174
+ writeFileSync(info.path, kit, 'utf-8');
175
+ if (framework === 'playwright') {
176
+ const gsPath = join(info.dir, 'global-setup.js');
177
+ writeFileSync(gsPath, globalSetupContent(), 'utf-8');
178
+ const pkgRoot = dirname(areaDir);
179
+ const cfg = join(pkgRoot, 'playwright.config.js');
180
+ if (!existsSync(cfg)) {
181
+ const rel = areaDir.slice(pkgRoot.length + 1) || '.';
182
+ const gsRel = gsPath.slice(pkgRoot.length + 1);
183
+ writeFileSync(cfg, playwrightConfig(`./${rel}`, `./${gsRel}`), 'utf-8');
184
+ }
185
+ }
186
+ written.push(info.path);
187
+ }
188
+ return written;
189
+ }
143
190
  /** Does this doc file / journey match a --journey selector? */
144
191
  function matchesJourney(file, journeyId, sel) {
145
192
  const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-');
@@ -175,13 +222,17 @@ export async function runGenerate(docsDir, useAI = true, force = false, only) {
175
222
  }
176
223
  mkdirSync(dirname(info.path), { recursive: true });
177
224
  writeFileSync(info.path, kit, 'utf-8');
178
- // Playwright areas need a config so the specs can actually run.
225
+ // Playwright areas need a config + globalSetup so the specs can run
226
+ // AND get their tokens minted once before the suite.
179
227
  if (framework === 'playwright') {
228
+ const gsPath = join(info.dir, 'global-setup.js');
229
+ writeFileSync(gsPath, globalSetupContent(), 'utf-8');
180
230
  const pkgRoot = dirname(areaDir);
181
231
  const cfg = join(pkgRoot, 'playwright.config.js');
182
232
  if (!existsSync(cfg)) {
183
233
  const rel = areaDir.slice(pkgRoot.length + 1) || '.';
184
- writeFileSync(cfg, playwrightConfig(`./${rel}`), 'utf-8');
234
+ const gsRel = gsPath.slice(pkgRoot.length + 1);
235
+ writeFileSync(cfg, playwrightConfig(`./${rel}`, `./${gsRel}`), 'utf-8');
185
236
  }
186
237
  }
187
238
  harnessByArea.set(area, info.path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@activate-core/test-forge",
3
- "version": "0.6.1",
3
+ "version": "0.8.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": {