@activate-core/test-forge 0.18.7 → 0.18.9

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.
Files changed (32) hide show
  1. package/dist/cli/index.js +21 -10
  2. package/dist/core/coverage.js +54 -0
  3. package/dist/core/{cache.js → docs/cache.js} +1 -1
  4. package/dist/core/{discover.js → docs/discover.js} +2 -2
  5. package/dist/core/{grounding.js → docs/grounding.js} +2 -2
  6. package/dist/core/{reference.js → docs/reference.js} +1 -1
  7. package/dist/core/{source-of-truth.js → docs/source-of-truth.js} +2 -2
  8. package/dist/core/gates/coverage.js +54 -0
  9. package/dist/core/{ai-generator.js → generate/ai-generator.js} +1 -1
  10. package/dist/core/generate/ai-session.js +120 -0
  11. package/dist/core/{harness.js → generate/harness.js} +7 -1
  12. package/dist/core/generate/plan-cache.js +181 -0
  13. package/dist/core/{run-generate.js → generate/run-generate.js} +23 -320
  14. package/dist/core/{seed-generator.js → generate/seed-generator.js} +1 -1
  15. package/dist/core/{suite-planner.js → generate/suite-planner.js} +1 -1
  16. package/dist/core/{env-template.js → project/env-template.js} +2 -2
  17. package/dist/core/{setup.js → project/setup.js} +3 -3
  18. package/dist/core/{update.js → project/update.js} +5 -5
  19. package/dist/core/{run-tests.js → run/run-tests.js} +22 -2
  20. package/package.json +2 -2
  21. /package/dist/core/{doc-guidance.js → docs/doc-guidance.js} +0 -0
  22. /package/dist/core/{parser.js → docs/parser.js} +0 -0
  23. /package/dist/core/{lint.js → gates/lint.js} +0 -0
  24. /package/dist/core/{validate.js → gates/validate.js} +0 -0
  25. /package/dist/core/{generator.js → generate/generator.js} +0 -0
  26. /package/dist/core/{playwright-writer.js → generate/playwright-writer.js} +0 -0
  27. /package/dist/core/{security-payloads.js → generate/security-payloads.js} +0 -0
  28. /package/dist/core/{config.js → project/config.js} +0 -0
  29. /package/dist/core/{detect.js → project/detect.js} +0 -0
  30. /package/dist/core/{init.js → project/init.js} +0 -0
  31. /package/dist/core/{map.js → project/map.js} +0 -0
  32. /package/dist/core/{report.js → run/report.js} +0 -0
package/dist/cli/index.js CHANGED
@@ -19,16 +19,16 @@
19
19
  * (an auth-gated page can move unseen).
20
20
  */
21
21
  import { VERSION } from '../core/index.js';
22
- import { runGenerate } from '../core/run-generate.js';
23
- import { runTests } from '../core/run-tests.js';
24
- import { runDiscover } from '../core/discover.js';
25
- import { runMap } from '../core/map.js';
26
- import { runInit } from '../core/init.js';
27
- import { runSetup } from '../core/setup.js';
28
- import { runUpdate } from '../core/update.js';
29
- import { runEnvTemplate } from '../core/env-template.js';
30
- import { loadConfig } from '../core/config.js';
31
- import { publishReport, loadReportConfig, loadDotenv, scanInventory, publishInventory } from '../core/report.js';
22
+ import { runGenerate } from '../core/generate/run-generate.js';
23
+ import { runTests } from '../core/run/run-tests.js';
24
+ import { runDiscover } from '../core/docs/discover.js';
25
+ import { runMap } from '../core/project/map.js';
26
+ import { runInit } from '../core/project/init.js';
27
+ import { runSetup } from '../core/project/setup.js';
28
+ import { runUpdate } from '../core/project/update.js';
29
+ import { runEnvTemplate } from '../core/project/env-template.js';
30
+ import { loadConfig } from '../core/project/config.js';
31
+ import { publishReport, loadReportConfig, loadDotenv, scanInventory, publishInventory } from '../core/run/report.js';
32
32
  import { dirname } from 'node:path';
33
33
  const command = process.argv[2];
34
34
  const useBasic = process.argv.includes('--basic');
@@ -92,6 +92,8 @@ function printUsage() {
92
92
  console.log(' testforge env Write .env.testing.example from docs');
93
93
  console.log(' testforge run [--area a] [--journey j] Run the generated tests (Jest/Playwright)');
94
94
  console.log(' testforge run --env-file <file> Run against another server (e.g. .env.dev / .env.staging)');
95
+ console.log(' testforge run --headed | --ui | --debug Watch the browser / open the Playwright UI');
96
+ console.log(' testforge run -- <flags> Pass anything else straight to the runner');
95
97
  }
