@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.
@@ -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 { writePlaywrightFile } from './playwright-writer.js';
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 { harnessInfo, generateHarnessAsync, generateHarness, playwrightConfig, globalSetupContent, } from './harness.js';
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 basic generator
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: writePlaywrightFile(journey, cases), status: 'basic' };
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
- // Skip if unchanged.
308
- if (!force && cache[file] === hash) {
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 ~4 waves
338
- // instead of 15 serial calls.
339
- const generated = await pool(planned, GEN_CONCURRENCY, async (pf) => {
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
- if (isSyntaxValid(readFileSync(filePath, 'utf-8'), ext)) {
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 (e.g.`,
46
- ` process.env.DB_URL). NEVER hardcode a connection string or any`,
47
- ` secret.`,
48
- `- Make it IDEMPOTENT: use upsert by a stable, well-known test id so`,
49
- ` running it repeatedly (fresh DB or already-seeded) is safe.`,
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,`,
@@ -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.10.2",
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": {