@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 +9 -2
- package/dist/core/docs/review.js +3 -0
- package/dist/core/docs/source-of-truth.js +2 -1
- package/dist/core/gates/lint.js +38 -4
- package/dist/core/generate/ai-generator.js +40 -0
- package/dist/core/generate/harness.js +52 -1
- package/dist/core/generate/run-generate.js +60 -13
- package/dist/core/generate/verify.js +66 -2
- package/dist/core/paths.js +15 -0
- package/dist/core/project/env-template.js +17 -3
- package/dist/core/run/run-tests.js +7 -2
- package/package.json +1 -1
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
|
package/dist/core/docs/review.js
CHANGED
|
@@ -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
|
/**
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
730
|
+
const home = configHome(areaDir, 'playwright.config.js');
|
|
731
|
+
const cfg = join(home, 'playwright.config.js');
|
|
699
732
|
if (!existsSync(cfg)) {
|
|
700
|
-
|
|
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
|
|
739
|
+
const home = configHome(areaDir, 'jest.config.js');
|
|
740
|
+
const cfg = join(home, 'jest.config.js');
|
|
708
741
|
if (!existsSync(cfg)) {
|
|
709
|
-
|
|
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 {
|
|
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
|
+
}
|
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
|
+
}
|
|
@@ -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
|
-
|
|
271
|
+
`# Copy this to ${envFile} and fill in real values.`,
|
|
258
272
|
'# Only variables for the features you actually run are required.',
|
|
259
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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": {
|