@activate-core/test-forge 0.10.2 → 0.12.0
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/core/ai-generator.js +129 -28
- package/dist/core/config.js +2 -0
- package/dist/core/detect.js +210 -0
- package/dist/core/harness.js +159 -11
- package/dist/core/playwright-writer.js +53 -38
- package/dist/core/run-generate.js +225 -20
- package/dist/core/seed-generator.js +16 -5
- package/dist/core/validate.js +219 -0
- package/package.json +1 -1
|
@@ -16,16 +16,17 @@
|
|
|
16
16
|
* so tests/seeds land where the project expects them. Defaults to
|
|
17
17
|
* generated-tests/ when not configured.
|
|
18
18
|
*/
|
|
19
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, } from 'node:fs';
|
|
19
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, } from 'node:fs';
|
|
20
20
|
import { join, dirname } from 'node:path';
|
|
21
21
|
import { parseJourney } from './parser.js';
|
|
22
22
|
import { generateTestCases } from './generator.js';
|
|
23
|
-
import {
|
|
23
|
+
import { writeDeferredStub } from './playwright-writer.js';
|
|
24
24
|
import { generateWithAI, generateWithAIAsync } from './ai-generator.js';
|
|
25
25
|
import { generateSeed } from './seed-generator.js';
|
|
26
26
|
import { planSuite } from './suite-planner.js';
|
|
27
|
-
import { isSyntaxValid } from './validate.js';
|
|
28
|
-
import {
|
|
27
|
+
import { isSyntaxValid, checkTestGates, analyzeFile } from './validate.js';
|
|
28
|
+
import { installedModules, transportHint, projectSelectors } from './detect.js';
|
|
29
|
+
import { harnessInfo, generateHarnessAsync, generateHarness, playwrightConfig, globalSetupContent, fixturesDoc, } from './harness.js';
|
|
29
30
|
import { hashContent, loadCache, saveCache } from './cache.js';
|
|
30
31
|
import { findMarkdownFiles } from './find-files.js';
|
|
31
32
|
import { loadConfig, testDirForType, resolveFramework } from './config.js';
|
|
@@ -55,7 +56,7 @@ async function pool(items, limit, fn) {
|
|
|
55
56
|
return results;
|
|
56
57
|
}
|
|
57
58
|
/** Async version of generateValidated (for concurrent per-file gen). */
|
|
58
|
-
async function generateValidatedAsync(generate, ext) {
|
|
59
|
+
async function generateValidatedAsync(generate, ext, gate) {
|
|
59
60
|
let content = '';
|
|
60
61
|
for (let attempt = 0; attempt < MAX_GEN_ATTEMPTS; attempt++) {
|
|
61
62
|
try {
|
|
@@ -64,7 +65,7 @@ async function generateValidatedAsync(generate, ext) {
|
|
|
64
65
|
catch {
|
|
65
66
|
continue;
|
|
66
67
|
}
|
|
67
|
-
if (isSyntaxValid(content, ext))
|
|
68
|
+
if (isSyntaxValid(content, ext) && !(gate && gate(content)))
|
|
68
69
|
return content;
|
|
69
70
|
}
|
|
70
71
|
return null;
|
|
@@ -74,7 +75,7 @@ async function generateValidatedAsync(generate, ext) {
|
|
|
74
75
|
* couple of times on malformed results. Returns the valid content, or
|
|
75
76
|
* null if every attempt failed to produce parseable code.
|
|
76
77
|
*/
|
|
77
|
-
function generateValidated(generate, ext) {
|
|
78
|
+
function generateValidated(generate, ext, gate) {
|
|
78
79
|
let content = '';
|
|
79
80
|
for (let attempt = 0; attempt < MAX_GEN_ATTEMPTS; attempt++) {
|
|
80
81
|
try {
|
|
@@ -84,7 +85,7 @@ function generateValidated(generate, ext) {
|
|
|
84
85
|
// generation (or output cleaning) threw — retry
|
|
85
86
|
continue;
|
|
86
87
|
}
|
|
87
|
-
if (isSyntaxValid(content, ext)) {
|
|
88
|
+
if (isSyntaxValid(content, ext) && !(gate && gate(content))) {
|
|
88
89
|
return content;
|
|
89
90
|
}
|
|
90
91
|
}
|
|
@@ -95,16 +96,18 @@ function generateValidated(generate, ext) {
|
|
|
95
96
|
* syntax gate + retry) and falls back to the deterministic basic
|
|
96
97
|
* generator if AI output can't be made valid.
|
|
97
98
|
*/
|
|
98
|
-
function buildTest(journey, useAI, ext, testPath, focus) {
|
|
99
|
+
function buildTest(journey, useAI, ext, testPath, framework, focus) {
|
|
99
100
|
if (useAI) {
|
|
100
101
|
const ai = generateValidated(() => generateWithAI(journey, testPath, focus), ext);
|
|
101
102
|
if (ai !== null) {
|
|
102
103
|
return { content: ai, status: 'ai' };
|
|
103
104
|
}
|
|
104
|
-
// fall through to the
|
|
105
|
+
// fall through to the deferred stub
|
|
105
106
|
}
|
|
107
|
+
// NOT a runnable test: pending placeholders only, correct for the
|
|
108
|
+
// runner, so nothing ever passes silently.
|
|
106
109
|
const cases = generateTestCases(journey);
|
|
107
|
-
return { content:
|
|
110
|
+
return { content: writeDeferredStub(journey, cases, framework), status: 'basic' };
|
|
108
111
|
}
|
|
109
112
|
/**
|
|
110
113
|
* Turn a journey id into the alternate naming conventions we might
|
|
@@ -172,6 +175,7 @@ export async function runHarness(only) {
|
|
|
172
175
|
}
|
|
173
176
|
mkdirSync(dirname(info.path), { recursive: true });
|
|
174
177
|
writeFileSync(info.path, kit, 'utf-8');
|
|
178
|
+
writeFileSync(join(info.dir, 'FIXTURES.md'), fixturesDoc(), 'utf-8');
|
|
175
179
|
if (framework === 'playwright') {
|
|
176
180
|
const gsPath = join(info.dir, 'global-setup.js');
|
|
177
181
|
writeFileSync(gsPath, globalSetupContent(), 'utf-8');
|
|
@@ -188,6 +192,97 @@ export async function runHarness(only) {
|
|
|
188
192
|
return written;
|
|
189
193
|
}
|
|
190
194
|
const PLAN_DIR = '.testforge/plans';
|
|
195
|
+
/** Marker every testforge-written test carries, so stale-file cleanup
|
|
196
|
+
* (F6) only ever removes OUR files, never hand-authored ones. */
|
|
197
|
+
const GEN_MARKER = '@generated by testforge';
|
|
198
|
+
/**
|
|
199
|
+
* F9 — a journey doc that is still an unconfirmed stub. Deep-generating
|
|
200
|
+
* confident assertions from it produces UNVALIDATED tests, so we skip it
|
|
201
|
+
* and tell the user to confirm the spec first.
|
|
202
|
+
*/
|
|
203
|
+
function isStubDoc(markdown) {
|
|
204
|
+
// The doc's FIRST heading is its premise. A confirmed journey opens with a
|
|
205
|
+
// real title (e.g. "# Journey: ..."); a genuine stub opens by declaring
|
|
206
|
+
// itself uncovered ("# TODO: not covered by the reference ...").
|
|
207
|
+
//
|
|
208
|
+
// We deliberately look ONLY at the opening heading. Partial "reference
|
|
209
|
+
// SILENT" annotations on individual SECTIONS of an otherwise-complete doc,
|
|
210
|
+
// and trailing "developer verification checklist" sections, are healthy —
|
|
211
|
+
// the previous whole-document scan wrongly skipped confirmed journeys that
|
|
212
|
+
// merely carried such notes.
|
|
213
|
+
const firstHeading = (markdown.match(/^#{1,6}\s+.+$/m) || [''])[0];
|
|
214
|
+
return /\btodo\b|not covered by the reference|reference[-\s]silent|unconfirmed/i.test(firstHeading);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* True if a generated file is a testforge DEFERRED stub — a placeholder
|
|
218
|
+
* written when AI generation was unavailable. It is NOT real coverage, so
|
|
219
|
+
* per-file resume must RE-ATTEMPT it (unlike a real file, which is kept).
|
|
220
|
+
*/
|
|
221
|
+
function isDeferredStub(content) {
|
|
222
|
+
return content.includes('DEFERRED stub');
|
|
223
|
+
}
|
|
224
|
+
/** True if the journey's output dir already holds any deferred stub. */
|
|
225
|
+
function hasDeferredStubs(testDir, ext) {
|
|
226
|
+
let files;
|
|
227
|
+
try {
|
|
228
|
+
files = readdirSync(testDir);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
for (const f of files) {
|
|
234
|
+
if (!f.endsWith(ext))
|
|
235
|
+
continue;
|
|
236
|
+
try {
|
|
237
|
+
if (isDeferredStub(readFileSync(join(testDir, f), 'utf-8')))
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
/* ignore unreadable */
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
/** Slugs recorded in a journey's existing plan cache (the previous run). */
|
|
247
|
+
function readPlanSlugs(journey) {
|
|
248
|
+
const p = join(PLAN_DIR, `${journey.area}__${journey.id}.json`);
|
|
249
|
+
try {
|
|
250
|
+
const c = JSON.parse(readFileSync(p, 'utf-8'));
|
|
251
|
+
if (Array.isArray(c.files)) {
|
|
252
|
+
return c.files.map((f) => f.slug).filter((s) => !!s);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
/* no previous plan */
|
|
257
|
+
}
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* F6 — replace, don't duplicate. When a journey is (re)generated and its
|
|
262
|
+
* plan changed, the files for slugs that no longer exist are stale. Delete
|
|
263
|
+
* them — but ONLY files that carry our marker, so we never touch a
|
|
264
|
+
* developer's own tests. Prevents the twin-suite accumulation.
|
|
265
|
+
*/
|
|
266
|
+
function cleanStaleFiles(testDir, journeyId, ext, flat, keepSlugs, dropSlugs) {
|
|
267
|
+
const removed = [];
|
|
268
|
+
const keep = new Set(keepSlugs);
|
|
269
|
+
for (const slug of dropSlugs) {
|
|
270
|
+
if (keep.has(slug))
|
|
271
|
+
continue;
|
|
272
|
+
const base = flat ? `${journeyId}-${slug}` : slug;
|
|
273
|
+
const f = join(testDir, `${base}${ext}`);
|
|
274
|
+
try {
|
|
275
|
+
if (existsSync(f) && readFileSync(f, 'utf-8').includes(GEN_MARKER)) {
|
|
276
|
+
rmSync(f);
|
|
277
|
+
removed.push(`${base}${ext}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
/* best-effort */
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return removed;
|
|
285
|
+
}
|
|
191
286
|
/**
|
|
192
287
|
* Get a journey's file plan, cached by doc hash. Caching the plan keeps
|
|
193
288
|
* the file slugs STABLE across runs, so per-file resume (skip-existing)
|
|
@@ -238,6 +333,10 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
238
333
|
const seedExt = ext.endsWith('.js') ? '.seed.js' : '.seed.ts';
|
|
239
334
|
const cache = loadCache(CACHE_PATH);
|
|
240
335
|
const results = [];
|
|
336
|
+
// Real UI test-hooks per area, extracted once from the components — feeds
|
|
337
|
+
// the F10 selector-grounding gate so generated UI tests can only assert on
|
|
338
|
+
// selectors that genuinely exist (never an invented data-testid).
|
|
339
|
+
const selectorsByArea = new Map();
|
|
241
340
|
// Shared harness per area — generated once, then every test in that
|
|
242
341
|
// area imports it (so files stop inventing their own helper paths).
|
|
243
342
|
const harnessByArea = new Map();
|
|
@@ -261,6 +360,7 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
261
360
|
}
|
|
262
361
|
mkdirSync(dirname(info.path), { recursive: true });
|
|
263
362
|
writeFileSync(info.path, kit, 'utf-8');
|
|
363
|
+
writeFileSync(join(info.dir, 'FIXTURES.md'), fixturesDoc(), 'utf-8');
|
|
264
364
|
// Playwright areas need a config + globalSetup so the specs can run
|
|
265
365
|
// AND get their tokens minted once before the suite.
|
|
266
366
|
if (framework === 'playwright') {
|
|
@@ -304,8 +404,25 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
304
404
|
const flat = framework === 'jest';
|
|
305
405
|
const testDir = flat ? areaDir : join(areaDir, journey.id);
|
|
306
406
|
const seedPath = join(seedDir, `${journey.id}${seedExt}`);
|
|
307
|
-
//
|
|
308
|
-
|
|
407
|
+
// Dependency-aware guardrails (F1): what this area may import, and how
|
|
408
|
+
// it should reach the server — both read from the real project, so the
|
|
409
|
+
// AI can't fall back to an uninstalled convention (e.g. supertest).
|
|
410
|
+
const allowedModules = installedModules(testDir);
|
|
411
|
+
const transport = transportHint(testDir, framework);
|
|
412
|
+
// F10 — selector grounding (Playwright areas only): the real test-hooks
|
|
413
|
+
// in this area's components, so a generated UI test can't invent one.
|
|
414
|
+
let knownSelectors;
|
|
415
|
+
if (framework === 'playwright') {
|
|
416
|
+
knownSelectors = selectorsByArea.get(areaDir);
|
|
417
|
+
if (!knownSelectors) {
|
|
418
|
+
knownSelectors = projectSelectors(areaDir);
|
|
419
|
+
selectorsByArea.set(areaDir, knownSelectors);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const testGate = (content) => checkTestGates(content, ext, { framework, allowedModules, knownSelectors });
|
|
423
|
+
// Skip if unchanged — but NOT if the journey still has deferred stubs
|
|
424
|
+
// to retry (a resume pass fills those in without touching real files).
|
|
425
|
+
if (!force && cache[file] === hash && !hasDeferredStubs(testDir, ext)) {
|
|
309
426
|
results.push({
|
|
310
427
|
journeyName: journey.name,
|
|
311
428
|
testDir,
|
|
@@ -320,6 +437,22 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
320
437
|
const collision = collisionWarning(areaDir, journey.id, ext);
|
|
321
438
|
if (collision)
|
|
322
439
|
warnings.push(collision);
|
|
440
|
+
// F9 — don't deep-generate from an unconfirmed TODO/stub doc: the
|
|
441
|
+
// assertions would be guesses. Skip it and ask for a confirmed spec.
|
|
442
|
+
if (isStubDoc(markdown)) {
|
|
443
|
+
results.push({
|
|
444
|
+
journeyName: journey.name,
|
|
445
|
+
testDir,
|
|
446
|
+
testFiles: [],
|
|
447
|
+
seedPath,
|
|
448
|
+
status: 'skipped',
|
|
449
|
+
seedStatus: 'none',
|
|
450
|
+
warnings: [
|
|
451
|
+
'doc is an unconfirmed TODO/stub — confirm the expected behaviour before generating (skipped to avoid unvalidated tests)',
|
|
452
|
+
],
|
|
453
|
+
});
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
323
456
|
// Ensure this area's shared harness exists (once), so every file
|
|
324
457
|
// imports the same testkit instead of inventing its own.
|
|
325
458
|
const harnessPath = useAI
|
|
@@ -330,13 +463,24 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
330
463
|
}
|
|
331
464
|
// 1. Plan the suite (deep mode), then generate one focused file per
|
|
332
465
|
// concern. In basic mode there is no AI planner — one file only.
|
|
466
|
+
// Capture the PREVIOUS plan's slugs first so we can clean up any
|
|
467
|
+
// files whose slug no longer exists (F6 replace-don't-duplicate).
|
|
468
|
+
const prevSlugs = useAI ? readPlanSlugs(journey) : [];
|
|
333
469
|
const planned = useAI
|
|
334
470
|
? loadOrPlan(journey, hash)
|
|
335
471
|
: [{ slug: journey.id, title: journey.name, covers: '' }];
|
|
472
|
+
const plannedSlugs = planned.map((p) => p.slug);
|
|
473
|
+
const staleRemoved = cleanStaleFiles(testDir, journey.id, ext, flat, plannedSlugs, prevSlugs);
|
|
474
|
+
if (staleRemoved.length) {
|
|
475
|
+
warnings.push(`removed ${staleRemoved.length} stale file(s) from a previous plan: ${staleRemoved.join(', ')}`);
|
|
476
|
+
}
|
|
336
477
|
// Generate all focused files CONCURRENTLY (bounded pool). Each AI
|
|
337
|
-
// call is independent, so a 15-file journey finishes in
|
|
338
|
-
// instead of
|
|
339
|
-
|
|
478
|
+
// call is independent, so a 15-file journey finishes in several waves
|
|
479
|
+
// instead of one-at-a-time. Concurrency is configurable: too many
|
|
480
|
+
// parallel agentic AI runs starve each other and time out, so a flaky
|
|
481
|
+
// environment can lower `aiConcurrency` (e.g. 2) for reliability.
|
|
482
|
+
const concurrency = Math.max(1, config.aiConcurrency ?? GEN_CONCURRENCY);
|
|
483
|
+
const generated = await pool(planned, concurrency, async (pf) => {
|
|
340
484
|
// Flat (jest): prefix the file with the journey so many journeys can
|
|
341
485
|
// share one dir without clashing. Folder (playwright): the journey
|
|
342
486
|
// folder already namespaces it, so the slug alone is enough.
|
|
@@ -348,7 +492,10 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
348
492
|
// (--force ignores this and regenerates.)
|
|
349
493
|
if (!force && existsSync(filePath)) {
|
|
350
494
|
try {
|
|
351
|
-
|
|
495
|
+
const existing = readFileSync(filePath, 'utf-8');
|
|
496
|
+
// Keep a real, parsing file — but a DEFERRED stub is not real
|
|
497
|
+
// coverage, so fall through and re-attempt it on this resume pass.
|
|
498
|
+
if (isSyntaxValid(existing, ext) && !isDeferredStub(existing)) {
|
|
352
499
|
return { slug: pf.slug, path: filePath, status: 'ai' };
|
|
353
500
|
}
|
|
354
501
|
}
|
|
@@ -359,22 +506,27 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
359
506
|
let content;
|
|
360
507
|
let status;
|
|
361
508
|
if (useAI) {
|
|
362
|
-
const ai = await generateValidatedAsync(() => generateWithAIAsync(journey, filePath, { title: pf.title, covers: pf.covers }, harnessPath), ext);
|
|
509
|
+
const ai = await generateValidatedAsync(() => generateWithAIAsync(journey, filePath, { title: pf.title, covers: pf.covers }, harnessPath, transport), ext, testGate);
|
|
363
510
|
if (ai !== null) {
|
|
364
511
|
content = ai;
|
|
365
512
|
status = 'ai';
|
|
366
513
|
}
|
|
367
514
|
else {
|
|
368
|
-
const built = buildTest(journey, false, ext, filePath);
|
|
515
|
+
const built = buildTest(journey, false, ext, filePath, framework);
|
|
369
516
|
content = built.content;
|
|
370
517
|
status = 'basic';
|
|
371
518
|
}
|
|
372
519
|
}
|
|
373
520
|
else {
|
|
374
|
-
const built = buildTest(journey, false, ext, filePath);
|
|
521
|
+
const built = buildTest(journey, false, ext, filePath, framework);
|
|
375
522
|
content = built.content;
|
|
376
523
|
status = 'basic';
|
|
377
524
|
}
|
|
525
|
+
// Stamp our marker (F6) so stale-file cleanup can recognise OUR
|
|
526
|
+
// files later without ever touching a developer's own tests.
|
|
527
|
+
if (!content.includes(GEN_MARKER)) {
|
|
528
|
+
content = `// ${GEN_MARKER}\n${content}`;
|
|
529
|
+
}
|
|
378
530
|
// Write IMMEDIATELY (not batched at the end of the journey), so an
|
|
379
531
|
// interruption preserves every file generated so far.
|
|
380
532
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
@@ -389,6 +541,15 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
389
541
|
const anyAI = testFiles.some((t) => t.status === 'ai');
|
|
390
542
|
const anyBasic = testFiles.some((t) => t.status === 'basic');
|
|
391
543
|
const status = anyAI || !anyBasic ? 'ai' : 'basic';
|
|
544
|
+
// F10 — complete-generation honesty: any file that fell back to a
|
|
545
|
+
// deferred stub is NOT real coverage. Surface it clearly (and the
|
|
546
|
+
// journey stays uncached below, so a later run retries it with AI).
|
|
547
|
+
const deferred = testFiles.filter((t) => t.status === 'basic');
|
|
548
|
+
if (useAI && deferred.length) {
|
|
549
|
+
warnings.push(`${deferred.length} file(s) could not be generated with AI and were left as DEFERRED stubs (pending, not real coverage): ${deferred
|
|
550
|
+
.map((t) => t.slug)
|
|
551
|
+
.join(', ')}`);
|
|
552
|
+
}
|
|
392
553
|
// 2. Seed file (only when using AI — basic mode has no seed concept).
|
|
393
554
|
let seedStatus = 'none';
|
|
394
555
|
if (useAI) {
|
|
@@ -433,5 +594,49 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
433
594
|
});
|
|
434
595
|
}
|
|
435
596
|
saveCache(CACHE_PATH, cache);
|
|
597
|
+
writeCoverageManifest(results);
|
|
436
598
|
return results;
|
|
437
599
|
}
|
|
600
|
+
/**
|
|
601
|
+
* F8 — write an honest coverage manifest. For every generated file it
|
|
602
|
+
* records real (has assertions) vs deferred (pending only), so a "green"
|
|
603
|
+
* run is never mistaken for full coverage. Written to
|
|
604
|
+
* .testforge/coverage.json and summarised to the console.
|
|
605
|
+
*/
|
|
606
|
+
function writeCoverageManifest(results) {
|
|
607
|
+
const journeys = results
|
|
608
|
+
.filter((r) => r.testFiles.length)
|
|
609
|
+
.map((r) => {
|
|
610
|
+
const files = r.testFiles.map((t) => {
|
|
611
|
+
let a = { activeTests: 0, pending: 0, assertions: 0, real: false };
|
|
612
|
+
try {
|
|
613
|
+
a = analyzeFile(readFileSync(t.path, 'utf-8'));
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
/* file unreadable — count as empty */
|
|
617
|
+
}
|
|
618
|
+
return { file: t.path, status: t.status, ...a };
|
|
619
|
+
});
|
|
620
|
+
return {
|
|
621
|
+
journey: r.journeyName,
|
|
622
|
+
real: files.filter((f) => f.real).length,
|
|
623
|
+
deferred: files.filter((f) => !f.real).length,
|
|
624
|
+
files,
|
|
625
|
+
};
|
|
626
|
+
});
|
|
627
|
+
if (!journeys.length)
|
|
628
|
+
return;
|
|
629
|
+
const totalReal = journeys.reduce((n, j) => n + j.real, 0);
|
|
630
|
+
const totalDeferred = journeys.reduce((n, j) => n + j.deferred, 0);
|
|
631
|
+
try {
|
|
632
|
+
mkdirSync('.testforge', { recursive: true });
|
|
633
|
+
writeFileSync(join('.testforge', 'coverage.json'), JSON.stringify({ real: totalReal, deferred: totalDeferred, journeys }, null, 2), 'utf-8');
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
/* best-effort */
|
|
637
|
+
}
|
|
638
|
+
console.log(`\nCoverage: ${totalReal} real test file(s), ${totalDeferred} deferred/placeholder — see .testforge/coverage.json`);
|
|
639
|
+
if (totalDeferred) {
|
|
640
|
+
console.log(` (deferred files are pending, NOT real coverage — regenerate or implement them.)`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
@@ -42,11 +42,22 @@ export function buildSeedPrompt(journeyDoc, seedPath) {
|
|
|
42
42
|
`the seed code to stdout.`,
|
|
43
43
|
``,
|
|
44
44
|
`Requirements for the seed you write:`,
|
|
45
|
-
`- Connect using the env var named in the map
|
|
46
|
-
`
|
|
47
|
-
`
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
`- Connect using the env var(s) named in the map. If the app spans`,
|
|
46
|
+
` MULTIPLE databases (e.g. main + chat/conversation + identity/IM),`,
|
|
47
|
+
` connect to and seed EACH into the DB it belongs to — do not assume a`,
|
|
48
|
+
` single process.env.DB_URL. NEVER hardcode a connection string or secret.`,
|
|
49
|
+
`- Make it IDEMPOTENT with an explicit field split: upsert by a stable`,
|
|
50
|
+
` id using { $set: <mutable fields>, $setOnInsert: { createdAt, ...other`,
|
|
51
|
+
` creation-only fields } } (updateOne/findOneAndUpdate, upsert:true). Do`,
|
|
52
|
+
` NOT use replaceOne, and do NOT put createdAt/creation metadata in $set`,
|
|
53
|
+
` — that clobbers it on every re-run. Seed shared/base entities (e.g. an`,
|
|
54
|
+
` identity user) INSERT-ONLY ($setOnInsert only) so a developer's richer`,
|
|
55
|
+
` local record is never overwritten.`,
|
|
56
|
+
`- Every well-known id MUST be env-overridable: read`,
|
|
57
|
+
` process.env.TEST_<NAME>_ID (falling back to a stable default), using`,
|
|
58
|
+
` the SAME names the testkit's TEST_IDS exposes. This lets the same`,
|
|
59
|
+
` fixtures target data created BY HAND on staging/prod without code`,
|
|
60
|
+
` edits (used with SEED_MODE=existing, where writes are skipped).`,
|
|
50
61
|
`- Prefix test data clearly (e.g. names/ids starting with "qa-" or`,
|
|
51
62
|
` "QA-TEST") so it is obvious and easy to clean up.`,
|
|
52
63
|
`- Insert only what THIS feature's tests need as a precondition,`,
|
package/dist/core/validate.js
CHANGED
|
@@ -20,6 +20,225 @@ import { execFileSync } from 'node:child_process';
|
|
|
20
20
|
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { tmpdir } from 'node:os';
|
|
23
|
+
/**
|
|
24
|
+
* Semantic gate for a generated test file. Returns a human-readable
|
|
25
|
+
* reason string if the content violates a guardrail, or null if it is
|
|
26
|
+
* clean. Runs AFTER syntax validation.
|
|
27
|
+
*/
|
|
28
|
+
export function checkTestGates(content, _ext, ctx) {
|
|
29
|
+
// F3 — runner correctness: a Jest/backend area must never pull in
|
|
30
|
+
// Playwright (not installed there; a top-level import breaks the suite).
|
|
31
|
+
if (ctx.framework === 'jest' && /@playwright\/test/.test(content)) {
|
|
32
|
+
return "jest area must not import '@playwright/test'";
|
|
33
|
+
}
|
|
34
|
+
// F4 — goto guard: `page.goto(...)` must take a real route/URL, not a
|
|
35
|
+
// pasted description and not an unfilled route-file placeholder. Literal
|
|
36
|
+
// args with whitespace are prose; `[param]`/`:param`/`{param}`/`<param>`
|
|
37
|
+
// are route templates that navigate to a garbage literal path at runtime.
|
|
38
|
+
// (Only quote-delimited literals are inspected — a backtick template like
|
|
39
|
+
// `/s/${code}` is the correct, resolved form and is intentionally exempt.)
|
|
40
|
+
const gotoRe = /\.goto\(\s*(['"])([\s\S]*?)\1/g;
|
|
41
|
+
let m;
|
|
42
|
+
while ((m = gotoRe.exec(content)) !== null) {
|
|
43
|
+
const arg = m[2];
|
|
44
|
+
if (/\s/.test(arg)) {
|
|
45
|
+
return `goto() argument looks like prose, not a URL/path: "${arg.slice(0, 40)}…"`;
|
|
46
|
+
}
|
|
47
|
+
if (/\[[A-Za-z_]|\/:[A-Za-z_]|\{[A-Za-z_]|<[A-Za-z_]/.test(arg)) {
|
|
48
|
+
return `goto() has an unresolved route placeholder — fill it with a real value: "${arg.slice(0, 40)}"`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// F2 — no silent false-green: a file with RUNNABLE tests must assert
|
|
52
|
+
// something. Pending placeholders (test.todo/skip/fixme) are exempt —
|
|
53
|
+
// they don't count as passing. This is what stops an empty-but-green
|
|
54
|
+
// file (the worst QA outcome) from ever being written.
|
|
55
|
+
const activeTests = (content.match(/\b(?:it|test)\s*\(/g) || []).length +
|
|
56
|
+
(content.match(/\b(?:it|test)\.(?:each|concurrent)\b/g) || []).length;
|
|
57
|
+
const assertions = (content.match(/\bexpect\s*\(/g) || []).length +
|
|
58
|
+
(content.match(/\bassert\b/g) || []).length;
|
|
59
|
+
if (activeTests > 0 && assertions === 0) {
|
|
60
|
+
return 'has runnable test(s) but no assertions — would pass without testing anything';
|
|
61
|
+
}
|
|
62
|
+
// F1 — dependency awareness: reject imports/requires of a module the
|
|
63
|
+
// project can't resolve (bare specifier not in allowedModules).
|
|
64
|
+
if (ctx.allowedModules) {
|
|
65
|
+
for (const mod of importedModules(content)) {
|
|
66
|
+
const top = topLevelModule(mod);
|
|
67
|
+
if (!ctx.allowedModules.has(top)) {
|
|
68
|
+
return `imports '${mod}' which the project does not have installed`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// F11 — hermetic seam (jest/backend): a test that imports a known
|
|
73
|
+
// EXTERNAL network service (payment/SMS/email/cloud) must mock it, or the
|
|
74
|
+
// suite silently depends on a live third party — it flakes offline and can
|
|
75
|
+
// fire real charges/messages. DB clients are intentionally NOT listed:
|
|
76
|
+
// backend integration tests legitimately use a real (local) database.
|
|
77
|
+
if (ctx.framework === 'jest') {
|
|
78
|
+
const EXTERNAL = new Set([
|
|
79
|
+
'stripe', 'twilio', '@sendgrid/mail', '@sendgrid/client',
|
|
80
|
+
'nodemailer', 'aws-sdk', '@aws-sdk/client-ses', '@aws-sdk/client-sns',
|
|
81
|
+
]);
|
|
82
|
+
for (const mod of importedModules(content)) {
|
|
83
|
+
const top = topLevelModule(mod);
|
|
84
|
+
if (!EXTERNAL.has(top))
|
|
85
|
+
continue;
|
|
86
|
+
const mocked = content.includes(`jest.mock('${mod}'`) ||
|
|
87
|
+
content.includes(`jest.mock("${mod}"`) ||
|
|
88
|
+
content.includes(`jest.mock('${top}'`) ||
|
|
89
|
+
content.includes(`jest.mock("${top}"`);
|
|
90
|
+
if (!mocked) {
|
|
91
|
+
return `imports external service '${top}' without a jest.mock — the test would hit the live service; mock it at the seam`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// F10 — selector grounding: a RUNNABLE (non-skipped) UI test must not lean
|
|
96
|
+
// on a guessed selector. Unchecked, the generator invents plausible
|
|
97
|
+
// data-testids that time out at runtime — the journey is never really
|
|
98
|
+
// exercised, and the red looks like a product failure instead of a test
|
|
99
|
+
// gap. A guessed selector must be grounded in a real component attribute,
|
|
100
|
+
// or the test must be explicitly deferred (test.fixme/skip). This is
|
|
101
|
+
// feature-agnostic — it guards every UI journey, not any one flow.
|
|
102
|
+
if (ctx.framework === 'playwright') {
|
|
103
|
+
const active = stripPendingBlocks(content);
|
|
104
|
+
// (a) The generator's own tell: a selector sitting next to a
|
|
105
|
+
// placeholder marker on the same line (e.g. `// TODO: real selector`).
|
|
106
|
+
const marker = String.raw `(?:TODO|FIXME|placeholder|real selector|guess(?:ed)?|unconfirmed|not\s+verified)`;
|
|
107
|
+
const sel = String.raw `(?:getByTestId\s*\(|\[\s*data-(?:testid|cy|test)\s*=)`;
|
|
108
|
+
const selNearMarker = new RegExp(`(?:${sel}[^\\n]*?\\b${marker}\\b)|(?:\\b${marker}\\b[^\\n]*?${sel})`, 'i');
|
|
109
|
+
if (selNearMarker.test(active)) {
|
|
110
|
+
return 'a runnable test uses a guessed/placeholder selector — ground it in a real component attribute, or defer the test (test.fixme/skip)';
|
|
111
|
+
}
|
|
112
|
+
// (b) When the project's real test-hooks are known, any test-id a
|
|
113
|
+
// runnable test uses that isn't one of them is an invented selector.
|
|
114
|
+
if (ctx.knownSelectors && ctx.knownSelectors.size > 0) {
|
|
115
|
+
for (const id of usedTestIds(active)) {
|
|
116
|
+
if (!ctx.knownSelectors.has(id)) {
|
|
117
|
+
return `references selector '${id}' not found in the project's components — ground it or defer the test`;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Remove pending test blocks (`it/test/describe.skip|fixme|todo(...)`) so the
|
|
126
|
+
* F9 selector check only ever inspects RUNNABLE code. A guessed selector
|
|
127
|
+
* inside a deferred test is fine — it's an honest gap, not a false red.
|
|
128
|
+
* String- and comment-aware paren matching so a `(` inside a body or string
|
|
129
|
+
* doesn't unbalance the scan.
|
|
130
|
+
*/
|
|
131
|
+
function stripPendingBlocks(content) {
|
|
132
|
+
const startRe = /\b(?:it|test|describe)\.(?:skip|fixme|todo)\s*\(/g;
|
|
133
|
+
let out = '';
|
|
134
|
+
let last = 0;
|
|
135
|
+
let m;
|
|
136
|
+
while ((m = startRe.exec(content)) !== null) {
|
|
137
|
+
const openIdx = m.index + m[0].length - 1; // index of the '('
|
|
138
|
+
const end = matchParen(content, openIdx);
|
|
139
|
+
if (end === -1)
|
|
140
|
+
continue; // unbalanced — leave as-is, don't drop tail
|
|
141
|
+
out += content.slice(last, m.index);
|
|
142
|
+
last = end + 1;
|
|
143
|
+
startRe.lastIndex = last;
|
|
144
|
+
}
|
|
145
|
+
out += content.slice(last);
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Given the index of an opening `(`, return the index of its matching `)`,
|
|
150
|
+
* skipping over quoted strings, template literals, and comments. Returns -1
|
|
151
|
+
* if no match is found.
|
|
152
|
+
*/
|
|
153
|
+
function matchParen(s, open) {
|
|
154
|
+
let depth = 0;
|
|
155
|
+
for (let i = open; i < s.length; i++) {
|
|
156
|
+
const c = s[i];
|
|
157
|
+
// skip strings / templates
|
|
158
|
+
if (c === "'" || c === '"' || c === '`') {
|
|
159
|
+
const q = c;
|
|
160
|
+
i++;
|
|
161
|
+
while (i < s.length && s[i] !== q) {
|
|
162
|
+
if (s[i] === '\\')
|
|
163
|
+
i++;
|
|
164
|
+
i++;
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// skip comments
|
|
169
|
+
if (c === '/' && s[i + 1] === '/') {
|
|
170
|
+
while (i < s.length && s[i] !== '\n')
|
|
171
|
+
i++;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (c === '/' && s[i + 1] === '*') {
|
|
175
|
+
i += 2;
|
|
176
|
+
while (i < s.length && !(s[i] === '*' && s[i + 1] === '/'))
|
|
177
|
+
i++;
|
|
178
|
+
i++;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (c === '(')
|
|
182
|
+
depth++;
|
|
183
|
+
else if (c === ')') {
|
|
184
|
+
depth--;
|
|
185
|
+
if (depth === 0)
|
|
186
|
+
return i;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return -1;
|
|
190
|
+
}
|
|
191
|
+
/** Test-hook values a file references: getByTestId('x'), [data-testid="x"], data-cy, data-test. */
|
|
192
|
+
function usedTestIds(content) {
|
|
193
|
+
const ids = new Set();
|
|
194
|
+
const patterns = [
|
|
195
|
+
/getByTestId\s*\(\s*(['"`])([^'"`]+)\1/g,
|
|
196
|
+
/\[\s*data-(?:testid|cy|test)\s*=\s*(['"])([^'"]+)\1\s*\]/g,
|
|
197
|
+
];
|
|
198
|
+
for (const re of patterns) {
|
|
199
|
+
let m;
|
|
200
|
+
while ((m = re.exec(content)) !== null)
|
|
201
|
+
ids.add(m[2]);
|
|
202
|
+
}
|
|
203
|
+
return [...ids];
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* F8 — lightweight coverage stats for one generated file, so a manifest
|
|
207
|
+
* can tell REAL coverage from deferred placeholders (a green run is never
|
|
208
|
+
* mistaken for full coverage).
|
|
209
|
+
*/
|
|
210
|
+
export function analyzeFile(content) {
|
|
211
|
+
const activeTests = (content.match(/\b(?:it|test)\s*\(/g) || []).length +
|
|
212
|
+
(content.match(/\b(?:it|test)\.(?:each|concurrent)\b/g) || []).length;
|
|
213
|
+
const pending = (content.match(/\b(?:it|test|describe)\.(?:todo|skip|fixme)\b/g) || []).length;
|
|
214
|
+
const assertions = (content.match(/\bexpect\s*\(/g) || []).length;
|
|
215
|
+
return { activeTests, pending, assertions, real: activeTests > 0 && assertions > 0 };
|
|
216
|
+
}
|
|
217
|
+
/** Extract bare module specifiers from import/require (skips relative). */
|
|
218
|
+
function importedModules(content) {
|
|
219
|
+
const mods = new Set();
|
|
220
|
+
const patterns = [
|
|
221
|
+
/\bimport\b[^'"]*?from\s*(['"])([^'"]+)\1/g,
|
|
222
|
+
/\bimport\s*(['"])([^'"]+)\1/g,
|
|
223
|
+
/\brequire\(\s*(['"])([^'"]+)\1\s*\)/g,
|
|
224
|
+
];
|
|
225
|
+
for (const re of patterns) {
|
|
226
|
+
let m;
|
|
227
|
+
while ((m = re.exec(content)) !== null) {
|
|
228
|
+
const spec = m[2];
|
|
229
|
+
if (!spec.startsWith('.') && !spec.startsWith('/'))
|
|
230
|
+
mods.add(spec);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return [...mods];
|
|
234
|
+
}
|
|
235
|
+
/** `@scope/pkg/sub` -> `@scope/pkg`; `pkg/sub` -> `pkg`; `node:fs` -> `node:fs`. */
|
|
236
|
+
function topLevelModule(spec) {
|
|
237
|
+
if (spec.startsWith('node:'))
|
|
238
|
+
return spec;
|
|
239
|
+
const parts = spec.split('/');
|
|
240
|
+
return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
|
|
241
|
+
}
|
|
23
242
|
/** True if `content` parses (or, for TS, passes a structural check). */
|
|
24
243
|
export function isSyntaxValid(content, ext) {
|
|
25
244
|
if (!content.trim())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@activate-core/test-forge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
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": {
|