@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
|
/**
|
package/dist/core/gates/lint.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
723
|
+
const home = configHome(areaDir, 'playwright.config.js');
|
|
724
|
+
const cfg = join(home, 'playwright.config.js');
|
|
699
725
|
if (!existsSync(cfg)) {
|
|
700
|
-
|
|
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
|
|
732
|
+
const home = configHome(areaDir, 'jest.config.js');
|
|
733
|
+
const cfg = join(home, 'jest.config.js');
|
|
708
734
|
if (!existsSync(cfg)) {
|
|
709
|
-
|
|
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
|
package/dist/core/paths.js
CHANGED
|
@@ -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.
|
|
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": {
|