@activate-core/test-forge 0.19.3 → 1.0.1

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.
@@ -23,6 +23,7 @@ import { DEEP_DOC_GUIDANCE } from './doc-guidance.js';
23
23
  import { promptConfirm } from '../prompt.js';
24
24
  import { loadSourceChoice, resolveDocsSource, saveJourneyReferences, saveSourceChoice, } from './source-of-truth.js';
25
25
  import { analyzeReference, draftDocsFromReference, DIFF_PATH, } from './reference.js';
26
+ import { reviewDocs } from './review.js';
26
27
  /** Scope line: one journey, or all of them. */
27
28
  function scopeLines(journey) {
28
29
  return journey
@@ -180,7 +181,7 @@ export async function runDiscover(srcDir, docsDir, opts = {}, journey) {
180
181
  if (!ok) {
181
182
  console.log('Basing docs on testforge.md instead.\n');
182
183
  saveSourceChoice({ mode: 'map' });
183
- return runCodeDiscover(srcDir, docsDir, journey);
184
+ return discoverFromCode(srcDir, docsDir, journey);
184
185
  }
185
186
  }
186
187
  // Is this the product's specification, or one feature's? A ticket's PDF
@@ -246,6 +247,10 @@ export async function runDiscover(srcDir, docsDir, opts = {}, journey) {
246
247
  for (const path of restored) {
247
248
  writeFileSync(join(docsDir, path), before.get(path), 'utf-8');
248
249
  }
250
+ // Second, focused pass per written doc: the drafting pass covers every
251
+ // journey at once and drops the small scenarios.
252
+ const written = [...added, ...rewritten.filter((p) => !restored.includes(p))];
253
+ await reviewDocs(docsDir, written, srcDir, source.reference);
249
254
  if (wholeProject) {
250
255
  saveSourceChoice({ mode: 'reference', reference: source.reference });
251
256
  if (added.length)
@@ -270,5 +275,15 @@ export async function runDiscover(srcDir, docsDir, opts = {}, journey) {
270
275
  return out;
271
276
  }
272
277
  saveSourceChoice({ mode: 'map' });
273
- return runCodeDiscover(srcDir, docsDir, journey);
278
+ return discoverFromCode(srcDir, docsDir, journey);
279
+ }
280
+ /** Draft from the code, then review every doc the draft wrote or changed. */
281
+ async function discoverFromCode(srcDir, docsDir, journey) {
282
+ const before = snapshotDocs(docsDir);
283
+ const out = runCodeDiscover(srcDir, docsDir, journey);
284
+ const changed = [...snapshotDocs(docsDir)]
285
+ .filter(([path, text]) => before.get(path) !== text && !notAJourneyDoc(path, text))
286
+ .map(([path]) => path);
287
+ await reviewDocs(docsDir, changed, srcDir);
288
+ return out;
274
289
  }
@@ -81,6 +81,36 @@ export const DEEP_DOC_GUIDANCE = [
81
81
  ` failure is an inconsistency, not a spec defect. Never use [observed]`,
82
82
  ` to override or soften a [spec] criterion.`,
83
83
  ``,
84
+ `METHOD. Work through these steps for every journey, in order:`,
85
+ `1. CONTROLS. Start the doc with a "## Controls" table listing EVERY`,
86
+ ` interactive element and where it appears: each input, button, link,`,
87
+ ` checkbox, code box, dropdown, pop-up, menu item, and the page itself.`,
88
+ ` Secondary controls count as much as the main form: Resend, Back,`,
89
+ ` show/hide, close, apply, sign-out, provider buttons. Apply every`,
90
+ ` [baseline] item that fits to EACH row, not just to the main form.`,
91
+ `2. COMBINATIONS. When the source states a rule across alternatives and`,
92
+ ` conditions ("for login and sign-up, also on a slow connection and on`,
93
+ ` a retry"), write one criterion per combination (login × slow, login ×`,
94
+ ` retry, sign-up × slow, sign-up × retry), never just one of them.`,
95
+ `3. CODE BRANCHES. Read the code the journey runs — screens, components,`,
96
+ ` client API calls, and the server handlers they reach (related repos`,
97
+ ` included) — and walk EVERY validation rule, error response and`,
98
+ ` conditional branch: account states (unverified, banned, deleted,`,
99
+ ` other roles), limits and expiry, empty and failure states, refresh`,
100
+ ` and deep links, what is stored in the browser and when it is`,
101
+ ` cleared, input handling (paste, clearing after an error). Each branch`,
102
+ ` becomes an [observed] criterion, or a Question when only product can`,
103
+ ` say what it should do. A skipped branch is the commonest gap.`,
104
+ `4. QUOTE [spec]. End every [spec] criterion with the source sentence it`,
105
+ ` rests on, quoted: (source: "..."). A criterion you cannot quote is`,
106
+ ` NOT [spec] — make it [baseline] or [observed], or a Question.`,
107
+ `5. SHARED RULES. A rule shared by several journeys (password, redirects,`,
108
+ ` attack payloads, accepted formats) uses the same cases and values in`,
109
+ ` every doc.`,
110
+ `6. COVERAGE. After the numbered sections, add "## Coverage": one row per`,
111
+ ` control from step 1, naming the section that covers each [baseline]`,
112
+ ` item that fits it. A blank cell is a gap — fill it before finishing.`,
113
+ ``,
84
114
  `TEST DATA. Tests fill data themselves. Never ask for an exact value:`,
85
115
  `valid data comes from the stated or [observed] rules; invalid data`,
86
116
  `breaks one rule at a time; a choice control is filled with an option`,
@@ -100,6 +130,7 @@ export const DEEP_DOC_GUIDANCE = [
100
130
  `Organise the body with numbered "## N. <Section>" headings, ONE`,
101
131
  `concern per section, because each section later becomes its own`,
102
132
  `focused test file — so the richer and better-separated the sections,`,
103
- `the better the test suite. Put "## Test setup" and "## Questions for`,
104
- `product (not asserted)" last, without numbers.`,
133
+ `the better the test suite. "## Controls" comes first; "## Coverage",`,
134
+ `"## Test setup" and "## Questions for product (not asserted)" come`,
135
+ `last. None of these four is numbered.`,
105
136
  ].join('\n');
@@ -0,0 +1,81 @@
1
+ /**
2
+ * testforge — journey doc gap review.
3
+ *
4
+ * One drafting pass writes every journey at once, and it reliably runs out
5
+ * of attention before the small things: a Resend button with no failure
6
+ * cases, a code box nobody pasted into, one combination of the four the
7
+ * source asked for, a server branch never read. So each freshly written doc
8
+ * gets a second, focused pass of its own: re-run the method from
9
+ * DEEP_DOC_GUIDANCE against that one journey, fill what is missing, and fix
10
+ * tiers that claim more than their source says. The reviewer edits the file
11
+ * in place and never deletes a sound criterion.
12
+ */
13
+ import { join } from 'node:path';
14
+ import { loadConfig, splitCommand, withEditTools, withReferenceTools } from '../project/config.js';
15
+ import { runWithStdin } from '../generate/ai-generator.js';
16
+ import { pool } from '../generate/run-generate.js';
17
+ import { DEEP_DOC_GUIDANCE } from './doc-guidance.js';
18
+ /** Build the prompt that reviews and completes ONE journey doc. */
19
+ export function buildReviewPrompt(docPath, srcDir, reference) {
20
+ const source = reference
21
+ ? `The source of truth is the reference: ${reference}. Read it fully; [spec]` +
22
+ ` criteria come only from it, and the code under "${srcDir}" (and any` +
23
+ ` related repos) supplies technical detail and [observed] rules.`
24
+ : `The source of truth is the code under "${srcDir}" (and any related repos).`;
25
+ return [
26
+ `You are a senior QA engineer reviewing ONE journey doc that a first`,
27
+ `pass drafted: "${docPath}". Your job is to make it COMPLETE, so that`,
28
+ `no scenario a careful QA engineer would test is missing.`,
29
+ ``,
30
+ source,
31
+ ``,
32
+ `The doc must follow this guidance:`,
33
+ DEEP_DOC_GUIDANCE,
34
+ ``,
35
+ `Review it step by step against the METHOD above and fix what you find:`,
36
+ `- Controls: is every interactive element of this journey in the table,`,
37
+ ` secondary controls included? Add the missing ones.`,
38
+ `- Baseline: does every control have every [baseline] item that fits`,
39
+ ` it? Add the missing criteria to the right section.`,
40
+ `- Combinations: is every combination the source states written out?`,
41
+ `- Code branches: read the code this journey runs, client and server.`,
42
+ ` Is every validation, error response and conditional branch an`,
43
+ ` [observed] criterion or a Question? Add the missing ones.`,
44
+ `- Tiers: does every [spec] criterion quote a real source sentence?`,
45
+ ` Re-tag any that does not. Does any criterion contradict the code`,
46
+ ` with nothing to test (a rule the screen has no way to break)? Turn`,
47
+ ` it into a "Likely defect" note or a Question.`,
48
+ `- Shared rules: read the other docs in the same folder and make shared`,
49
+ ` cases (password, redirects, payloads, formats) identical.`,
50
+ `- Coverage: rebuild the "## Coverage" table; no blank cell may remain.`,
51
+ ``,
52
+ `Rules: edit "${docPath}" in place and touch no other file. Keep every`,
53
+ `sound criterion. Keep the header, the section numbering style and the`,
54
+ `last sections (Coverage, Test setup, Questions for product). Never`,
55
+ `write "# TODO". When done, print ONE line: what you added or fixed,`,
56
+ `with counts.`,
57
+ ].join('\n');
58
+ }
59
+ /**
60
+ * Review each doc (paths relative to docsDir) in its own AI pass, a few at
61
+ * a time. A failed review leaves that doc as drafted and is reported, never
62
+ * fatal: the draft is still a valid doc.
63
+ */
64
+ export async function reviewDocs(docsDir, docs, srcDir, reference) {
65
+ if (!docs.length)
66
+ return;
67
+ const config = loadConfig();
68
+ const base = splitCommand(config.aiCommand);
69
+ const { program, args } = reference ? withReferenceTools(base) : withEditTools(base);
70
+ console.log(`\nReviewing ${docs.length} journey doc(s) for missing scenarios...`);
71
+ await pool(docs, Math.max(1, config.aiConcurrency ?? 4), async (doc) => {
72
+ const path = join(docsDir, doc);
73
+ try {
74
+ const out = await runWithStdin(program, args, buildReviewPrompt(path, srcDir, reference), config.aiTimeoutMs);
75
+ console.log(` ${doc}: ${out.trim().split('\n').pop() ?? 'reviewed'}`);
76
+ }
77
+ catch (err) {
78
+ console.log(` ${doc}: review failed, kept as drafted (${err.message.split('\n')[0]})`);
79
+ }
80
+ });
81
+ }
@@ -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
  }
@@ -18,7 +18,7 @@ const MAP_PATH = 'testforge.md';
18
18
  * Spawn a command, feed `input` on stdin, resolve with its stdout.
19
19
  * Async (unlike execFileSync) so many calls can run concurrently.
20
20
  */
21
- function runWithStdin(program, args, input, timeoutMs = 300_000) {
21
+ export function runWithStdin(program, args, input, timeoutMs = 300_000) {
22
22
  return new Promise((resolve, reject) => {
23
23
  const child = spawn(program, args, {
24
24
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -303,7 +303,7 @@ export function generateHarnessAsync(harnessPath, framework) {
303
303
  * dir, treats *.test.js as specs, and runs the testkit's setup() once via
304
304
  * globalSetup (mints/populates tokens before any worker starts).
305
305
  */
306
- export function playwrightConfig(testDirRelToPackage, globalSetupRelToPackage, category = 'web') {
306
+ export function playwrightConfig(testDirRelToPackage, globalSetupRelToPackage, category = 'web', outputDirRelToPackage = 'test-results') {
307
307
  return [
308
308
  `// @generated by testforge`,
309
309
  `// Minimal Playwright config so testforge's generated specs run.`,
@@ -315,6 +315,9 @@ export function playwrightConfig(testDirRelToPackage, globalSetupRelToPackage, c
315
315
  ` testMatch: /.*\\.(test|spec)\\.[jt]s$/,`,
316
316
  ` globalSetup: require.resolve('${globalSetupRelToPackage}'),`,
317
317
  ` timeout: 60000,`,
318
+ ` // Run output (failure reports, traces) stays with testforge's local state,`,
319
+ ` // which is not committed.`,
320
+ ` outputDir: '${outputDirRelToPackage}',`,
318
321
  ` // Serial, like the Jest config: these suites share ONE test database`,
319
322
  ` // and seed/reset it, so parallel workers race on the same rows and the`,
320
323
  ` // 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 } 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';
@@ -45,7 +46,7 @@ const GEN_CONCURRENCY = 4;
45
46
  * the results. Keeps a heavy fan-out (15 AI calls) from spawning 15
46
47
  * processes at once while still finishing in a few waves, not serially.
47
48
  */
48
- async function pool(items, limit, fn) {
49
+ export async function pool(items, limit, fn) {
49
50
  const results = new Array(items.length);
50
51
  let next = 0;
51
52
  async function worker() {
@@ -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) {
@@ -578,6 +597,13 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
578
597
  if (useAI && verifyOn && targets.length) {
579
598
  for (let attempt = 0; attempt <= verifyAttempts; attempt += 1) {
580
599
  const outcome = verifyFiles(config, journey.area, targets);
600
+ if (outcome.infra) {
601
+ // Not the tests' fault, so no repair; and not verified, so the
602
+ // journey stays uncached and the next run checks it again.
603
+ verified = false;
604
+ warnings.push(`verify: not run — ${outcome.reason}`);
605
+ break;
606
+ }
581
607
  if (!outcome.ran) {
582
608
  // Say WHY once. The reason is a property of the project (no env
583
609
  // file, no runner), not of this journey, so repeating it per
@@ -689,25 +715,39 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
689
715
  * Config files are written only if absent, so a project's hand-tuned config is
690
716
  * never clobbered. `harnessDir` is the area's helpers dir (where setup lives).
691
717
  */
692
- function writeHarnessConfigs(framework, areaDir, harnessDir, area) {
693
- const pkgRoot = dirname(areaDir);
694
- const rel = areaDir.slice(pkgRoot.length + 1) || '.';
718
+ export function writeHarnessConfigs(framework, areaDir, harnessDir, area) {
719
+ const posix = (from, to) => relative(from, resolve(to)).split(sep).join('/') || '.';
695
720
  if (framework === 'playwright') {
696
721
  const gsPath = join(harnessDir, 'global-setup.js');
697
722
  writeFileSync(gsPath, globalSetupContent(), 'utf-8');
698
- const cfg = join(pkgRoot, 'playwright.config.js');
723
+ const home = configHome(areaDir, 'playwright.config.js');
724
+ const cfg = join(home, 'playwright.config.js');
699
725
  if (!existsSync(cfg)) {
700
- const gsRel = gsPath.slice(pkgRoot.length + 1);
701
- writeFileSync(cfg, playwrightConfig(`./${rel}`, `./${gsRel}`, area), 'utf-8');
726
+ writeFileSync(cfg, playwrightConfig(`./${posix(home, areaDir)}`, `./${posix(home, gsPath)}`, area, posix(home, RESULTS_DIR)), 'utf-8');
702
727
  }
703
728
  }
704
729
  else if (framework === 'jest') {
705
730
  const setupPath = join(harnessDir, 'jest.setup.js');
706
731
  writeFileSync(setupPath, jestSetupContent(), 'utf-8');
707
- const cfg = join(pkgRoot, 'jest.config.js');
732
+ const home = configHome(areaDir, 'jest.config.js');
733
+ const cfg = join(home, 'jest.config.js');
708
734
  if (!existsSync(cfg)) {
709
- const setupRel = setupPath.slice(pkgRoot.length + 1);
710
- writeFileSync(cfg, jestConfig(rel, setupRel, area), 'utf-8');
735
+ writeFileSync(cfg, jestConfig(posix(home, areaDir), posix(home, setupPath), area), 'utf-8');
711
736
  }
712
737
  }
713
738
  }
739
+ /**
740
+ * Where a runner config goes: the package root, where `npx playwright test`
741
+ * and `npx jest` look for it by themselves — so CI, a teammate or a custom
742
+ * testCommand finds it with no --config flag. Tests can sit any depth below
743
+ * (tests/e2e, not just tests/). Only when the project already has its own
744
+ * config there do we fall back to the test folder's parent, never touching
745
+ * the project's file.
746
+ */
747
+ function configHome(areaDir, name) {
748
+ const root = packageRoot(areaDir);
749
+ const existing = join(root, name);
750
+ if (!existsSync(existing) || readFileSync(existing, 'utf-8').includes(GEN_MARKER))
751
+ return root;
752
+ return resolve(dirname(areaDir));
753
+ }
@@ -40,9 +40,9 @@ function buildPlanPrompt(journey, map) {
40
40
  ` refunds) may need many. Do not pad, do not over-split, do not force`,
41
41
  ` a fixed number.`,
42
42
  `- Each file covers ONE concern and must be independently meaningful.`,
43
- `- "## Test setup" is what every file needs to run, not a behaviour, and`,
44
- ` "## Questions for product (not asserted)" is not tested: plan no file`,
45
- ` for either.`,
43
+ `- "## Controls" and "## Coverage" are indexes, "## Test setup" is what`,
44
+ ` every file needs to run, and "## Questions for product (not`,
45
+ ` asserted)" is not tested: plan no file for any of them.`,
46
46
  ``,
47
47
  `Output ONLY a JSON array (no prose, no markdown fences) of objects:`,
48
48
  ` [{ "slug": "<short-kebab-name>", "title": "<describe title>",`,
@@ -44,6 +44,35 @@ function countsFrom(output) {
44
44
  return null;
45
45
  return { passed: passed ?? 0, skipped: skipped ?? 0 };
46
46
  }
47
+ /**
48
+ * Failures no test edit can fix. Sending one to the repair prompt burns AI
49
+ * calls rewriting correct tests against an error they cannot cause (one
50
+ * night: 877 failures, every one a missing browser).
51
+ */
52
+ const INFRA_FAILURES = [
53
+ [
54
+ /Executable doesn't exist at|browserType\.launch:|Host system is missing dependencies|does not support (chromium|firefox|webkit) on/i,
55
+ 'the test browser cannot start — install the one this Playwright version needs (`npx playwright install chromium`), or pin a Playwright version your OS supports',
56
+ ],
57
+ [
58
+ /net::ERR_CONNECTION_REFUSED|ECONNREFUSED/i,
59
+ 'the app (or a service it needs) is not reachable — start it and check BASE_URL / the DB settings in the env file',
60
+ ],
61
+ ];
62
+ export function infraFailure(output) {
63
+ return INFRA_FAILURES.find(([re]) => re.test(output))?.[1] ?? null;
64
+ }
65
+ /**
66
+ * Can the test browser start? Asked once before generation spends anything,
67
+ * so a missing browser stops the run instead of failing every verify.
68
+ */
69
+ export function browserPreflight(root) {
70
+ const res = spawnSync(process.execPath, ['-e', "require('@playwright/test').chromium.launch().then((b) => b.close())"], { cwd: root, encoding: 'utf-8', timeout: 120_000 });
71
+ if (res.status === 0)
72
+ return null;
73
+ const output = `${res.stdout ?? ''}\n${res.stderr ?? ''}`;
74
+ return infraFailure(output) ?? (output.trim().split('\n').find((l) => l.trim()) || 'the test browser could not start');
75
+ }
47
76
  /**
48
77
  * Run exactly the given generated files. Never throws: a verify step that
49
78
  * breaks generation would be worse than no verify step at all.
@@ -90,6 +119,9 @@ export function verifyFiles(config, area, files) {
90
119
  if (/command not found|Cannot find module '@playwright|is not recognized/i.test(output)) {
91
120
  return { ran: false, ok: false, output, reason: 'test runner is not installed' };
92
121
  }
122
+ const infra = infraFailure(output);
123
+ if (infra)
124
+ return { ran: false, infra: true, ok: false, output, reason: infra };
93
125
  if (res.status !== 0)
94
126
  return { ran: true, ok: false, output };
95
127
  // A suite where every test skipped ALSO exits 0. Treating that as verified
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@activate-core/test-forge",
3
- "version": "0.19.3",
3
+ "version": "1.0.1",
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": {