@activate-core/test-forge 1.0.0 → 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.
@@ -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
  }
@@ -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';
@@ -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
+ }
@@ -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": "1.0.0",
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": {