@activate-core/test-forge 0.18.8 → 0.18.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +10 -10
- package/dist/core/{cache.js → docs/cache.js} +1 -1
- package/dist/core/{discover.js → docs/discover.js} +2 -2
- package/dist/core/{grounding.js → docs/grounding.js} +2 -2
- package/dist/core/{reference.js → docs/reference.js} +1 -1
- package/dist/core/{source-of-truth.js → docs/source-of-truth.js} +2 -2
- package/dist/core/gates/coverage.js +54 -0
- package/dist/core/{ai-generator.js → generate/ai-generator.js} +1 -1
- package/dist/core/generate/ai-session.js +120 -0
- package/dist/core/{harness.js → generate/harness.js} +1 -1
- package/dist/core/generate/plan-cache.js +181 -0
- package/dist/core/{run-generate.js → generate/run-generate.js} +23 -320
- package/dist/core/{seed-generator.js → generate/seed-generator.js} +1 -1
- package/dist/core/{suite-planner.js → generate/suite-planner.js} +1 -1
- package/dist/core/{env-template.js → project/env-template.js} +2 -2
- package/dist/core/{setup.js → project/setup.js} +3 -3
- package/dist/core/{update.js → project/update.js} +5 -5
- package/dist/core/{run-tests.js → run/run-tests.js} +1 -1
- package/package.json +2 -2
- /package/dist/core/{doc-guidance.js → docs/doc-guidance.js} +0 -0
- /package/dist/core/{parser.js → docs/parser.js} +0 -0
- /package/dist/core/{lint.js → gates/lint.js} +0 -0
- /package/dist/core/{validate.js → gates/validate.js} +0 -0
- /package/dist/core/{generator.js → generate/generator.js} +0 -0
- /package/dist/core/{playwright-writer.js → generate/playwright-writer.js} +0 -0
- /package/dist/core/{security-payloads.js → generate/security-payloads.js} +0 -0
- /package/dist/core/{config.js → project/config.js} +0 -0
- /package/dist/core/{detect.js → project/detect.js} +0 -0
- /package/dist/core/{init.js → project/init.js} +0 -0
- /package/dist/core/{map.js → project/map.js} +0 -0
- /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');
|
|
@@ -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 '
|
|
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 '
|
|
21
|
+
import { loadConfig, splitCommand, withEditTools } from '../project/config.js';
|
|
22
22
|
import { DEEP_DOC_GUIDANCE } from './doc-guidance.js';
|
|
23
|
-
import { promptConfirm } from '
|
|
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 '
|
|
23
|
+
import { loadConfig, splitCommand, withReadOnlyTools } from '../project/config.js';
|
|
24
24
|
import { hashProjectSource } from './cache.js';
|
|
25
|
-
import { STATE_DIR } from '
|
|
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 '
|
|
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 '
|
|
25
|
-
import { SOURCE_PATH } from '
|
|
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 '
|
|
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 '
|
|
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
|
/**
|
|
@@ -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,
|
|
19
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, } from 'node:fs';
|
|
20
20
|
import { join, dirname, basename, extname } from 'node:path';
|
|
21
|
-
import { CACHE_PATH
|
|
22
|
-
import { lintGenerated } from '
|
|
23
|
-
import { parseJourney, notAJourneyDoc } from '
|
|
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,
|
|
27
|
-
import { groundJourney } from '
|
|
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 {
|
|
30
|
-
import {
|
|
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 '
|
|
34
|
-
import { findMarkdownFiles } from '
|
|
35
|
-
import { loadConfig, testDirForType, resolveFramework } from '
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
!
|
|
546
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
`${
|
|
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 '
|
|
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 '
|
|
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 '
|
|
17
|
-
import { STATE_DIR, LEGACY_STATE_DIR } from '
|
|
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 '
|
|
15
|
-
import { CACHE_PATH } from '
|
|
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 '
|
|
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 '
|
|
14
|
-
import { runGenerate } from '
|
|
15
|
-
import { hashProjectSource, hashReference, loadCache, saveCache } from '
|
|
16
|
-
import { loadSourceChoice } from '
|
|
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 '
|
|
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 '
|
|
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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@activate-core/test-forge",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.10",
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|