96
98
  async function main() {
97
99
  if (command === 'update') {
@@ -125,10 +127,19 @@ async function main() {
125
127
  runEnvTemplate('docs', '.env.testing.example', '.', generatedScanDirs());
126
128
  }
127
129
  else if (command === 'run') {
130
+ // Anything after `--`, plus the runner flags people reach for most, goes
131
+ // straight to the test runner. Watching a UI suite is an ordinary thing to
132
+ // want and should not mean rebuilding the command by hand.
133
+ const dashIdx = process.argv.indexOf('--');
134
+ const passThrough = [
135
+ ...['--headed', '--ui', '--debug'].filter((f) => process.argv.includes(f)),
136
+ ...(dashIdx === -1 ? [] : process.argv.slice(dashIdx + 1)),
137
+ ];
128
138
  const code = runTests({
129
139
  area: flagValue('--area'),
130
140
  journey: flagValue('--journey'),
131
141
  envFile: flagValue('--env-file'),
142
+ passThrough,
132
143
  });
133
144
  process.exitCode = code;
134
145
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * testforge — the coverage manifest.
3
+ *
4
+ * A green run is only meaningful if the count behind it is honest, so this
5
+ * records REAL files (they assert something) apart from DEFERRED ones (they
6
+ * are pending placeholders). Without the split, a suite of placeholders reads
7
+ * as full coverage.
8
+ */
9
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { analyzeFile } from './validate.js';
11
+ import { COVERAGE_PATH, STATE_DIR } from './paths.js';
12
+ /**
13
+ * F8 — write an honest coverage manifest. For every generated file it
14
+ * records real (has assertions) vs deferred (pending only), so a "green"
15
+ * run is never mistaken for full coverage. Written to
16
+ * test-artifacts/coverage.json and summarised to the console.
17
+ */
18
+ export function writeCoverageManifest(results) {
19
+ const journeys = results
20
+ .filter((r) => r.testFiles.length)
21
+ .map((r) => {
22
+ const files = r.testFiles.map((t) => {
23
+ let a = { activeTests: 0, pending: 0, assertions: 0, real: false };
24
+ try {
25
+ a = analyzeFile(readFileSync(t.path, 'utf-8'));
26
+ }
27
+ catch {
28
+ /* file unreadable — count as empty */
29
+ }
30
+ return { file: t.path, status: t.status, ...a };
31
+ });
32
+ return {
33
+ journey: r.journeyName,
34
+ real: files.filter((f) => f.real).length,
35
+ deferred: files.filter((f) => !f.real).length,
36
+ files,
37
+ };
38
+ });
39
+ if (!journeys.length)
40
+ return;
41
+ const totalReal = journeys.reduce((n, j) => n + j.real, 0);
42
+ const totalDeferred = journeys.reduce((n, j) => n + j.deferred, 0);
43
+ try {
44
+ mkdirSync(STATE_DIR, { recursive: true });
45
+ writeFileSync(COVERAGE_PATH, JSON.stringify({ real: totalReal, deferred: totalDeferred, journeys }, null, 2), 'utf-8');
46
+ }
47
+ catch {
48
+ /* best-effort */
49
+ }
50
+ console.log(`\nCoverage: ${totalReal} real test file(s), ${totalDeferred} deferred/placeholder — see ${COVERAGE_PATH}`);
51
+ if (totalDeferred) {
52
+ console.log(` (deferred files are pending, NOT real coverage — regenerate or implement them.)`);
53
+ }
54
+ }
@@ -12,7 +12,7 @@
12
12
  import { createHash } from 'node:crypto';
13
13
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, } from 'node:fs';
14
14
  import { dirname, join, extname } from 'node:path';
15
- import { STATE_DIR, LEGACY_STATE_DIR } from './paths.js';
15
+ import { STATE_DIR, LEGACY_STATE_DIR } from '../paths.js';
16
16
  /** Make a short fingerprint of a file's content. */
17
17
  export function hashContent(content) {
18
18
  return createHash('sha256').update(content).digest('hex');
@@ -18,9 +18,9 @@ import { execFileSync } from 'node:child_process';
18
18
  import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, renameSync, } from 'node:fs';
19
19
  import { join, relative, sep, basename } from 'node:path';
20
20
  import { notAJourneyDoc } from './parser.js';
21
- import { loadConfig, splitCommand, withEditTools } from './config.js';
21
+ import { loadConfig, splitCommand, withEditTools } from '../project/config.js';
22
22
  import { DEEP_DOC_GUIDANCE } from './doc-guidance.js';
23
- import { promptConfirm } from './prompt.js';
23
+ import { promptConfirm } from '../prompt.js';
24
24
  import { loadSourceChoice, resolveDocsSource, saveJourneyReferences, saveSourceChoice, } from './source-of-truth.js';
25
25
  import { analyzeReference, draftDocsFromReference, DIFF_PATH, } from './reference.js';
26
26
  /** Scope line: one journey, or all of them. */
@@ -20,9 +20,9 @@
20
20
  import { execFileSync } from 'node:child_process';
21
21
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
22
22
  import { dirname, join } from 'node:path';
23
- import { loadConfig, splitCommand, withReadOnlyTools } from './config.js';
23
+ import { loadConfig, splitCommand, withReadOnlyTools } from '../project/config.js';
24
24
  import { hashProjectSource } from './cache.js';
25
- import { STATE_DIR } from './paths.js';
25
+ import { STATE_DIR } from '../paths.js';
26
26
  const MAP_PATH = 'testforge.md';
27
27
  /** Where a journey's grounded facts live, mirroring the plan cache layout. */
28
28
  function groundingPath(journey) {
@@ -22,7 +22,7 @@
22
22
  * and tuned for the Claude CLI.
23
23
  */
24
24
  import { execFileSync } from 'node:child_process';
25
- import { loadConfig, splitCommand, withReferenceTools } from './config.js';
25
+ import { loadConfig, splitCommand, withReferenceTools } from '../project/config.js';
26
26
  import { DEEP_DOC_GUIDANCE } from './doc-guidance.js';
27
27
  /** Where the comparison report is written. */
28
28
  export const DIFF_PATH = 'testforge.diff.md';
@@ -21,8 +21,8 @@
21
21
  */
22
22
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
23
23
  import { dirname } from 'node:path';
24
- import { isInteractive, promptChoice, promptLine } from './prompt.js';
25
- import { SOURCE_PATH } from './paths.js';
24
+ import { isInteractive, promptChoice, promptLine } from '../prompt.js';
25
+ import { SOURCE_PATH } 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))
@@ -0,0 +1,54 @@
1
+ /**
2
+ * testforge — the coverage manifest.
3
+ *
4
+ * A green run is only meaningful if the count behind it is honest, so this
5
+ * records REAL files (they assert something) apart from DEFERRED ones (they
6
+ * are pending placeholders). Without the split, a suite of placeholders reads
7
+ * as full coverage.
8
+ */
9
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { analyzeFile } from './validate.js';
11
+ import { COVERAGE_PATH, STATE_DIR } from '../paths.js';
12
+ /**
13
+ * F8 — write an honest coverage manifest. For every generated file it
14
+ * records real (has assertions) vs deferred (pending only), so a "green"
15
+ * run is never mistaken for full coverage. Written to
16
+ * test-artifacts/coverage.json and summarised to the console.
17
+ */
18
+ export function writeCoverageManifest(results) {
19
+ const journeys = results
20
+ .filter((r) => r.testFiles.length)
21
+ .map((r) => {
22
+ const files = r.testFiles.map((t) => {
23
+ let a = { activeTests: 0, pending: 0, assertions: 0, real: false };
24
+ try {
25
+ a = analyzeFile(readFileSync(t.path, 'utf-8'));
26
+ }
27
+ catch {
28
+ /* file unreadable — count as empty */
29
+ }
30
+ return { file: t.path, status: t.status, ...a };
31
+ });
32
+ return {
33
+ journey: r.journeyName,
34
+ real: files.filter((f) => f.real).length,
35
+ deferred: files.filter((f) => !f.real).length,
36
+ files,
37
+ };
38
+ });
39
+ if (!journeys.length)
40
+ return;
41
+ const totalReal = journeys.reduce((n, j) => n + j.real, 0);
42
+ const totalDeferred = journeys.reduce((n, j) => n + j.deferred, 0);
43
+ try {
44
+ mkdirSync(STATE_DIR, { recursive: true });
45
+ writeFileSync(COVERAGE_PATH, JSON.stringify({ real: totalReal, deferred: totalDeferred, journeys }, null, 2), 'utf-8');
46
+ }
47
+ catch {
48
+ /* best-effort */
49
+ }
50
+ console.log(`\nCoverage: ${totalReal} real test file(s), ${totalDeferred} deferred/placeholder — see ${COVERAGE_PATH}`);
51
+ if (totalDeferred) {
52
+ console.log(` (deferred files are pending, NOT real coverage — regenerate or implement them.)`);
53
+ }
54
+ }
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { execFileSync, spawn } from 'node:child_process';
14
14
  import { readFileSync, existsSync } from 'node:fs';
15
- import { loadConfig, splitCommand, withReadOnlyTools, resolveFramework, } from './config.js';
15
+ import { loadConfig, splitCommand, withReadOnlyTools, resolveFramework, } from '../project/config.js';
16
16
  const MAP_PATH = 'testforge.md';
17
17
  /**
18
18
  * Spawn a command, feed `input` on stdin, resolve with its stdout.
@@ -0,0 +1,120 @@
1
+ /**
2
+ * testforge — is the AI answering, and if not, why.
3
+ *
4
+ * One place owns the answer, because every caller needs the same one: the
5
+ * per-file generator, the seed generator and the harness builder all ask the
6
+ * same CLI, and a limit that stops one has stopped them all. Keeping the
7
+ * state here is what lets a run say "AI unavailable: <reason>" once and wait,
8
+ * instead of each caller failing on its own with no explanation.
9
+ *
10
+ * Nothing here decides what to DO about it — run-generate owns that. This
11
+ * module only reports.
12
+ */
13
+ import { pingAi } from './ai-generator.js';
14
+ // WHY the last AI attempt failed, and whether the AI is gone for the whole run.
15
+ //
16
+ // Both validators used to swallow the error and retry, so when the AI CLI was
17
+ // unavailable — usage limit reached mid-run, not logged in, not installed —
18
+ // the operator saw "could not be generated with AI" forty-three times and no
19
+ // reason, while every remaining journey was burned into a deferred stub and
20
+ // every seed retried against a dead CLI. Record the reason, put it in the
21
+ // warning, and once the failure is clearly "AI unavailable" stop starting new
22
+ // journeys: the deferred stubs are re-attempted automatically on the next run.
23
+ let lastAiFailure = null;
24
+ let aiUnavailable = null;
25
+ // Process failures in a row with no success between them. The CLI does not
26
+ // always SAY it is out — on wagonex it exited 1 with empty stderr for every
27
+ // call and the message-matching below saw nothing to match, so thirteen
28
+ // journeys were burned before anyone noticed. Two silent failures in a row
29
+ // earn a ping; if the ping fails too, the AI is unavailable, message or not.
30
+ let consecutiveAiFailures = 0;
31
+ const AI_UNAVAILABLE = /usage limit|spend limit|limit will reset|rate limit|\b429\b|not logged in|please log ?in|unauthori[sz]ed|invalid api key|credit balance|quota|spawn \S+ ENOENT/i;
32
+ export function noteAiFailure(e) {
33
+ // execFileSync attaches what the process said; node's own message is just
34
+ // "Command failed: <cmd>", which explains nothing.
35
+ const x = e;
36
+ const said = [x.stderr, x.stdout].map((v) => String(v ?? '').trim()).find(Boolean);
37
+ let msg = (x.message ?? String(e)).replace(/\s+/g, ' ').trim();
38
+ if (said && !msg.includes(said.slice(0, 40)))
39
+ msg += `: ${said}`;
40
+ if (x.signal === 'SIGKILL' && !/timed out/.test(msg))
41
+ msg += ' (killed — hit aiTimeoutMs)';
42
+ lastAiFailure = msg.slice(0, 300);
43
+ consecutiveAiFailures += 1;
44
+ if (AI_UNAVAILABLE.test(msg))
45
+ aiUnavailable = lastAiFailure;
46
+ }
47
+ export function noteAiRejected(why) {
48
+ // The AI answered, but with something we will not write. Not a sign it is
49
+ // gone — do not count it towards unavailability — but the operator still
50
+ // deserves to know why the file is a stub.
51
+ lastAiFailure = `AI output rejected: ${why}`.slice(0, 300);
52
+ }
53
+ export function noteAiSuccess() {
54
+ consecutiveAiFailures = 0;
55
+ }
56
+ /**
57
+ * Two silent failures in a row: ask the CLI directly whether it is alive.
58
+ * Returns true when it was failing but answers now — the caller then redoes
59
+ * the journey those failures degraded, exactly as after a wait.
60
+ */
61
+ export async function confirmAiAlive() {
62
+ if (aiUnavailable || consecutiveAiFailures < 2)
63
+ return false;
64
+ if (!(await pingAi(60_000))) {
65
+ aiUnavailable = lastAiFailure ?? 'the AI CLI is not answering';
66
+ return false;
67
+ }
68
+ consecutiveAiFailures = 0;
69
+ return true;
70
+ }
71
+ export function aiFailureNote() {
72
+ return lastAiFailure ? ` — last AI error: ${lastAiFailure}` : '';
73
+ }
74
+ /**
75
+ * Wait for the AI CLI to answer again after a usage limit. Polls with a
76
+ * one-line question every `aiWaitPollMs` for up to `aiWaitMaxMs`; true when
77
+ * it is back (and the failure state is cleared), false when the cap is hit.
78
+ * Progress goes to the console because a run that sits silent for hours
79
+ * looks hung.
80
+ */
81
+ export async function waitForAi(config) {
82
+ const poll = config.aiWaitPollMs ?? 20 * 60_000;
83
+ const max = config.aiWaitMaxMs ?? 8 * 3_600_000;
84
+ const mins = (ms) => `${Math.max(1, Math.round(ms / 60_000))} min`;
85
+ console.log(`\nAI unavailable: ${aiUnavailable}\n` +
86
+ `Waiting for it to come back — checking every ${mins(poll)}, for up to ${mins(max)}. ` +
87
+ `Ctrl-C is safe: re-running later resumes from exactly here.`);
88
+ const started = Date.now();
89
+ while (Date.now() - started < max) {
90
+ await new Promise((r) => setTimeout(r, poll));
91
+ if (await pingAi(60_000)) {
92
+ console.log(`AI is back (${new Date().toLocaleTimeString()}) — resuming.`);
93
+ aiUnavailable = null;
94
+ lastAiFailure = null;
95
+ return true;
96
+ }
97
+ console.log(`${new Date().toLocaleTimeString()} still unavailable — next check in ${mins(poll)}`);
98
+ }
99
+ console.log(`Gave up waiting after ${mins(max)}.`);
100
+ return false;
101
+ }
102
+ /** The reason the AI is unavailable, or null while it is answering. */
103
+ export function aiUnavailableReason() {
104
+ return aiUnavailable;
105
+ }
106
+ /** Forget everything — called once at the start of a run. */
107
+ export function resetAiSession() {
108
+ lastAiFailure = null;
109
+ aiUnavailable = null;
110
+ consecutiveAiFailures = 0;
111
+ }
112
+ /**
113
+ * True while the last thing that went wrong was the AI PROCESS failing (as
114
+ * opposed to it answering with output we refused). run-generate uses this to
115
+ * tell "the outage is over, redo what it broke" from "that file was simply
116
+ * rejected", which needs no redo.
117
+ */
118
+ export function lastAiFailureWasProcess() {
119
+ return consecutiveAiFailures > 0;
120
+ }
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { execFileSync, spawn } from 'node:child_process';
15
15
  import { existsSync, readFileSync } from 'node:fs';
16
- import { loadConfig, splitCommand, withReadOnlyTools } from './config.js';
16
+ import { loadConfig, splitCommand, withReadOnlyTools } from '../project/config.js';
17
17
  import { cleanOutput } from './ai-generator.js';
18
18
  const MAP_PATH = 'testforge.md';
19
19
  /**
@@ -315,6 +315,12 @@ export function playwrightConfig(testDirRelToPackage, globalSetupRelToPackage, c
315
315
  ` testMatch: /.*\\.(test|spec)\\.[jt]s$/,`,
316
316
  ` globalSetup: require.resolve('${globalSetupRelToPackage}'),`,
317
317
  ` timeout: 60000,`,
318
+ ` // Serial, like the Jest config: these suites share ONE test database`,
319
+ ` // and seed/reset it, so parallel workers race on the same rows and the`,
320
+ ` // same run gives different answers twice. It also keeps --headed to a`,
321
+ ` // single browser window instead of one per worker.`,
322
+ ` fullyParallel: false,`,
323
+ ` workers: 1,`,
318
324
  ` // Console line output. The last reporter auto-publishes the run to the`,
319
325
  ` // TestForge dashboard (needs TESTFORGE_KEY in env + project/dashboardUrl`,
320
326
  ` // in testforge.config.json).`,
@@ -0,0 +1,181 @@
1
+ /**
2
+ * testforge — the per-journey suite plan, and what is already on disk.
3
+ *
4
+ * A journey is split into focused files, one per concern, and that split is
5
+ * cached so a re-run does not re-ask the AI for a plan it already has. The
6
+ * rest of this module answers the questions a resume needs: which files the
7
+ * plan expects, which of those are real and which are placeholders, and which
8
+ * files from an older plan are now stale.
9
+ */
10
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { planSuite } from './suite-planner.js';
13
+ import { PLANS_DIR } from '../paths.js';
14
+ /** The line every generated file carries, so cleanup never touches a hand-written one. */
15
+ export const GEN_MARKER = '@generated by testforge';
16
+ /**
17
+ * Turn a journey id into the alternate naming conventions we might
18
+ * collide with (kebab <-> camel). Used to spot a pre-existing test for
19
+ * the same journey written under a different filename style.
20
+ */
21
+ export function alternateIds(id) {
22
+ const kebab = id
23
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
24
+ .replace(/_/g, '-')
25
+ .toLowerCase();
26
+ const camel = id.replace(/[-_]([a-zA-Z0-9])/g, (_, c) => c.toUpperCase());
27
+ return [...new Set([kebab, camel])].filter((x) => x !== id);
28
+ }
29
+ /**
30
+ * Warn if a test for this same journey already exists under a different
31
+ * naming convention (e.g. urlShortener.test.js vs url-shortener.test.js),
32
+ * which would silently create a duplicate.
33
+ */
34
+ export function collisionWarning(testDir, id, ext) {
35
+ if (!existsSync(testDir))
36
+ return null;
37
+ const alts = new Set(alternateIds(id).map((a) => `${a}${ext}`));
38
+ let existing;
39
+ try {
40
+ existing = readdirSync(testDir);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ const clashes = existing.filter((f) => alts.has(f));
46
+ if (clashes.length === 0)
47
+ return null;
48
+ return `an existing test with different naming already covers this journey: ${clashes.join(', ')} — new file may duplicate it`;
49
+ }
50
+ /** Plan cache path for a journey, organised per area:
51
+ * `<PLANS_DIR>/<area>/<id>.json` (e.g. test-artifacts/test-plans/api/payment-api.json). */
52
+ export function planPath(journey) {
53
+ return join(PLANS_DIR, journey.area, `${journey.id}.json`);
54
+ }
55
+ /**
56
+ * F9 — a journey doc that is still an unconfirmed stub. Deep-generating
57
+ * confident assertions from it produces UNVALIDATED tests, so we skip it
58
+ * and tell the user to confirm the spec first.
59
+ */
60
+ export function isStubDoc(markdown) {
61
+ // The doc's FIRST heading is its premise. A confirmed journey opens with a
62
+ // real title (e.g. "# Journey: ..."); a genuine stub opens by declaring
63
+ // itself uncovered ("# TODO: not covered by the reference ...").
64
+ //
65
+ // We deliberately look ONLY at the opening heading. Partial "reference
66
+ // SILENT" annotations on individual SECTIONS of an otherwise-complete doc,
67
+ // and trailing "developer verification checklist" sections, are healthy —
68
+ // the previous whole-document scan wrongly skipped confirmed journeys that
69
+ // merely carried such notes.
70
+ const firstHeading = (markdown.match(/^#{1,6}\s+.+$/m) || [''])[0];
71
+ return /\btodo\b|not covered by the reference|reference[-\s]silent|unconfirmed/i.test(firstHeading);
72
+ }
73
+ /**
74
+ * True if a generated file is a testforge DEFERRED stub — a placeholder
75
+ * written when AI generation was unavailable. It is NOT real coverage, so
76
+ * per-file resume must RE-ATTEMPT it (unlike a real file, which is kept).
77
+ */
78
+ export function isDeferredStub(content) {
79
+ return content.includes('DEFERRED stub');
80
+ }
81
+ /** True if the journey's output dir already holds any deferred stub. */
82
+ export function hasDeferredStubs(testDir, ext) {
83
+ let files;
84
+ try {
85
+ files = readdirSync(testDir);
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ for (const f of files) {
91
+ if (!f.endsWith(ext))
92
+ continue;
93
+ try {
94
+ if (isDeferredStub(readFileSync(join(testDir, f), 'utf-8')))
95
+ return true;
96
+ }
97
+ catch {
98
+ /* ignore unreadable */
99
+ }
100
+ }
101
+ return false;
102
+ }
103
+ /** Slugs recorded in a journey's existing plan cache (the previous run). */
104
+ export function readPlanSlugs(journey) {
105
+ const p = planPath(journey);
106
+ try {
107
+ const c = JSON.parse(readFileSync(p, 'utf-8'));
108
+ if (Array.isArray(c.files)) {
109
+ return c.files.map((f) => f.slug).filter((s) => !!s);
110
+ }
111
+ }
112
+ catch {
113
+ /* no previous plan */
114
+ }
115
+ return [];
116
+ }
117
+ /**
118
+ * F6 — replace, don't duplicate. When a journey is (re)generated and its
119
+ * plan changed, the files for slugs that no longer exist are stale. Delete
120
+ * them — but ONLY files that carry our marker, so we never touch a
121
+ * developer's own tests. Prevents the twin-suite accumulation.
122
+ */
123
+ export function cleanStaleFiles(testDir, journeyId, ext, flat, keepSlugs, dropSlugs) {
124
+ const removed = [];
125
+ const keep = new Set(keepSlugs);
126
+ for (const slug of dropSlugs) {
127
+ if (keep.has(slug))
128
+ continue;
129
+ const base = flat ? `${journeyId}-${slug}` : slug;
130
+ const f = join(testDir, `${base}${ext}`);
131
+ try {
132
+ if (existsSync(f) && readFileSync(f, 'utf-8').includes(GEN_MARKER)) {
133
+ rmSync(f);
134
+ removed.push(`${base}${ext}`);
135
+ }
136
+ }
137
+ catch {
138
+ /* best-effort */
139
+ }
140
+ }
141
+ return removed;
142
+ }
143
+ /**
144
+ * Get a journey's file plan, cached by doc hash. Caching the plan keeps
145
+ * the file slugs STABLE across runs, so per-file resume (skip-existing)
146
+ * actually matches — and it saves the planner AI call on a resume. When
147
+ * the doc changes, the hash changes and we re-plan.
148
+ */
149
+ export function loadOrPlan(journey, docHash) {
150
+ const p = planPath(journey);
151
+ if (existsSync(p)) {
152
+ try {
153
+ const cached = JSON.parse(readFileSync(p, 'utf-8'));
154
+ if (cached.hash === docHash && Array.isArray(cached.files) && cached.files.length) {
155
+ return cached.files;
156
+ }
157
+ }
158
+ catch {
159
+ // corrupt plan cache — re-plan
160
+ }
161
+ }
162
+ const files = planSuite(journey);
163
+ try {
164
+ mkdirSync(dirname(p), { recursive: true });
165
+ writeFileSync(p, JSON.stringify({ hash: docHash, files }, null, 2), 'utf-8');
166
+ }
167
+ catch {
168
+ // best-effort cache
169
+ }
170
+ return files;
171
+ }
172
+ /** Does this doc file / journey match a --journey selector? */
173
+ export function matchesJourney(file, journeyId, sel) {
174
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-');
175
+ const n = norm(sel);
176
+ const stem = file.replace(/\.md$/, '');
177
+ return (journeyId === n ||
178
+ norm(stem) === n ||
179
+ norm(stem).endsWith(`-${n}`) ||
180
+ norm(stem).split('/').pop() === n);
181
+ }
@@ -16,23 +16,25 @@
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, rmSync, } from 'node:fs';
19
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, } from 'node:fs';
20
20
  import { join, dirname, basename, extname } from 'node:path';
21
- import { CACHE_PATH, COVERAGE_PATH, PLANS_DIR, STATE_DIR } from './paths.js';
22
- import { lintGenerated } from './lint.js';
23
- import { parseJourney, notAJourneyDoc } from './parser.js';
21
+ import { CACHE_PATH } from '../paths.js';
22
+ import { lintGenerated } from '../gates/lint.js';
23
+ import { parseJourney, notAJourneyDoc } from '../docs/parser.js';
24
+ import { writeCoverageManifest } from '../gates/coverage.js';
25
+ import { cleanStaleFiles, collisionWarning, hasDeferredStubs, isDeferredStub, isStubDoc, loadOrPlan, readPlanSlugs, GEN_MARKER, matchesJourney, } from './plan-cache.js';
26
+ import { aiFailureNote, aiUnavailableReason, confirmAiAlive, lastAiFailureWasProcess, noteAiFailure, noteAiRejected, noteAiSuccess, resetAiSession, waitForAi, } from './ai-session.js';
24
27
  import { generateTestCases } from './generator.js';
25
28
  import { writeDeferredStub } from './playwright-writer.js';
26
- import { generateWithAI, generateWithAIAsync, pingAi, } from './ai-generator.js';
27
- import { groundJourney } from './grounding.js';
29
+ import { generateWithAI, generateWithAIAsync, } from './ai-generator.js';
30
+ import { groundJourney } from '../docs/grounding.js';
28
31
  import { generateSeed } from './seed-generator.js';
29
- import { planSuite } from './suite-planner.js';
30
- import { isSyntaxValid, checkTestGates, analyzeFile } from './validate.js';
31
- import { installedModules, transportHint, projectSelectors } from './detect.js';
32
+ import { isSyntaxValid, checkTestGates } from '../gates/validate.js';
33
+ import { installedModules, transportHint, projectSelectors } from '../project/detect.js';
32
34
  import { harnessInfo, generateHarnessAsync, generateHarness, playwrightConfig, globalSetupContent, fixturesDoc, jestConfig, jestSetupContent, } from './harness.js';
33
- import { hashContent, loadCache, saveCache } from './cache.js';
34
- import { findMarkdownFiles } from './find-files.js';
35
- import { loadConfig, testDirForType, resolveFramework } from './config.js';
35
+ import { hashContent, loadCache, saveCache } from '../docs/cache.js';
36
+ import { findMarkdownFiles } from '../find-files.js';
37
+ import { loadConfig, testDirForType, resolveFramework } from '../project/config.js';
36
38
  /** How many times to re-ask the AI when output won't parse. */
37
39
  const MAX_GEN_ATTEMPTS = 2;
38
40
  /** How many focused files to generate concurrently within a journey. */
@@ -57,99 +59,11 @@ async function pool(items, limit, fn) {
57
59
  await Promise.all(Array.from({ length: n }, () => worker()));
58
60
  return results;
59
61
  }
60
- // WHY the last AI attempt failed, and whether the AI is gone for the whole run.
61
- //
62
- // Both validators used to swallow the error and retry, so when the AI CLI was
63
- // unavailable — usage limit reached mid-run, not logged in, not installed —
64
- // the operator saw "could not be generated with AI" forty-three times and no
65
- // reason, while every remaining journey was burned into a deferred stub and
66
- // every seed retried against a dead CLI. Record the reason, put it in the
67
- // warning, and once the failure is clearly "AI unavailable" stop starting new
68
- // journeys: the deferred stubs are re-attempted automatically on the next run.
69
- let lastAiFailure = null;
70
- let aiUnavailable = null;
71
- // Process failures in a row with no success between them. The CLI does not
72
- // always SAY it is out — on wagonex it exited 1 with empty stderr for every
73
- // call and the message-matching below saw nothing to match, so thirteen
74
- // journeys were burned before anyone noticed. Two silent failures in a row
75
- // earn a ping; if the ping fails too, the AI is unavailable, message or not.
76
- let consecutiveAiFailures = 0;
77
- const AI_UNAVAILABLE = /usage limit|spend limit|limit will reset|rate limit|\b429\b|not logged in|please log ?in|unauthori[sz]ed|invalid api key|credit balance|quota|spawn \S+ ENOENT/i;
78
- function noteAiFailure(e) {
79
- // execFileSync attaches what the process said; node's own message is just
80
- // "Command failed: <cmd>", which explains nothing.
81
- const x = e;
82
- const said = [x.stderr, x.stdout].map((v) => String(v ?? '').trim()).find(Boolean);
83
- let msg = (x.message ?? String(e)).replace(/\s+/g, ' ').trim();
84
- if (said && !msg.includes(said.slice(0, 40)))
85
- msg += `: ${said}`;
86
- if (x.signal === 'SIGKILL' && !/timed out/.test(msg))
87
- msg += ' (killed — hit aiTimeoutMs)';
88
- lastAiFailure = msg.slice(0, 300);
89
- consecutiveAiFailures += 1;
90
- if (AI_UNAVAILABLE.test(msg))
91
- aiUnavailable = lastAiFailure;
92
- }
93
- function noteAiRejected(why) {
94
- // The AI answered, but with something we will not write. Not a sign it is
95
- // gone — do not count it towards unavailability — but the operator still
96
- // deserves to know why the file is a stub.
97
- lastAiFailure = `AI output rejected: ${why}`.slice(0, 300);
98
- }
99
- function noteAiSuccess() {
100
- consecutiveAiFailures = 0;
101
- }
102
- /**
103
- * Two silent failures in a row: ask the CLI directly whether it is alive.
104
- * Returns true when it was failing but answers now — the caller then redoes
105
- * the journey those failures degraded, exactly as after a wait.
106
- */
107
- async function confirmAiAlive() {
108
- if (aiUnavailable || consecutiveAiFailures < 2)
109
- return false;
110
- if (!(await pingAi(60_000))) {
111
- aiUnavailable = lastAiFailure ?? 'the AI CLI is not answering';
112
- return false;
113
- }
114
- consecutiveAiFailures = 0;
115
- return true;
116
- }
117
- function aiFailureNote() {
118
- return lastAiFailure ? ` — last AI error: ${lastAiFailure}` : '';
119
- }
120
- /**
121
- * Wait for the AI CLI to answer again after a usage limit. Polls with a
122
- * one-line question every `aiWaitPollMs` for up to `aiWaitMaxMs`; true when
123
- * it is back (and the failure state is cleared), false when the cap is hit.
124
- * Progress goes to the console because a run that sits silent for hours
125
- * looks hung.
126
- */
127
- async function waitForAi(config) {
128
- const poll = config.aiWaitPollMs ?? 20 * 60_000;
129
- const max = config.aiWaitMaxMs ?? 8 * 3_600_000;
130
- const mins = (ms) => `${Math.max(1, Math.round(ms / 60_000))} min`;
131
- console.log(`\nAI unavailable: ${aiUnavailable}\n` +
132
- `Waiting for it to come back — checking every ${mins(poll)}, for up to ${mins(max)}. ` +
133
- `Ctrl-C is safe: re-running later resumes from exactly here.`);
134
- const started = Date.now();
135
- while (Date.now() - started < max) {
136
- await new Promise((r) => setTimeout(r, poll));
137
- if (await pingAi(60_000)) {
138
- console.log(`AI is back (${new Date().toLocaleTimeString()}) — resuming.`);
139
- aiUnavailable = null;
140
- lastAiFailure = null;
141
- return true;
142
- }
143
- console.log(`${new Date().toLocaleTimeString()} still unavailable — next check in ${mins(poll)}`);
144
- }
145
- console.log(`Gave up waiting after ${mins(max)}.`);
146
- return false;
147
- }
148
62
  /** Async version of generateValidated (for concurrent per-file gen). */
149
63
  async function generateValidatedAsync(generate, ext, gate) {
150
64
  let content = '';
151
65
  for (let attempt = 0; attempt < MAX_GEN_ATTEMPTS; attempt++) {
152
- if (aiUnavailable)
66
+ if (aiUnavailableReason())
153
67
  return null; // retrying a dead CLI only burns time
154
68
  try {
155
69
  content = await generate();
@@ -174,7 +88,7 @@ async function generateValidatedAsync(generate, ext, gate) {
174
88
  function generateValidated(generate, ext, gate) {
175
89
  let content = '';
176
90
  for (let attempt = 0; attempt < MAX_GEN_ATTEMPTS; attempt++) {
177
- if (aiUnavailable)
91
+ if (aiUnavailableReason())
178
92
  return null; // retrying a dead CLI only burns time
179
93
  try {
180
94
  content = generate();
@@ -213,40 +127,6 @@ function buildTest(journey, useAI, ext, testPath, framework, focus, grounding) {
213
127
  const cases = generateTestCases(journey);
214
128
  return { content: writeDeferredStub(journey, cases, framework), status: 'basic' };
215
129
  }
216
- /**
217
- * Turn a journey id into the alternate naming conventions we might
218
- * collide with (kebab <-> camel). Used to spot a pre-existing test for
219
- * the same journey written under a different filename style.
220
- */
221
- function alternateIds(id) {
222
- const kebab = id
223
- .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
224
- .replace(/_/g, '-')
225
- .toLowerCase();
226
- const camel = id.replace(/[-_]([a-zA-Z0-9])/g, (_, c) => c.toUpperCase());
227
- return [...new Set([kebab, camel])].filter((x) => x !== id);
228
- }
229
- /**
230
- * Warn if a test for this same journey already exists under a different
231
- * naming convention (e.g. urlShortener.test.js vs url-shortener.test.js),
232
- * which would silently create a duplicate.
233
- */
234
- function collisionWarning(testDir, id, ext) {
235
- if (!existsSync(testDir))
236
- return null;
237
- const alts = new Set(alternateIds(id).map((a) => `${a}${ext}`));
238
- let existing;
239
- try {
240
- existing = readdirSync(testDir);
241
- }
242
- catch {
243
- return null;
244
- }
245
- const clashes = existing.filter((f) => alts.has(f));
246
- if (clashes.length === 0)
247
- return null;
248
- return `an existing test with different naming already covers this journey: ${clashes.join(', ')} — new file may duplicate it`;
249
- }
250
130
  /**
251
131
  * (Re)generate ONLY the shared harness for one or all areas — without
252
132
  * touching any test file. Use this after upgrading the harness (e.g. to
@@ -285,141 +165,8 @@ export async function runHarness(only) {
285
165
  }
286
166
  return written;
287
167
  }
288
- /** Plan cache path for a journey, organised per area:
289
- * `<PLANS_DIR>/<area>/<id>.json` (e.g. test-artifacts/test-plans/api/payment-api.json). */
290
- function planPath(journey) {
291
- return join(PLANS_DIR, journey.area, `${journey.id}.json`);
292
- }
293
168
  /** Marker every testforge-written test carries, so stale-file cleanup
294
169
  * (F6) only ever removes OUR files, never hand-authored ones. */
295
- const GEN_MARKER = '@generated by testforge';
296
- /**
297
- * F9 — a journey doc that is still an unconfirmed stub. Deep-generating
298
- * confident assertions from it produces UNVALIDATED tests, so we skip it
299
- * and tell the user to confirm the spec first.
300
- */
301
- function isStubDoc(markdown) {
302
- // The doc's FIRST heading is its premise. A confirmed journey opens with a
303
- // real title (e.g. "# Journey: ..."); a genuine stub opens by declaring
304
- // itself uncovered ("# TODO: not covered by the reference ...").
305
- //
306
- // We deliberately look ONLY at the opening heading. Partial "reference
307
- // SILENT" annotations on individual SECTIONS of an otherwise-complete doc,
308
- // and trailing "developer verification checklist" sections, are healthy —
309
- // the previous whole-document scan wrongly skipped confirmed journeys that
310
- // merely carried such notes.
311
- const firstHeading = (markdown.match(/^#{1,6}\s+.+$/m) || [''])[0];
312
- return /\btodo\b|not covered by the reference|reference[-\s]silent|unconfirmed/i.test(firstHeading);
313
- }
314
- /**
315
- * True if a generated file is a testforge DEFERRED stub — a placeholder
316
- * written when AI generation was unavailable. It is NOT real coverage, so
317
- * per-file resume must RE-ATTEMPT it (unlike a real file, which is kept).
318
- */
319
- function isDeferredStub(content) {
320
- return content.includes('DEFERRED stub');
321
- }
322
- /** True if the journey's output dir already holds any deferred stub. */
323
- function hasDeferredStubs(testDir, ext) {
324
- let files;
325
- try {
326
- files = readdirSync(testDir);
327
- }
328
- catch {
329
- return false;
330
- }
331
- for (const f of files) {
332
- if (!f.endsWith(ext))
333
- continue;
334
- try {
335
- if (isDeferredStub(readFileSync(join(testDir, f), 'utf-8')))
336
- return true;
337
- }
338
- catch {
339
- /* ignore unreadable */
340
- }
341
- }
342
- return false;
343
- }
344
- /** Slugs recorded in a journey's existing plan cache (the previous run). */
345
- function readPlanSlugs(journey) {
346
- const p = planPath(journey);
347
- try {
348
- const c = JSON.parse(readFileSync(p, 'utf-8'));
349
- if (Array.isArray(c.files)) {
350
- return c.files.map((f) => f.slug).filter((s) => !!s);
351
- }
352
- }
353
- catch {
354
- /* no previous plan */
355
- }
356
- return [];
357
- }
358
- /**
359
- * F6 — replace, don't duplicate. When a journey is (re)generated and its
360
- * plan changed, the files for slugs that no longer exist are stale. Delete
361
- * them — but ONLY files that carry our marker, so we never touch a
362
- * developer's own tests. Prevents the twin-suite accumulation.
363
- */
364
- function cleanStaleFiles(testDir, journeyId, ext, flat, keepSlugs, dropSlugs) {
365
- const removed = [];
366
- const keep = new Set(keepSlugs);
367
- for (const slug of dropSlugs) {
368
- if (keep.has(slug))
369
- continue;
370
- const base = flat ? `${journeyId}-${slug}` : slug;
371
- const f = join(testDir, `${base}${ext}`);
372
- try {
373
- if (existsSync(f) && readFileSync(f, 'utf-8').includes(GEN_MARKER)) {
374
- rmSync(f);
375
- removed.push(`${base}${ext}`);
376
- }
377
- }
378
- catch {
379
- /* best-effort */
380
- }
381
- }
382
- return removed;
383
- }
384
- /**
385
- * Get a journey's file plan, cached by doc hash. Caching the plan keeps
386
- * the file slugs STABLE across runs, so per-file resume (skip-existing)
387
- * actually matches — and it saves the planner AI call on a resume. When
388
- * the doc changes, the hash changes and we re-plan.
389
- */
390
- function loadOrPlan(journey, docHash) {
391
- const p = planPath(journey);
392
- if (existsSync(p)) {
393
- try {
394
- const cached = JSON.parse(readFileSync(p, 'utf-8'));
395
- if (cached.hash === docHash && Array.isArray(cached.files) && cached.files.length) {
396
- return cached.files;
397
- }
398
- }
399
- catch {
400
- // corrupt plan cache — re-plan
401
- }
402
- }
403
- const files = planSuite(journey);
404
- try {
405
- mkdirSync(dirname(p), { recursive: true });
406
- writeFileSync(p, JSON.stringify({ hash: docHash, files }, null, 2), 'utf-8');
407
- }
408
- catch {
409
- // best-effort cache
410
- }
411
- return files;
412
- }
413
- /** Does this doc file / journey match a --journey selector? */
414
- function matchesJourney(file, journeyId, sel) {
415
- const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-');
416
- const n = norm(sel);
417
- const stem = file.replace(/\.md$/, '');
418
- return (journeyId === n ||
419
- norm(stem) === n ||
420
- norm(stem).endsWith(`-${n}`) ||
421
- norm(stem).split('/').pop() === n);
422
- }
423
170
  export async function runGenerate(docsDir, useAI = true, force = false, only, areaOnly, opts = {}) {
424
171
  const allFiles = findMarkdownFiles(docsDir);
425
172
  if (allFiles.length === 0) {
@@ -431,9 +178,7 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
431
178
  const seedExt = ext.endsWith('.js') ? '.seed.js' : '.seed.ts';
432
179
  const cache = loadCache(CACHE_PATH);
433
180
  const results = [];
434
- lastAiFailure = null;
435
- aiUnavailable = null;
436
- consecutiveAiFailures = 0;
181
+ resetAiSession();
437
182
  // Only journey docs are generated from. A file at the docs root, under a
438
183
  // "_reference/" folder, or without a "# Journey:" heading is reference
439
184
  // material — report it once and leave it alone rather than inventing a
@@ -542,8 +287,8 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
542
287
  if (useAI && (await confirmAiAlive()) && redoPrevious())
543
288
  continue;
544
289
  if (useAI &&
545
- !aiUnavailable &&
546
- consecutiveAiFailures === 0 &&
290
+ !aiUnavailableReason() &&
291
+ !lastAiFailureWasProcess() &&
547
292
  i > 0 &&
548
293
  stubbed.has(files[i - 1]) &&
549
294
  redoPrevious()) {
@@ -553,7 +298,7 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
553
298
  // journey would only become deferred stubs. Either wait for it to come back
554
299
  // (--wait-for-limit) or stop here; either way the stubs already written are
555
300
  // re-attempted automatically on the next run.
556
- if (aiUnavailable && useAI) {
301
+ if (aiUnavailableReason() && useAI) {
557
302
  if (waitForLimit && (await waitForAi(config))) {
558
303
  if (redoPrevious())
559
304
  continue;
@@ -857,7 +602,8 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
857
602
  ],
858
603
  });
859
604
  }
860
- if (notAttempted > 0 && aiUnavailable) {
605
+ const unavailable = aiUnavailableReason();
606
+ if (notAttempted > 0 && unavailable) {
861
607
  // One line, not one per journey: the operator needs the reason and the
862
608
  // count, then to re-run once the CLI is back.
863
609
  results.push({
@@ -868,7 +614,7 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
868
614
  status: 'skipped',
869
615
  seedStatus: 'none',
870
616
  warnings: [
871
- `${aiUnavailable}. Nothing was written for these; re-run \`testforge generate\` when the AI CLI is back — deferred stubs from this run are re-attempted automatically.`,
617
+ `${unavailable}. Nothing was written for these; re-run \`testforge generate\` when the AI CLI is back — deferred stubs from this run are re-attempted automatically.`,
872
618
  ],
873
619
  });
874
620
  }
@@ -876,49 +622,6 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
876
622
  writeCoverageManifest(results);
877
623
  return results;
878
624
  }
879
- /**
880
- * F8 — write an honest coverage manifest. For every generated file it
881
- * records real (has assertions) vs deferred (pending only), so a "green"
882
- * run is never mistaken for full coverage. Written to
883
- * test-artifacts/coverage.json and summarised to the console.
884
- */
885
- function writeCoverageManifest(results) {
886
- const journeys = results
887
- .filter((r) => r.testFiles.length)
888
- .map((r) => {
889
- const files = r.testFiles.map((t) => {
890
- let a = { activeTests: 0, pending: 0, assertions: 0, real: false };
891
- try {
892
- a = analyzeFile(readFileSync(t.path, 'utf-8'));
893
- }
894
- catch {
895
- /* file unreadable — count as empty */
896
- }
897
- return { file: t.path, status: t.status, ...a };
898
- });
899
- return {
900
- journey: r.journeyName,
901
- real: files.filter((f) => f.real).length,
902
- deferred: files.filter((f) => !f.real).length,
903
- files,
904
- };
905
- });
906
- if (!journeys.length)
907
- return;
908
- const totalReal = journeys.reduce((n, j) => n + j.real, 0);
909
- const totalDeferred = journeys.reduce((n, j) => n + j.deferred, 0);
910
- try {
911
- mkdirSync(STATE_DIR, { recursive: true });
912
- writeFileSync(COVERAGE_PATH, JSON.stringify({ real: totalReal, deferred: totalDeferred, journeys }, null, 2), 'utf-8');
913
- }
914
- catch {
915
- /* best-effort */
916
- }
917
- console.log(`\nCoverage: ${totalReal} real test file(s), ${totalDeferred} deferred/placeholder — see ${COVERAGE_PATH}`);
918
- if (totalDeferred) {
919
- console.log(` (deferred files are pending, NOT real coverage — regenerate or implement them.)`);
920
- }
921
- }
922
625
  /**
923
626
  * Write the per-area runner config.
924
627
  * - playwright: global-setup.js + playwright.config.js
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { execFileSync } from 'node:child_process';
18
18
  import { readFileSync, existsSync } from 'node:fs';
19
- import { loadConfig, splitCommand, withReadOnlyTools } from './config.js';
19
+ import { loadConfig, splitCommand, withReadOnlyTools } from '../project/config.js';
20
20
  import { cleanOutput } from './ai-generator.js';
21
21
  const MAP_PATH = 'testforge.md';
22
22
  /** Build the seed-generation prompt for one journey. */
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { execFileSync } from 'node:child_process';
18
18
  import { readFileSync, existsSync } from 'node:fs';
19
- import { loadConfig, splitCommand, withReadOnlyTools } from './config.js';
19
+ import { loadConfig, splitCommand, withReadOnlyTools } from '../project/config.js';
20
20
  const MAP_PATH = 'testforge.md';
21
21
  /** Build the planning prompt for one journey. */
22
22
  function buildPlanPrompt(journey, map) {
@@ -13,8 +13,8 @@
13
13
  */
14
14
  import { readFileSync, writeFileSync, existsSync, readdirSync, } from 'node:fs';
15
15
  import { join } from 'node:path';
16
- import { findMarkdownFiles } from './find-files.js';
17
- import { STATE_DIR, LEGACY_STATE_DIR } from './paths.js';
16
+ import { findMarkdownFiles } from '../find-files.js';
17
+ import { STATE_DIR, LEGACY_STATE_DIR } from '../paths.js';
18
18
  /** Pull env var names from a chunk of text. */
19
19
  function extractEnvVars(text) {
20
20
  const vars = new Set();
@@ -11,11 +11,11 @@
11
11
  * single stage; setup just chains them for a smooth first run.
12
12
  */
13
13
  import { existsSync } from 'node:fs';
14
- import { hashProjectSource, loadCache, saveCache } from './cache.js';
15
- import { CACHE_PATH } from './paths.js';
14
+ import { hashProjectSource, loadCache, saveCache } from '../docs/cache.js';
15
+ import { CACHE_PATH } from '../paths.js';
16
16
  import { runInit } from './init.js';
17
17
  import { runMap } from './map.js';
18
- import { runDiscover } from './discover.js';
18
+ import { runDiscover } from '../docs/discover.js';
19
19
  import { runEnvTemplate } from './env-template.js';
20
20
  export async function runSetup(srcDir, opts = {}) {
21
21
  // If the project already looks set up, don't redo it — point the user
@@ -10,12 +10,12 @@
10
10
  */
11
11
  import { existsSync } from 'node:fs';
12
12
  import { runMap } from './map.js';
13
- import { runDiscover } from './discover.js';
14
- import { runGenerate } from './run-generate.js';
15
- import { hashProjectSource, hashReference, loadCache, saveCache } from './cache.js';
16
- import { loadSourceChoice } from './source-of-truth.js';
13
+ import { runDiscover } from '../docs/discover.js';
14
+ import { runGenerate } from '../generate/run-generate.js';
15
+ import { hashProjectSource, hashReference, loadCache, saveCache } from '../docs/cache.js';
16
+ import { loadSourceChoice } from '../docs/source-of-truth.js';
17
17
  import { runEnvTemplate } from './env-template.js';
18
- import { CACHE_PATH } from './paths.js';
18
+ import { CACHE_PATH } from '../paths.js';
19
19
  const SOURCE_KEY = '__source__';
20
20
  const REFERENCE_KEY = '__reference__';
21
21
  export async function runUpdate(srcDir, opts = {}) {
@@ -15,7 +15,7 @@
15
15
  import { spawnSync } from 'node:child_process';
16
16
  import { existsSync } from 'node:fs';
17
17
  import { join, dirname, resolve, relative } from 'node:path';
18
- import { loadConfig, loadEnvFile, testDirForType, resolveFramework, } from './config.js';
18
+ import { loadConfig, loadEnvFile, testDirForType, resolveFramework, } from '../project/config.js';
19
19
  /** Nearest ancestor dir containing a package.json (the runner's cwd). */
20
20
  function packageRoot(dir) {
21
21
  let d = resolve(dir);
@@ -59,7 +59,25 @@ export function runTests(opts = {}) {
59
59
  if (!cmd) {
60
60
  if (framework === 'playwright') {
61
61
  const target = opts.journey ? `${rel}/${opts.journey}` : rel;
62
- cmd = `npx playwright test ${target}`;
62
+ // Name the config explicitly. It is written beside the AREA's tests
63
+ // (tests/Feature/playwright.config.js for tests/Feature/Web), not at
64
+ // the package root, so Playwright's own discovery finds nothing and
65
+ // silently falls back to defaults — no globalSetup, no reporters, and
66
+ // therefore no run published to the dashboard. Walk up from the test
67
+ // directory to the package root and take the first config found.
68
+ let cfgFlag = '';
69
+ for (let dir = rel;; dir = dirname(dir)) {
70
+ const hit = ['playwright.config.js', 'playwright.config.ts']
71
+ .map((f) => (dir === '.' ? f : join(dir, f)))
72
+ .find((f) => existsSync(join(root, f)));
73
+ if (hit) {
74
+ cfgFlag = ` --config=${hit}`;
75
+ break;
76
+ }
77
+ if (dir === '.' || dir === dirname(dir))
78
+ break;
79
+ }
80
+ cmd = `npx playwright test${cfgFlag} ${target}`;
63
81
  }
64
82
  else {
65
83
  cmd = opts.journey
@@ -67,6 +85,8 @@ export function runTests(opts = {}) {
67
85
  : `npx jest ${rel}`;
68
86
  }
69
87
  }
88
+ if (opts.passThrough?.length)
89
+ cmd += ` ${opts.passThrough.join(' ')}`;
70
90
  ranAnything = true;
71
91
  console.log(`\n=== ${area} (${framework}) ===`);
72
92
  console.log(`$ ${cmd} [cwd: ${root}]`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@activate-core/test-forge",
3
- "version": "0.18.7",
3
+ "version": "0.18.9",
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": {
@@ -13,7 +13,7 @@
13
13
  "README.md"
14
14
  ],
15
15
  "scripts": {
16
- "build": "tsc",
16
+ "build": "rm -rf dist && tsc",
17
17
  "dev": "tsc --watch"
18
18
  },
19
19
  "license": "MIT",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes