@activate-core/test-forge 1.0.0 → 1.0.2

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
@@ -19,7 +19,7 @@
19
19
  * (an auth-gated page can move unseen).
20
20
  */
21
21
  import { VERSION } from '../core/index.js';
22
- import { runGenerate } from '../core/generate/run-generate.js';
22
+ import { runGenerate, runHarness } from '../core/generate/run-generate.js';
23
23
  import { runTests } from '../core/run/run-tests.js';
24
24
  import { runDiscover } from '../core/docs/discover.js';
25
25
  import { runMap } from '../core/project/map.js';
@@ -79,7 +79,7 @@ function printUsage() {
79
79
  console.log(' testforge generate --force Regenerate everything');
80
80
  console.log(' testforge generate --no-wait Stop when the AI hits its usage limit (default: wait for the reset and resume)');
81
81
  console.log(' testforge generate --no-verify Skip running each journey once after generating (default: run and repair)');
82
- console.log(' testforge generate --journey <name> Generate just ONE journey');
82
+ console.log(' testforge generate --journey <name> Generate just ONE journey', ' testforge generate --harness Regenerate only the shared harness (testkit), no test files');
83
83
  console.log(' testforge generate --area <area> Generate a whole area (e.g. api)');
84
84
  console.log(' testforge discover Draft journey docs (asks: map or reference)');
85
85
  console.log(' testforge discover --journey <name> Draft just ONE journey doc');
@@ -144,6 +144,13 @@ async function main() {
144
144
  });
145
145
  process.exitCode = code;
146
146
  }
147
+ else if (command === 'generate' && process.argv.includes('--harness')) {
148
+ // Regenerate only the shared harness (testkit), leaving every test file
149
+ // as it is — e.g. after a TestForge upgrade changes what a harness does.
150
+ console.log('Regenerating the shared test harness...\n');
151
+ const written = await runHarness(flagValue('--area'));
152
+ console.log(written.length ? `Written:\n ${written.join('\n ')}` : 'No harness written.');
153
+ }
147
154
  else if (command === 'generate') {
148
155
  const useAI = !useBasic;
149
156
  console.log(useAI
@@ -41,6 +41,9 @@ export function buildReviewPrompt(docPath, srcDir, reference) {
41
41
  `- Code branches: read the code this journey runs, client and server.`,
42
42
  ` Is every validation, error response and conditional branch an`,
43
43
  ` [observed] criterion or a Question? Add the missing ones.`,
44
+ `- [observed]: open the exact code each [observed] criterion rests on and`,
45
+ ` confirm it states what that code DOES (follow the real state shape and`,
46
+ ` call path, not a similarly named field). Fix any that misread it.`,
44
47
  `- Tiers: does every [spec] criterion quote a real source sentence?`,
45
48
  ` Re-tag any that does not. Does any criterion contradict the code`,
46
49
  ` with nothing to test (a rule the screen has no way to break)? Turn`,
@@ -22,7 +22,7 @@
22
22
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
23
23
  import { dirname } from 'node:path';
24
24
  import { isInteractive, promptChoice, promptLine } from '../prompt.js';
25
- import { SOURCE_PATH } from '../paths.js';
25
+ import { SOURCE_PATH, ensureStateDirIgnored } from '../paths.js';
26
26
  /** Read the remembered source choice, or null if none/invalid. */
27
27
  export function loadSourceChoice() {
28
28
  if (!existsSync(SOURCE_PATH))
@@ -58,6 +58,7 @@ export function saveSourceChoice(choice) {
58
58
  if (journeys && Object.keys(journeys).length)
59
59
  next.journeys = journeys;
60
60
  mkdirSync(dirname(SOURCE_PATH), { recursive: true });
61
+ ensureStateDirIgnored();
61
62
  writeFileSync(SOURCE_PATH, JSON.stringify(next, null, 2), 'utf-8');
62
63
  }
63
64
  /**
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { spawnSync } from 'node:child_process';
19
19
  import { resolve } from 'node:path';
20
- import { existsSync } from 'node:fs';
20
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
21
21
  import { join, dirname } from 'node:path';
22
22
  /** Walk up from a file to the nearest package that has ESLint installed. */
23
23
  function eslintFor(file) {
@@ -77,7 +77,9 @@ export function lintGenerated(files) {
77
77
  encoding: 'utf-8',
78
78
  timeout: 120_000,
79
79
  });
80
- const after = readProblems(g.bin, cwd, g.files);
80
+ let after = readProblems(g.bin, cwd, g.files);
81
+ if (relaxTypingRules(after.typingRules))
82
+ after = readProblems(g.bin, cwd, g.files);
81
83
  checked += g.files.length;
82
84
  fixed += Math.max(0, before - after.count);
83
85
  remaining.push(...after.lines);
@@ -87,6 +89,32 @@ export function lintGenerated(files) {
87
89
  function countProblems(bin, cwd, files) {
88
90
  return readProblems(bin, cwd, files).count;
89
91
  }
92
+ /**
93
+ * Strictness rules a house config applies to app code that say nothing about
94
+ * whether a generated TEST is right: `any` in a fixture, `require` in a
95
+ * CommonJS helper. Left in place they fail `npm run lint` in CI on files no
96
+ * one hand-edits, so a generated file gets a header turning off exactly the
97
+ * ones it trips — every other rule stays on.
98
+ */
99
+ const TYPING_RULES = new Set([
100
+ '@typescript-eslint/no-explicit-any',
101
+ '@typescript-eslint/no-require-imports',
102
+ '@typescript-eslint/no-var-requires',
103
+ '@typescript-eslint/ban-ts-comment',
104
+ ]);
105
+ /** Add the header to each file; true when any file changed. */
106
+ function relaxTypingRules(byFile) {
107
+ let changed = false;
108
+ for (const [file, rules] of byFile) {
109
+ if (!rules.size)
110
+ continue;
111
+ const text = readFileSync(file, 'utf-8');
112
+ const header = `/* eslint-disable ${[...rules].sort().join(', ')} -- generated by testforge */\n`;
113
+ writeFileSync(file, header + text, 'utf-8');
114
+ changed = true;
115
+ }
116
+ return changed;
117
+ }
90
118
  function readProblems(bin, cwd, files) {
91
119
  const res = spawnSync(bin, ['-f', 'json', '--no-error-on-unmatched-pattern', ...files], {
92
120
  cwd,
@@ -99,14 +127,20 @@ function readProblems(bin, cwd, files) {
99
127
  parsed = JSON.parse(res.stdout || '[]');
100
128
  }
101
129
  catch {
102
- return { count: 0, lines: [] }; // linter unavailable or output unusable
130
+ return { count: 0, lines: [], typingRules: new Map() }; // linter unavailable or output unusable
103
131
  }
104
132
  const lines = [];
133
+ const typingRules = new Map();
105
134
  let count = 0;
106
135
  for (const file of parsed) {
107
136
  for (const m of file.messages) {
108
137
  if (m.severity !== 2)
109
138
  continue; // warnings do not fail a build
139
+ if (m.ruleId && TYPING_RULES.has(m.ruleId)) {
140
+ const set = typingRules.get(file.filePath) ?? new Set();
141
+ set.add(m.ruleId);
142
+ typingRules.set(file.filePath, set);
143
+ }
110
144
  count += 1;
111
145
  if (lines.length < 20) {
112
146
  const rel = file.filePath.replace(cwd + '/', '');
@@ -114,5 +148,5 @@ function readProblems(bin, cwd, files) {
114
148
  }
115
149
  }
116
150
  }
117
- return { count, lines };
151
+ return { count, lines, typingRules };
118
152
  }
@@ -297,6 +297,46 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
297
297
  ` list, and treat a template placeholder (DUMMY, replace_me, an empty`,
298
298
  ` value, a bracketed name) as absent rather than as a credential.`,
299
299
  ` Say which actor was missing in the skip reason.`,
300
+ `(13) A [baseline] CHECK PROVES ROBUSTNESS, NOT A VALUE. "Opens without a`,
301
+ ` crash" is proved by the page loading with no error and no 5xx — not by`,
302
+ ` an exact URL, trailing slash, heading text or element the document`,
303
+ ` does not name. Never add an extra condition the criterion does not`,
304
+ ` state (e.g. "double-click opens exactly one pop-up" when the document`,
305
+ ` only says the button opens it), and never assert data in a place the`,
306
+ ` page does not render it — check where the app shows it first.`,
307
+ `(14) SCOPE EVERY LOCATOR TO THE JOURNEY'S OWN UI. Global components`,
308
+ ` (a login pop-up, a header, a cookie bar) share ids and labels with`,
309
+ ` the page under test — "#password" can match the login modal's field.`,
310
+ ` Locate inside the journey's form/container, never page-wide by a`,
311
+ ` bare id or label that another always-mounted component also uses.`,
312
+ `(15) NOTHING YOU ARE NOT TESTING MAY BLOCK YOU. Apps open their own`,
313
+ ` pop-ups for a signed-in or new user (referral/promo, cookie consent,`,
314
+ ` onboarding tips, notification prompts). Use the harness's login /`,
315
+ ` storage helpers that pre-suppress them; if one still opens, close it`,
316
+ ` before you continue. A click intercepted by an overlay the test is`,
317
+ ` not about is your bug, not the product's.`,
318
+ `(16) "INSIDE" MEANS THE MIDDLE. To click inside a panel, click its centre`,
319
+ ` or a non-interactive child — never a corner offset like (5,5): with a`,
320
+ ` border-radius that point is outside the element and lands on the`,
321
+ ` overlay behind it.`,
322
+ `(17) LET THE APP SETTLE BEFORE YOU CHANGE THE SESSION. After a login,`,
323
+ ` sign-out or navigation the app sends its own requests and may`,
324
+ ` redirect by itself. Wait for the network to go idle before rotating`,
325
+ ` a session, logging in elsewhere or navigating away, and assert the`,
326
+ ` URL the APP chooses — never force a navigation that can cut off the`,
327
+ ` app's own redirect.`,
328
+ `(18) ONE SOURCE FOR SEEDED IDS. Read every seeded id and handle from the`,
329
+ ` harness (TEST_IDS / the values setup() publishes). If a test needs a`,
330
+ ` private account, override EVERY id that belongs to it, never a`,
331
+ ` subset — a leftover shared id makes you compare against another`,
332
+ ` customer's row.`,
333
+ `(19) VERIFICATION CODES THE TEST CANNOT READ (SMS/email OTP, magic`,
334
+ ` links, OAuth). Drive the real UI up to the code step, then use the`,
335
+ ` harness's bypassVerification() so the final verify/register request`,
336
+ ` is answered with a REAL account and a REAL token; everything after it`,
337
+ ` (logged-in state, redirect, payment) is then tested for real. Put`,
338
+ ` "(verification bypassed)" in that test's title. Wrong-code and`,
339
+ ` expired-code tests do NOT bypass: the real server must refuse them.`,
300
340
  `When rules conflict, prefer reporting LESS: a pending test costs a line`,
301
341
  `in the report; a false failure costs the team a release.`,
302
342
  ``,
@@ -105,6 +105,8 @@ export const HARNESS_CONTRACT = [
105
105
  ` process.env work with NO hand-`,
106
106
  ` pasted tokens.`,
107
107
  ` - token(role?): string the current access token accessor`,
108
+ ` - REQUIRED_PACKAGES, resetRateLimits(), bypassVerification(): see`,
109
+ ` RUN ENVIRONMENT below.`,
108
110
  `Plus, for Playwright suites, ALSO export:`,
109
111
  ` - apiContext(request): a helper that returns an authed API request`,
110
112
  ` context (Bearer = the minted token — NEVER hardcode a secret).`,
@@ -180,6 +182,52 @@ export const HARNESS_CONTRACT = [
180
182
  ` distinct token (env). If only one identity is available, make those`,
181
183
  ` cases pending — never assert a scope the token cannot actually prove.`,
182
184
  ``,
185
+ `RUN ENVIRONMENT — the suite must run on a fresh machine given only the`,
186
+ `env file. Every rule below exists because breaking it stopped a real run.`,
187
+ ` - PACKAGES: use only Node built-ins, the test runner, and libraries the`,
188
+ ` project already lists in package.json. If the data layer needs a`,
189
+ ` driver or helper the project lacks (mysql2, pg, mongodb, bcryptjs),`,
190
+ ` require it lazily and, when missing, throw "testkit: missing package`,
191
+ ` <name> — run: npm i -D <name>". Also export REQUIRED_PACKAGES: an`,
192
+ ` array literal naming every such package, so the runner can check them`,
193
+ ` before the suite starts.`,
194
+ ` - NO OTHER RUNTIMES: never shell out to php, python, artisan, docker or`,
195
+ ` any CLI — the test machine need not have them. Hash a password in`,
196
+ ` Node: bcrypt via bcryptjs, and for PHP/Laravel rewrite the "$2a$" /`,
197
+ ` "$2b$" prefix to "$2y$" (the same algorithm).`,
198
+ ` - THE SEED OWNS ITS CREDENTIALS: an account the seed CREATES gets a`,
199
+ ` fixed test password constant that satisfies the app's password rule`,
200
+ ` (e.g. 'QaTest#2026pass'), overridable by TEST_<ROLE>_PASSWORD. Never`,
201
+ ` require env for a credential the seed itself sets.`,
202
+ ` - SEED EVERYTHING A TEST NEEDS: special accounts (unverified, banned,`,
203
+ ` soft-deleted, other roles, expired temporary passwords), codes`,
204
+ ` (valid / expired / restricted coupons), shareable links, a second`,
205
+ ` customer's records — all seeded and exposed through TEST_IDS and the`,
206
+ ` env setup() publishes. In auto mode no test waits on hand-made data.`,
207
+ ` - LOG IN ONCE: mint each role's token once per run (globalSetup) and`,
208
+ ` reuse the cached token; login(page) injects it. Backends rate-limit`,
209
+ ` by IP (e.g. 60 requests/minute) and a suite that calls the login API`,
210
+ ` per test is throttled (HTTP 429). If the limiter's state lives in a`,
211
+ ` store the testkit reaches in local mode (e.g. the backend's Redis`,
212
+ ` cache), export resetRateLimits(): clear ONLY the limiter keys, gated`,
213
+ ` exactly like reset(), and call it from setup().`,
214
+ ` - SUPPRESS APP POP-UPS: read the client for pop-ups that open by`,
215
+ ` themselves for the seeded roles (referral/promo, cookie consent,`,
216
+ ` onboarding, notification prompts) and have login(page) and any`,
217
+ ` seeded browser storage pre-set the flags that suppress them, like a`,
218
+ ` "prompt already shown" flag. Give the seeded user a first name so`,
219
+ ` greetings render.`,
220
+ ` - VERIFICATION BYPASS (Playwright): when the app verifies by a code the`,
221
+ ` test cannot read (SMS/email OTP, magic link, OAuth), export`,
222
+ ` bypassVerification(page, { url, account }): route the request at`,
223
+ ` \`url\` (the final verify/register call); in the handler, seed the`,
224
+ ` account (verified, with the given email/phone/name and the seed`,
225
+ ` password), log it in through the REAL login API, and fulfil the`,
226
+ ` intercepted request with the app's REAL success response shape (read`,
227
+ ` the controller and the client that consumes it) carrying that real`,
228
+ ` token and user. Only the code check is skipped — everything after it`,
229
+ ` runs against the real backend.`,
230
+ ``,
183
231
  `CONNECTION LIFECYCLE — setup() must be safe to call repeatedly across`,
184
232
  `suites, or one suite's teardown breaks the next (MongoNotConnectedError).`,
185
233
  ` - connect() and setup() MUST check the live connection state before`,
@@ -303,7 +351,7 @@ export function generateHarnessAsync(harnessPath, framework) {
303
351
  * dir, treats *.test.js as specs, and runs the testkit's setup() once via
304
352
  * globalSetup (mints/populates tokens before any worker starts).
305
353
  */
306
- export function playwrightConfig(testDirRelToPackage, globalSetupRelToPackage, category = 'web') {
354
+ export function playwrightConfig(testDirRelToPackage, globalSetupRelToPackage, category = 'web', outputDirRelToPackage = 'test-results') {
307
355
  return [
308
356
  `// @generated by testforge`,
309
357
  `// Minimal Playwright config so testforge's generated specs run.`,
@@ -315,6 +363,9 @@ export function playwrightConfig(testDirRelToPackage, globalSetupRelToPackage, c
315
363
  ` testMatch: /.*\\.(test|spec)\\.[jt]s$/,`,
316
364
  ` globalSetup: require.resolve('${globalSetupRelToPackage}'),`,
317
365
  ` timeout: 60000,`,
366
+ ` // Run output (failure reports, traces) stays with testforge's local state,`,
367
+ ` // which is not committed.`,
368
+ ` outputDir: '${outputDirRelToPackage}',`,
318
369
  ` // Serial, like the Jest config: these suites share ONE test database`,
319
370
  ` // and seed/reset it, so parallel workers race on the same rows and the`,
320
371
  ` // same run gives different answers twice. It also keeps --headed to a`,
@@ -17,8 +17,8 @@
17
17
  * generated-tests/ when not configured.
18
18
  */
19
19
  import { readFileSync, writeFileSync, mkdirSync, existsSync, } from 'node:fs';
20
- import { join, dirname, basename, extname } from 'node:path';
21
- import { CACHE_PATH } from '../paths.js';
20
+ import { join, dirname, basename, extname, relative, resolve, sep } from 'node:path';
21
+ import { CACHE_PATH, RESULTS_DIR, ensureStateDirIgnored } from '../paths.js';
22
22
  import { lintGenerated } from '../gates/lint.js';
23
23
  import { parseJourney, notAJourneyDoc } from '../docs/parser.js';
24
24
  import { writeCoverageManifest } from '../gates/coverage.js';
@@ -27,7 +27,8 @@ import { aiFailureNote, aiUnavailableReason, confirmAiAlive, lastAiFailureWasPro
27
27
  import { generateTestCases } from './generator.js';
28
28
  import { writeDeferredStub } from './playwright-writer.js';
29
29
  import { generateWithAI, generateWithAIAsync, repairWithAIAsync, } from './ai-generator.js';
30
- import { verifyFiles } from './verify.js';
30
+ import { verifyFiles, browserPreflight, missingPackages } from './verify.js';
31
+ import { packageRoot } from '../run/run-tests.js';
31
32
  import { groundJourney } from '../docs/grounding.js';
32
33
  import { generateSeed } from './seed-generator.js';
33
34
  import { isSyntaxValid, checkTestGates } from '../gates/validate.js';
@@ -183,6 +184,20 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
183
184
  const ext = config.testExtension ?? '.test.ts';
184
185
  const seedDir = config.seedOutput ?? 'generated-tests/seed';
185
186
  const seedExt = ext.endsWith('.js') ? '.seed.js' : '.seed.ts';
187
+ // A browser that cannot start fails every verify, and each failure would be
188
+ // sent for repair. Find out once, before any AI call is spent.
189
+ if (useAI && verifyOn) {
190
+ for (const area of Object.keys(config.testOutput ?? {})) {
191
+ if (resolveFramework(config, area, area) !== 'playwright')
192
+ continue;
193
+ const problem = browserPreflight(packageRoot(testDirForType(config, area)));
194
+ if (problem) {
195
+ throw new Error(`Cannot verify "${area}" tests: ${problem}.\n` +
196
+ 'Fix that and re-run, or pass --no-verify to write tests without running them.');
197
+ }
198
+ }
199
+ }
200
+ ensureStateDirIgnored();
186
201
  const cache = loadCache(CACHE_PATH);
187
202
  const results = [];
188
203
  resetAiSession();
@@ -559,6 +574,10 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
559
574
  const lintTargets = [
560
575
  ...testFiles.map((t) => (typeof t === 'string' ? t : t.path)),
561
576
  ...(seedPath ? [seedPath] : []),
577
+ // The shared harness and the runner config are generated too, and CI
578
+ // lints them like any other file.
579
+ // Only files testforge generated: a project's own config is never edited.
580
+ ...[harnessPath, join(dirname(testDir), 'playwright.config.js')].filter((f) => !!f && existsSync(f) && readFileSync(f, 'utf-8').includes(GEN_MARKER)),
562
581
  ];
563
582
  const lint = lintGenerated(lintTargets);
564
583
  if (lint.fixed > 0) {
@@ -575,9 +594,23 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
575
594
  // is reported and left UNCACHED, so it is retried rather than trusted.
576
595
  let verified;
577
596
  const targets = testFiles.filter((t) => t.status === 'ai').map((t) => t.path);
578
- if (useAI && verifyOn && targets.length) {
597
+ const missing = harnessPath ? missingPackages(harnessPath, packageRoot(testDir)) : [];
598
+ if (useAI && verifyOn && targets.length && missing.length) {
599
+ // The suite cannot even start; running it would only feed repair a
600
+ // failure no test edit can fix.
601
+ verified = false;
602
+ warnings.push(`verify: not run — install the packages the harness needs: npm i -D ${missing.join(' ')}`);
603
+ }
604
+ else if (useAI && verifyOn && targets.length) {
579
605
  for (let attempt = 0; attempt <= verifyAttempts; attempt += 1) {
580
606
  const outcome = verifyFiles(config, journey.area, targets);
607
+ if (outcome.infra) {
608
+ // Not the tests' fault, so no repair; and not verified, so the
609
+ // journey stays uncached and the next run checks it again.
610
+ verified = false;
611
+ warnings.push(`verify: not run — ${outcome.reason}`);
612
+ break;
613
+ }
581
614
  if (!outcome.ran) {
582
615
  // Say WHY once. The reason is a property of the project (no env
583
616
  // file, no runner), not of this journey, so repeating it per
@@ -689,25 +722,39 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
689
722
  * Config files are written only if absent, so a project's hand-tuned config is
690
723
  * never clobbered. `harnessDir` is the area's helpers dir (where setup lives).
691
724
  */
692
- function writeHarnessConfigs(framework, areaDir, harnessDir, area) {
693
- const pkgRoot = dirname(areaDir);
694
- const rel = areaDir.slice(pkgRoot.length + 1) || '.';
725
+ export function writeHarnessConfigs(framework, areaDir, harnessDir, area) {
726
+ const posix = (from, to) => relative(from, resolve(to)).split(sep).join('/') || '.';
695
727
  if (framework === 'playwright') {
696
728
  const gsPath = join(harnessDir, 'global-setup.js');
697
729
  writeFileSync(gsPath, globalSetupContent(), 'utf-8');
698
- const cfg = join(pkgRoot, 'playwright.config.js');
730
+ const home = configHome(areaDir, 'playwright.config.js');
731
+ const cfg = join(home, 'playwright.config.js');
699
732
  if (!existsSync(cfg)) {
700
- const gsRel = gsPath.slice(pkgRoot.length + 1);
701
- writeFileSync(cfg, playwrightConfig(`./${rel}`, `./${gsRel}`, area), 'utf-8');
733
+ writeFileSync(cfg, playwrightConfig(`./${posix(home, areaDir)}`, `./${posix(home, gsPath)}`, area, posix(home, RESULTS_DIR)), 'utf-8');
702
734
  }
703
735
  }
704
736
  else if (framework === 'jest') {
705
737
  const setupPath = join(harnessDir, 'jest.setup.js');
706
738
  writeFileSync(setupPath, jestSetupContent(), 'utf-8');
707
- const cfg = join(pkgRoot, 'jest.config.js');
739
+ const home = configHome(areaDir, 'jest.config.js');
740
+ const cfg = join(home, 'jest.config.js');
708
741
  if (!existsSync(cfg)) {
709
- const setupRel = setupPath.slice(pkgRoot.length + 1);
710
- writeFileSync(cfg, jestConfig(rel, setupRel, area), 'utf-8');
742
+ writeFileSync(cfg, jestConfig(posix(home, areaDir), posix(home, setupPath), area), 'utf-8');
711
743
  }
712
744
  }
713
745
  }
746
+ /**
747
+ * Where a runner config goes: the package root, where `npx playwright test`
748
+ * and `npx jest` look for it by themselves — so CI, a teammate or a custom
749
+ * testCommand finds it with no --config flag. Tests can sit any depth below
750
+ * (tests/e2e, not just tests/). Only when the project already has its own
751
+ * config there do we fall back to the test folder's parent, never touching
752
+ * the project's file.
753
+ */
754
+ function configHome(areaDir, name) {
755
+ const root = packageRoot(areaDir);
756
+ const existing = join(root, name);
757
+ if (!existsSync(existing) || readFileSync(existing, 'utf-8').includes(GEN_MARKER))
758
+ return root;
759
+ return resolve(dirname(areaDir));
760
+ }
@@ -13,8 +13,9 @@
13
13
  * anything we could infer.
14
14
  */
15
15
  import { spawnSync } from 'node:child_process';
16
- import { existsSync } from 'node:fs';
17
- import { relative } from 'node:path';
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { createRequire } from 'node:module';
18
+ import { join, relative } from 'node:path';
18
19
  import { loadEnvFile, testDirForType, resolveFramework, } from '../project/config.js';
19
20
  import { runnerCommand, packageRoot } from '../run/run-tests.js';
20
21
  /** How much runner output to carry into a repair prompt. */
@@ -44,6 +45,39 @@ function countsFrom(output) {
44
45
  return null;
45
46
  return { passed: passed ?? 0, skipped: skipped ?? 0 };
46
47
  }
48
+ /**
49
+ * Failures no test edit can fix. Sending one to the repair prompt burns AI
50
+ * calls rewriting correct tests against an error they cannot cause (one
51
+ * night: 877 failures, every one a missing browser).
52
+ */
53
+ const INFRA_FAILURES = [
54
+ [
55
+ /Executable doesn't exist at|browserType\.launch:|Host system is missing dependencies|does not support (chromium|firefox|webkit) on/i,
56
+ 'the test browser cannot start — install the one this Playwright version needs (`npx playwright install chromium`), or pin a Playwright version your OS supports',
57
+ ],
58
+ [
59
+ /missing package|package is not installed|Cannot find module '(?![./])/i,
60
+ 'a package the tests or their harness need is not installed in the project — install it as a dev dependency (the error names it)',
61
+ ],
62
+ [
63
+ /net::ERR_CONNECTION_REFUSED|ECONNREFUSED/i,
64
+ 'the app (or a service it needs) is not reachable — start it and check BASE_URL / the DB settings in the env file',
65
+ ],
66
+ ];
67
+ export function infraFailure(output) {
68
+ return INFRA_FAILURES.find(([re]) => re.test(output))?.[1] ?? null;
69
+ }
70
+ /**
71
+ * Can the test browser start? Asked once before generation spends anything,
72
+ * so a missing browser stops the run instead of failing every verify.
73
+ */
74
+ export function browserPreflight(root) {
75
+ const res = spawnSync(process.execPath, ['-e', "require('@playwright/test').chromium.launch().then((b) => b.close())"], { cwd: root, encoding: 'utf-8', timeout: 120_000 });
76
+ if (res.status === 0)
77
+ return null;
78
+ const output = `${res.stdout ?? ''}\n${res.stderr ?? ''}`;
79
+ return infraFailure(output) ?? (output.trim().split('\n').find((l) => l.trim()) || 'the test browser could not start');
80
+ }
47
81
  /**
48
82
  * Run exactly the given generated files. Never throws: a verify step that
49
83
  * breaks generation would be worse than no verify step at all.
@@ -90,6 +124,9 @@ export function verifyFiles(config, area, files) {
90
124
  if (/command not found|Cannot find module '@playwright|is not recognized/i.test(output)) {
91
125
  return { ran: false, ok: false, output, reason: 'test runner is not installed' };
92
126
  }
127
+ const infra = infraFailure(output);
128
+ if (infra)
129
+ return { ran: false, infra: true, ok: false, output, reason: infra };
93
130
  if (res.status !== 0)
94
131
  return { ran: true, ok: false, output };
95
132
  // A suite where every test skipped ALSO exits 0. Treating that as verified
@@ -106,3 +143,30 @@ export function verifyFiles(config, area, files) {
106
143
  }
107
144
  return { ran: true, ok: true, output };
108
145
  }
146
+ /**
147
+ * Packages the generated harness declares it needs (REQUIRED_PACKAGES) that
148
+ * the project cannot resolve. Read from the source, never executed.
149
+ */
150
+ export function missingPackages(harnessFile, root) {
151
+ let src = '';
152
+ try {
153
+ src = readFileSync(harnessFile, 'utf-8');
154
+ }
155
+ catch {
156
+ return [];
157
+ }
158
+ const list = src.match(/REQUIRED_PACKAGES\s*=\s*\[([^\]]*)\]/);
159
+ if (!list)
160
+ return [];
161
+ const names = [...list[1].matchAll(/['"]([^'"]+)['"]/g)].map((m) => m[1]);
162
+ const req = createRequire(join(root, 'package.json'));
163
+ return names.filter((n) => {
164
+ try {
165
+ req.resolve(n);
166
+ return false;
167
+ }
168
+ catch {
169
+ return true;
170
+ }
171
+ });
172
+ }
@@ -1,3 +1,4 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
1
2
  import { join } from 'node:path';
2
3
  /**
3
4
  * Single source of truth for TestForge's per-project state directory and the
@@ -17,3 +18,17 @@ export const SOURCE_PATH = join(STATE_DIR, 'source.json');
17
18
  export const COVERAGE_PATH = join(STATE_DIR, 'coverage.json');
18
19
  /** Per-journey generation plans, organised per area: <PLANS_DIR>/<area>/<id>.json */
19
20
  export const PLANS_DIR = join(STATE_DIR, 'test-plans');
21
+ /** Where Playwright writes its per-run output (failure reports, traces). */
22
+ export const RESULTS_DIR = join(STATE_DIR, 'playwright-results');
23
+ /**
24
+ * Keep the state dir out of git without touching the project's own
25
+ * .gitignore: caches, plans and run output are local and regenerated, only
26
+ * the source-of-truth choice is worth sharing with the team.
27
+ */
28
+ export function ensureStateDirIgnored() {
29
+ const file = join(STATE_DIR, '.gitignore');
30
+ if (existsSync(file))
31
+ return;
32
+ mkdirSync(STATE_DIR, { recursive: true });
33
+ writeFileSync(file, '# testforge local state: regenerated as needed, so not committed.\n*\n!.gitignore\n!source.json\n', 'utf-8');
34
+ }
@@ -15,6 +15,7 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, } from 'node:fs';
15
15
  import { join } from 'node:path';
16
16
  import { findMarkdownFiles } from '../find-files.js';
17
17
  import { STATE_DIR, LEGACY_STATE_DIR } from '../paths.js';
18
+ import { loadConfig } from './config.js';
18
19
  /** Pull env var names from a chunk of text. */
19
20
  function extractEnvVars(text) {
20
21
  const vars = new Set();
@@ -25,6 +26,10 @@ function extractEnvVars(text) {
25
26
  for (const m of text.matchAll(/process\.env\[\s*['"]([A-Z0-9_]+)['"]\s*\]/gi)) {
26
27
  vars.add(m[1]);
27
28
  }
29
+ // A generated suite often reads env through a small helper: env('TEST_X', default).
30
+ for (const m of text.matchAll(/\benv\(\s*['"]([A-Z][A-Z0-9_]+)['"]/g)) {
31
+ vars.add(m[1]);
32
+ }
28
33
  // backtick-wrapped ALL_CAPS names in "env var" lines
29
34
  for (const line of text.split('\n')) {
30
35
  if (/env var|secret|credential/i.test(line)) {
@@ -189,6 +194,15 @@ export function runEnvTemplate(docsDir, outPath, srcDir = '.', testDirs = []) {
189
194
  return;
190
195
  }
191
196
  const files = findMarkdownFiles(docsDir);
197
+ // Every caller gets the generated suite scanned, not only the one that
198
+ // remembered to pass its dirs: the tests' own TEST_* variables are exactly
199
+ // what a developer cannot guess.
200
+ const config = loadConfig();
201
+ if (!testDirs.length) {
202
+ testDirs = [...Object.values(config.testOutput ?? {}), config.seedOutput ?? '']
203
+ .filter((d) => !!d && existsSync(d));
204
+ }
205
+ const envFile = config.envFile ?? '.env.testing';
192
206
  // area -> var -> set of journeys
193
207
  const byArea = new Map();
194
208
  for (const file of files) {
@@ -254,9 +268,9 @@ export function runEnvTemplate(docsDir, outPath, srcDir = '.', testDirs = []) {
254
268
  '# .env.testing.example',
255
269
  '#',
256
270
  '# Test credentials the generated tests/seeds need, grouped by area.',
257
- '# Copy this to .env.testing and fill in real values.',
271
+ `# Copy this to ${envFile} and fill in real values.`,
258
272
  '# Only variables for the features you actually run are required.',
259
- '# Keep .env.testing OUT of version control.',
273
+ `# Keep ${envFile} OUT of version control.`,
260
274
  '',
261
275
  `# ${'='.repeat(40)}`,
262
276
  '# TESTFORGE DASHBOARD (auto-publish each run)',
@@ -296,5 +310,5 @@ export function runEnvTemplate(docsDir, outPath, srcDir = '.', testDirs = []) {
296
310
  }
297
311
  writeFileSync(outPath, lines.join('\n'), 'utf-8');
298
312
  console.log(`Env template written to ${outPath}`);
299
- console.log('Copy it to .env.testing and fill in real values.');
313
+ console.log(`Copy it to ${envFile} and fill in real values.`);
300
314
  }
@@ -37,8 +37,13 @@ export function packageRoot(dir) {
37
37
  export function runnerCommand(config, area, framework, root, rel, journey, targets) {
38
38
  const explicit = config.testCommand?.[area];
39
39
  const paths = (targets ?? []).map((t) => relative(root, t) || t);
40
- if (explicit)
41
- return paths.length ? `${explicit} ${paths.join(' ')}` : explicit;
40
+ if (explicit) {
41
+ if (paths.length)
42
+ return `${explicit} ${paths.join(' ')}`;
43
+ // A custom command still honours --journey: narrow it to that journey's
44
+ // tests, or `run --journey x` silently runs the whole suite.
45
+ return journey ? `${explicit} ${framework === 'playwright' ? `${rel}/${journey}` : `--testPathPattern "${journey}"`}` : explicit;
46
+ }
42
47
  if (framework === 'playwright') {
43
48
  const target = paths.length
44
49
  ? paths.join(' ')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@activate-core/test-forge",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
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": {