@activate-core/test-forge 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/ai-generator.js +22 -4
- package/dist/core/detect.js +133 -0
- package/dist/core/harness.js +12 -0
- package/dist/core/playwright-writer.js +53 -38
- package/dist/core/run-generate.js +158 -13
- package/dist/core/validate.js +80 -0
- package/package.json +1 -1
|
@@ -39,15 +39,19 @@ function runWithStdin(program, args, input) {
|
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
41
|
/** Build the prompt we send to the AI for one journey. */
|
|
42
|
-
export function buildPrompt(journey, map, testPath, focus, harnessPath) {
|
|
42
|
+
export function buildPrompt(journey, map, testPath, focus, harnessPath, transport) {
|
|
43
43
|
const config = loadConfig();
|
|
44
44
|
// Opinionated: testforge only generates Jest (api/backend) or Playwright
|
|
45
45
|
// (web/UI) tests — any other framework (e.g. a project's Cypress setup)
|
|
46
46
|
// is clamped to the right one for the journey. Single source of truth in
|
|
47
47
|
// resolveFramework, shared with the output-layout decision.
|
|
48
48
|
const isApi = resolveFramework(config, journey.area, journey.type) === 'jest';
|
|
49
|
+
// Do NOT assume an HTTP client here — the transport is detected from the
|
|
50
|
+
// project's real dependencies and passed in (Hapi/Fastify inject,
|
|
51
|
+
// supertest only if installed, or native http). Hardcoding supertest
|
|
52
|
+
// was what produced ~half the API suites that never loaded.
|
|
49
53
|
const framework = isApi
|
|
50
|
-
? 'Jest
|
|
54
|
+
? 'Jest for an API/backend test'
|
|
51
55
|
: 'Playwright for a web/UI test';
|
|
52
56
|
// Pending-test syntax differs by runner: Jest has test.todo/it.skip;
|
|
53
57
|
// Playwright has NEITHER (test.todo throws) — it uses test.fixme/skip.
|
|
@@ -107,6 +111,10 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath) {
|
|
|
107
111
|
`the behavior genuinely cannot be determined from the source — do not`,
|
|
108
112
|
`leave something pending that a quick read of the code would answer.`,
|
|
109
113
|
`Do NOT write or edit any file; output only the test code to stdout.`,
|
|
114
|
+
`EVERY runnable test MUST contain at least one real assertion. If you`,
|
|
115
|
+
`genuinely cannot assert an outcome, make the case pending (${pending})`,
|
|
116
|
+
`— never write a test body with no assertion, which passes without`,
|
|
117
|
+
`testing anything.`,
|
|
110
118
|
``,
|
|
111
119
|
isApi
|
|
112
120
|
? [
|
|
@@ -118,6 +126,13 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath) {
|
|
|
118
126
|
`duplicate exists, correct fields) using the database/ORM the`,
|
|
119
127
|
`project uses. Connect via the env var named in the map (e.g.`,
|
|
120
128
|
`process.env.DB_URL) — never hardcode a connection string.`,
|
|
129
|
+
`MOCKING: if the code calls an external service (e.g. an identity`,
|
|
130
|
+
`or payment API), mock it AT THE SEAM THE CODE ACTUALLY USES —`,
|
|
131
|
+
`read the source to see which client it imports (e.g. axios) and`,
|
|
132
|
+
`how it is called, then jest.mock that exact module so the mock`,
|
|
133
|
+
`truly intercepts. Do NOT mock a client the code doesn't use, and`,
|
|
134
|
+
`do NOT drive the server out-of-process while expecting an`,
|
|
135
|
+
`in-process mock to fire — they cannot both be true.`,
|
|
121
136
|
].join('\n')
|
|
122
137
|
: [
|
|
123
138
|
`Because this is a web journey, write Playwright tests that drive`,
|
|
@@ -160,6 +175,9 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath) {
|
|
|
160
175
|
``,
|
|
161
176
|
]
|
|
162
177
|
: []),
|
|
178
|
+
...(transport
|
|
179
|
+
? [transport, `Do NOT import any library that is not already a dependency of this project.`, ``]
|
|
180
|
+
: []),
|
|
163
181
|
...(harnessPath
|
|
164
182
|
? [
|
|
165
183
|
`SHARED HARNESS — a testkit already exists at:`,
|
|
@@ -229,9 +247,9 @@ export function generateWithAI(journey, testPath, focus) {
|
|
|
229
247
|
* journey's suite can be generated concurrently (each spawns its own
|
|
230
248
|
* `claude` process). Same prompt, same read-only exploration.
|
|
231
249
|
*/
|
|
232
|
-
export async function generateWithAIAsync(journey, testPath, focus, harnessPath) {
|
|
250
|
+
export async function generateWithAIAsync(journey, testPath, focus, harnessPath, transport) {
|
|
233
251
|
const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
|
|
234
|
-
const prompt = buildPrompt(journey, map, testPath, focus, harnessPath);
|
|
252
|
+
const prompt = buildPrompt(journey, map, testPath, focus, harnessPath, transport);
|
|
235
253
|
const { program, args } = withReadOnlyTools(splitCommand(loadConfig().aiCommand));
|
|
236
254
|
const stdout = await runWithStdin(program, args, prompt);
|
|
237
255
|
return cleanOutput(stdout);
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — project capability detection (dependency-aware, no guessing).
|
|
3
|
+
*
|
|
4
|
+
* Generated tests must fit the project they land in: they may only import
|
|
5
|
+
* libraries the project actually has, and backend suites must drive the
|
|
6
|
+
* server the way the project's stack supports (Hapi/Fastify inject,
|
|
7
|
+
* supertest, or a live HTTP call) — never by assuming a convention.
|
|
8
|
+
*
|
|
9
|
+
* Everything here is read from the real files (package.json + node_modules)
|
|
10
|
+
* walking up from the test location, so it works on ANY project, including
|
|
11
|
+
* a greenfield one with no existing tests.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
14
|
+
import { builtinModules } from 'node:module';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
/** Read a package.json's dependency names (all dependency fields). */
|
|
17
|
+
function depsOf(pkgPath) {
|
|
18
|
+
try {
|
|
19
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
20
|
+
const fields = [
|
|
21
|
+
'dependencies',
|
|
22
|
+
'devDependencies',
|
|
23
|
+
'peerDependencies',
|
|
24
|
+
'optionalDependencies',
|
|
25
|
+
];
|
|
26
|
+
const names = [];
|
|
27
|
+
for (const f of fields) {
|
|
28
|
+
const obj = pkg[f];
|
|
29
|
+
if (obj)
|
|
30
|
+
names.push(...Object.keys(obj));
|
|
31
|
+
}
|
|
32
|
+
return names;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Top-level entries of a node_modules dir, incl. scoped (@scope/pkg). */
|
|
39
|
+
function nodeModulesNames(nmDir) {
|
|
40
|
+
if (!existsSync(nmDir))
|
|
41
|
+
return [];
|
|
42
|
+
const out = [];
|
|
43
|
+
let entries;
|
|
44
|
+
try {
|
|
45
|
+
entries = readdirSync(nmDir);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
for (const e of entries) {
|
|
51
|
+
if (e.startsWith('.'))
|
|
52
|
+
continue;
|
|
53
|
+
if (e.startsWith('@')) {
|
|
54
|
+
try {
|
|
55
|
+
for (const sub of readdirSync(join(nmDir, e))) {
|
|
56
|
+
if (!sub.startsWith('.'))
|
|
57
|
+
out.push(`${e}/${sub}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* ignore */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
out.push(e);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The set of module specifiers a test at `fromDir` may import: every
|
|
72
|
+
* dependency declared in any package.json from `fromDir` up to the repo
|
|
73
|
+
* root, every package physically present in node_modules along the way,
|
|
74
|
+
* and all Node builtins. Union is intentional — being permissive here
|
|
75
|
+
* only risks NOT rejecting a real dep; the goal is to catch clearly
|
|
76
|
+
* absent libraries (e.g. supertest) without false-rejecting hoisted ones.
|
|
77
|
+
*/
|
|
78
|
+
export function installedModules(fromDir) {
|
|
79
|
+
const mods = new Set();
|
|
80
|
+
for (const b of builtinModules) {
|
|
81
|
+
mods.add(b);
|
|
82
|
+
mods.add(`node:${b}`);
|
|
83
|
+
}
|
|
84
|
+
let dir = fromDir;
|
|
85
|
+
for (let i = 0; i < 12; i++) {
|
|
86
|
+
const pkg = join(dir, 'package.json');
|
|
87
|
+
if (existsSync(pkg))
|
|
88
|
+
depsOf(pkg).forEach((d) => mods.add(d));
|
|
89
|
+
nodeModulesNames(join(dir, 'node_modules')).forEach((d) => mods.add(d));
|
|
90
|
+
const parent = dirname(dir);
|
|
91
|
+
if (parent === dir)
|
|
92
|
+
break;
|
|
93
|
+
dir = parent;
|
|
94
|
+
}
|
|
95
|
+
return mods;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* A concrete instruction for how a backend (Jest) suite should reach the
|
|
99
|
+
* server, based on what the project actually depends on — so the AI does
|
|
100
|
+
* not fall back to an uninstalled default (the supertest failure).
|
|
101
|
+
*/
|
|
102
|
+
export function transportHint(fromDir, framework) {
|
|
103
|
+
if (framework === 'playwright') {
|
|
104
|
+
return `This is a Playwright area: use the browser + the testkit's apiContext(request) for API calls. Do NOT use supertest.`;
|
|
105
|
+
}
|
|
106
|
+
const mods = installedModules(fromDir);
|
|
107
|
+
const has = (m) => mods.has(m);
|
|
108
|
+
if (has('@hapi/hapi') || has('hapi')) {
|
|
109
|
+
return [
|
|
110
|
+
`TRANSPORT: this is a Hapi backend. Drive the server IN-PROCESS with`,
|
|
111
|
+
`\`server.inject({ method, url, payload, headers })\` — the same way`,
|
|
112
|
+
`the project's own tests do. Do NOT use supertest (it is not a`,
|
|
113
|
+
`dependency and would run a separate process the in-process mocks`,
|
|
114
|
+
`can't intercept). Require the app's server/init from the real source.`,
|
|
115
|
+
].join('\n');
|
|
116
|
+
}
|
|
117
|
+
if (has('fastify')) {
|
|
118
|
+
return [
|
|
119
|
+
`TRANSPORT: this is a Fastify backend. Drive it IN-PROCESS with`,
|
|
120
|
+
`\`app.inject({ method, url, payload, headers })\`. Do NOT use supertest.`,
|
|
121
|
+
].join('\n');
|
|
122
|
+
}
|
|
123
|
+
if (has('supertest')) {
|
|
124
|
+
return `TRANSPORT: use supertest (it is installed) against the app's exported Express/Koa/HTTP handler.`;
|
|
125
|
+
}
|
|
126
|
+
// Express/Koa/etc. without supertest, or unknown: don't invent a dep.
|
|
127
|
+
return [
|
|
128
|
+
`TRANSPORT: do NOT import any HTTP-testing library that is not already`,
|
|
129
|
+
`a dependency of this project. Prefer the framework's own in-process`,
|
|
130
|
+
`injection if it has one; otherwise use Node's built-in http/fetch`,
|
|
131
|
+
`against process.env.BASE_URL. Never add a new dependency.`,
|
|
132
|
+
].join('\n');
|
|
133
|
+
}
|
package/dist/core/harness.js
CHANGED
|
@@ -102,6 +102,18 @@ function buildHarnessPrompt(harnessPath, framework) {
|
|
|
102
102
|
`exact names — the tests must run without any hand-pasted token.`,
|
|
103
103
|
``,
|
|
104
104
|
`This is a ${framework === 'jest' ? 'Jest/backend' : 'Playwright/UI'} area.`,
|
|
105
|
+
...(framework === 'jest'
|
|
106
|
+
? [
|
|
107
|
+
`Because this is a JEST/backend area, do NOT require or import`,
|
|
108
|
+
`'@playwright/test' anywhere (it is not installed in a backend`,
|
|
109
|
+
`package — a top-level require would break every suite that loads`,
|
|
110
|
+
`this testkit). OMIT the Playwright-only apiContext/login exports.`,
|
|
111
|
+
`Backend suites drive the server in-process via the framework's own`,
|
|
112
|
+
`inject (or an already-installed HTTP client) — never Playwright,`,
|
|
113
|
+
`and never a library this project has not installed.`,
|
|
114
|
+
``,
|
|
115
|
+
]
|
|
116
|
+
: []),
|
|
105
117
|
HARNESS_CONTRACT,
|
|
106
118
|
``,
|
|
107
119
|
`The file will be saved at EXACTLY: ${harnessPath}`,
|
|
@@ -1,47 +1,62 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* testforge —
|
|
2
|
+
* testforge — deferred-stub writer (fallback only).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* When AI generation is unavailable (usage limit / repeated invalid
|
|
5
|
+
* output), testforge still needs to leave SOMETHING at the file path so
|
|
6
|
+
* the journey is visibly accounted for. It must NEVER be a test that
|
|
7
|
+
* runs and passes with no assertions — a silent false-green is the worst
|
|
8
|
+
* QA outcome. So this writer emits PENDING placeholders only:
|
|
9
|
+
* - Jest → test.todo(...) (pending; no assertions; no import)
|
|
10
|
+
* - Playwright → test.fixme(...) (skipped; never counts as a pass)
|
|
8
11
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* @generated so it is easy to spot auto-created tests.
|
|
12
|
+
* It is also runner-aware: a Jest area never receives a
|
|
13
|
+
* `@playwright/test` import (which would break every suite that loads).
|
|
14
|
+
* A developer (or a later AI run) replaces these with real tests.
|
|
13
15
|
*/
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
const BANNER = [
|
|
17
|
+
`// @generated by testforge — DEFERRED stub (AI generation was unavailable).`,
|
|
18
|
+
`// These are PLACEHOLDERS, not real tests: they are pending so they do`,
|
|
19
|
+
`// NOT pass silently. Regenerate with AI once available, or implement`,
|
|
20
|
+
`// them by hand.`,
|
|
21
|
+
].join('\n');
|
|
22
|
+
/** One pending entry, phrased for the area's runner. */
|
|
23
|
+
function pendingLine(testCase, framework) {
|
|
24
|
+
const outcome = testCase.expect === 'pass' ? 'succeed' : 'be rejected';
|
|
25
|
+
const title = `${testCase.title} — TODO: implement (expected to ${outcome})`;
|
|
26
|
+
const safe = title.replace(/"/g, '\\"');
|
|
27
|
+
return framework === 'jest'
|
|
28
|
+
? ` test.todo("${safe}");`
|
|
29
|
+
: ` test.fixme("${safe}", async () => {});`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Build the full deferred-stub file for a journey, correct for the area's
|
|
33
|
+
* test runner. Every entry is pending, so the file contributes zero
|
|
34
|
+
* false-passing tests.
|
|
35
|
+
*/
|
|
36
|
+
export function writeDeferredStub(journey, cases, framework) {
|
|
37
|
+
const entries = (cases.length ? cases : [{ title: journey.name, expect: 'pass' }])
|
|
38
|
+
.map((c) => pendingLine(c, framework))
|
|
39
|
+
.join('\n');
|
|
40
|
+
if (framework === 'jest') {
|
|
41
|
+
// Jest globals — no import (a `@playwright/test` import here would
|
|
42
|
+
// break the whole suite in a backend package).
|
|
43
|
+
return [
|
|
44
|
+
BANNER,
|
|
45
|
+
``,
|
|
46
|
+
`describe(${JSON.stringify(journey.name)}, () => {`,
|
|
47
|
+
entries,
|
|
48
|
+
`});`,
|
|
49
|
+
``,
|
|
50
|
+
].join('\n');
|
|
51
|
+
}
|
|
16
52
|
return [
|
|
17
|
-
`import { test
|
|
53
|
+
`import { test } from "@playwright/test";`,
|
|
54
|
+
``,
|
|
55
|
+
BANNER,
|
|
18
56
|
``,
|
|
19
|
-
|
|
20
|
-
|
|
57
|
+
`test.describe(${JSON.stringify(journey.name)}, () => {`,
|
|
58
|
+
entries,
|
|
59
|
+
`});`,
|
|
21
60
|
``,
|
|
22
61
|
].join('\n');
|
|
23
62
|
}
|
|
24
|
-
/** Build a single Playwright test block from one test case. */
|
|
25
|
-
function testBlock(journey, testCase) {
|
|
26
|
-
const lines = [];
|
|
27
|
-
lines.push(` // @generated`);
|
|
28
|
-
lines.push(` test("${testCase.title}", async ({ page }) => {`);
|
|
29
|
-
lines.push(` await page.goto("${journey.route}");`);
|
|
30
|
-
lines.push(``);
|
|
31
|
-
lines.push(` // TODO: fill in the steps for this test.`);
|
|
32
|
-
if (testCase.expect === 'pass') {
|
|
33
|
-
lines.push(` // This case is expected to succeed.`);
|
|
34
|
-
lines.push(` // TODO: assert the success outcome (e.g. redirect or success message).`);
|
|
35
|
-
}
|
|
36
|
-
else {
|
|
37
|
-
lines.push(` // This case is expected to fail / be rejected.`);
|
|
38
|
-
lines.push(` // TODO: assert the error outcome (e.g. validation message shown).`);
|
|
39
|
-
}
|
|
40
|
-
lines.push(` });`);
|
|
41
|
-
return lines.join('\n');
|
|
42
|
-
}
|
|
43
|
-
/** Build the full Playwright test file content for a journey. */
|
|
44
|
-
export function writePlaywrightFile(journey, cases) {
|
|
45
|
-
const blocks = cases.map((c) => testBlock(journey, c)).join('\n\n');
|
|
46
|
-
return header(journey) + blocks + `\n\n});\n`;
|
|
47
|
-
}
|
|
@@ -16,15 +16,16 @@
|
|
|
16
16
|
* so tests/seeds land where the project expects them. Defaults to
|
|
17
17
|
* generated-tests/ when not configured.
|
|
18
18
|
*/
|
|
19
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, } from 'node:fs';
|
|
19
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, } from 'node:fs';
|
|
20
20
|
import { join, dirname } from 'node:path';
|
|
21
21
|
import { parseJourney } from './parser.js';
|
|
22
22
|
import { generateTestCases } from './generator.js';
|
|
23
|
-
import {
|
|
23
|
+
import { writeDeferredStub } from './playwright-writer.js';
|
|
24
24
|
import { generateWithAI, generateWithAIAsync } from './ai-generator.js';
|
|
25
25
|
import { generateSeed } from './seed-generator.js';
|
|
26
26
|
import { planSuite } from './suite-planner.js';
|
|
27
|
-
import { isSyntaxValid } from './validate.js';
|
|
27
|
+
import { isSyntaxValid, checkTestGates, analyzeFile } from './validate.js';
|
|
28
|
+
import { installedModules, transportHint } from './detect.js';
|
|
28
29
|
import { harnessInfo, generateHarnessAsync, generateHarness, playwrightConfig, globalSetupContent, } from './harness.js';
|
|
29
30
|
import { hashContent, loadCache, saveCache } from './cache.js';
|
|
30
31
|
import { findMarkdownFiles } from './find-files.js';
|
|
@@ -55,7 +56,7 @@ async function pool(items, limit, fn) {
|
|
|
55
56
|
return results;
|
|
56
57
|
}
|
|
57
58
|
/** Async version of generateValidated (for concurrent per-file gen). */
|
|
58
|
-
async function generateValidatedAsync(generate, ext) {
|
|
59
|
+
async function generateValidatedAsync(generate, ext, gate) {
|
|
59
60
|
let content = '';
|
|
60
61
|
for (let attempt = 0; attempt < MAX_GEN_ATTEMPTS; attempt++) {
|
|
61
62
|
try {
|
|
@@ -64,7 +65,7 @@ async function generateValidatedAsync(generate, ext) {
|
|
|
64
65
|
catch {
|
|
65
66
|
continue;
|
|
66
67
|
}
|
|
67
|
-
if (isSyntaxValid(content, ext))
|
|
68
|
+
if (isSyntaxValid(content, ext) && !(gate && gate(content)))
|
|
68
69
|
return content;
|
|
69
70
|
}
|
|
70
71
|
return null;
|
|
@@ -74,7 +75,7 @@ async function generateValidatedAsync(generate, ext) {
|
|
|
74
75
|
* couple of times on malformed results. Returns the valid content, or
|
|
75
76
|
* null if every attempt failed to produce parseable code.
|
|
76
77
|
*/
|
|
77
|
-
function generateValidated(generate, ext) {
|
|
78
|
+
function generateValidated(generate, ext, gate) {
|
|
78
79
|
let content = '';
|
|
79
80
|
for (let attempt = 0; attempt < MAX_GEN_ATTEMPTS; attempt++) {
|
|
80
81
|
try {
|
|
@@ -84,7 +85,7 @@ function generateValidated(generate, ext) {
|
|
|
84
85
|
// generation (or output cleaning) threw — retry
|
|
85
86
|
continue;
|
|
86
87
|
}
|
|
87
|
-
if (isSyntaxValid(content, ext)) {
|
|
88
|
+
if (isSyntaxValid(content, ext) && !(gate && gate(content))) {
|
|
88
89
|
return content;
|
|
89
90
|
}
|
|
90
91
|
}
|
|
@@ -95,16 +96,18 @@ function generateValidated(generate, ext) {
|
|
|
95
96
|
* syntax gate + retry) and falls back to the deterministic basic
|
|
96
97
|
* generator if AI output can't be made valid.
|
|
97
98
|
*/
|
|
98
|
-
function buildTest(journey, useAI, ext, testPath, focus) {
|
|
99
|
+
function buildTest(journey, useAI, ext, testPath, framework, focus) {
|
|
99
100
|
if (useAI) {
|
|
100
101
|
const ai = generateValidated(() => generateWithAI(journey, testPath, focus), ext);
|
|
101
102
|
if (ai !== null) {
|
|
102
103
|
return { content: ai, status: 'ai' };
|
|
103
104
|
}
|
|
104
|
-
// fall through to the
|
|
105
|
+
// fall through to the deferred stub
|
|
105
106
|
}
|
|
107
|
+
// NOT a runnable test: pending placeholders only, correct for the
|
|
108
|
+
// runner, so nothing ever passes silently.
|
|
106
109
|
const cases = generateTestCases(journey);
|
|
107
|
-
return { content:
|
|
110
|
+
return { content: writeDeferredStub(journey, cases, framework), status: 'basic' };
|
|
108
111
|
}
|
|
109
112
|
/**
|
|
110
113
|
* Turn a journey id into the alternate naming conventions we might
|
|
@@ -188,6 +191,60 @@ export async function runHarness(only) {
|
|
|
188
191
|
return written;
|
|
189
192
|
}
|
|
190
193
|
const PLAN_DIR = '.testforge/plans';
|
|
194
|
+
/** Marker every testforge-written test carries, so stale-file cleanup
|
|
195
|
+
* (F6) only ever removes OUR files, never hand-authored ones. */
|
|
196
|
+
const GEN_MARKER = '@generated by testforge';
|
|
197
|
+
/**
|
|
198
|
+
* F9 — a journey doc that is still an unconfirmed stub. Deep-generating
|
|
199
|
+
* confident assertions from it produces UNVALIDATED tests, so we skip it
|
|
200
|
+
* and tell the user to confirm the spec first.
|
|
201
|
+
*/
|
|
202
|
+
function isStubDoc(markdown) {
|
|
203
|
+
return (/^#.*\btodo\b/im.test(markdown) ||
|
|
204
|
+
/not covered by the reference/i.test(markdown) ||
|
|
205
|
+
/confirm (the )?expected behaviou?r/i.test(markdown) ||
|
|
206
|
+
/todo:\s*not covered/i.test(markdown));
|
|
207
|
+
}
|
|
208
|
+
/** Slugs recorded in a journey's existing plan cache (the previous run). */
|
|
209
|
+
function readPlanSlugs(journey) {
|
|
210
|
+
const p = join(PLAN_DIR, `${journey.area}__${journey.id}.json`);
|
|
211
|
+
try {
|
|
212
|
+
const c = JSON.parse(readFileSync(p, 'utf-8'));
|
|
213
|
+
if (Array.isArray(c.files)) {
|
|
214
|
+
return c.files.map((f) => f.slug).filter((s) => !!s);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
/* no previous plan */
|
|
219
|
+
}
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* F6 — replace, don't duplicate. When a journey is (re)generated and its
|
|
224
|
+
* plan changed, the files for slugs that no longer exist are stale. Delete
|
|
225
|
+
* them — but ONLY files that carry our marker, so we never touch a
|
|
226
|
+
* developer's own tests. Prevents the twin-suite accumulation.
|
|
227
|
+
*/
|
|
228
|
+
function cleanStaleFiles(testDir, journeyId, ext, flat, keepSlugs, dropSlugs) {
|
|
229
|
+
const removed = [];
|
|
230
|
+
const keep = new Set(keepSlugs);
|
|
231
|
+
for (const slug of dropSlugs) {
|
|
232
|
+
if (keep.has(slug))
|
|
233
|
+
continue;
|
|
234
|
+
const base = flat ? `${journeyId}-${slug}` : slug;
|
|
235
|
+
const f = join(testDir, `${base}${ext}`);
|
|
236
|
+
try {
|
|
237
|
+
if (existsSync(f) && readFileSync(f, 'utf-8').includes(GEN_MARKER)) {
|
|
238
|
+
rmSync(f);
|
|
239
|
+
removed.push(`${base}${ext}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
/* best-effort */
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return removed;
|
|
247
|
+
}
|
|
191
248
|
/**
|
|
192
249
|
* Get a journey's file plan, cached by doc hash. Caching the plan keeps
|
|
193
250
|
* the file slugs STABLE across runs, so per-file resume (skip-existing)
|
|
@@ -304,6 +361,12 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
304
361
|
const flat = framework === 'jest';
|
|
305
362
|
const testDir = flat ? areaDir : join(areaDir, journey.id);
|
|
306
363
|
const seedPath = join(seedDir, `${journey.id}${seedExt}`);
|
|
364
|
+
// Dependency-aware guardrails (F1): what this area may import, and how
|
|
365
|
+
// it should reach the server — both read from the real project, so the
|
|
366
|
+
// AI can't fall back to an uninstalled convention (e.g. supertest).
|
|
367
|
+
const allowedModules = installedModules(testDir);
|
|
368
|
+
const transport = transportHint(testDir, framework);
|
|
369
|
+
const testGate = (content) => checkTestGates(content, ext, { framework, allowedModules });
|
|
307
370
|
// Skip if unchanged.
|
|
308
371
|
if (!force && cache[file] === hash) {
|
|
309
372
|
results.push({
|
|
@@ -320,6 +383,22 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
320
383
|
const collision = collisionWarning(areaDir, journey.id, ext);
|
|
321
384
|
if (collision)
|
|
322
385
|
warnings.push(collision);
|
|
386
|
+
// F9 — don't deep-generate from an unconfirmed TODO/stub doc: the
|
|
387
|
+
// assertions would be guesses. Skip it and ask for a confirmed spec.
|
|
388
|
+
if (isStubDoc(markdown)) {
|
|
389
|
+
results.push({
|
|
390
|
+
journeyName: journey.name,
|
|
391
|
+
testDir,
|
|
392
|
+
testFiles: [],
|
|
393
|
+
seedPath,
|
|
394
|
+
status: 'skipped',
|
|
395
|
+
seedStatus: 'none',
|
|
396
|
+
warnings: [
|
|
397
|
+
'doc is an unconfirmed TODO/stub — confirm the expected behaviour before generating (skipped to avoid unvalidated tests)',
|
|
398
|
+
],
|
|
399
|
+
});
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
323
402
|
// Ensure this area's shared harness exists (once), so every file
|
|
324
403
|
// imports the same testkit instead of inventing its own.
|
|
325
404
|
const harnessPath = useAI
|
|
@@ -330,9 +409,17 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
330
409
|
}
|
|
331
410
|
// 1. Plan the suite (deep mode), then generate one focused file per
|
|
332
411
|
// concern. In basic mode there is no AI planner — one file only.
|
|
412
|
+
// Capture the PREVIOUS plan's slugs first so we can clean up any
|
|
413
|
+
// files whose slug no longer exists (F6 replace-don't-duplicate).
|
|
414
|
+
const prevSlugs = useAI ? readPlanSlugs(journey) : [];
|
|
333
415
|
const planned = useAI
|
|
334
416
|
? loadOrPlan(journey, hash)
|
|
335
417
|
: [{ slug: journey.id, title: journey.name, covers: '' }];
|
|
418
|
+
const plannedSlugs = planned.map((p) => p.slug);
|
|
419
|
+
const staleRemoved = cleanStaleFiles(testDir, journey.id, ext, flat, plannedSlugs, prevSlugs);
|
|
420
|
+
if (staleRemoved.length) {
|
|
421
|
+
warnings.push(`removed ${staleRemoved.length} stale file(s) from a previous plan: ${staleRemoved.join(', ')}`);
|
|
422
|
+
}
|
|
336
423
|
// Generate all focused files CONCURRENTLY (bounded pool). Each AI
|
|
337
424
|
// call is independent, so a 15-file journey finishes in ~4 waves
|
|
338
425
|
// instead of 15 serial calls.
|
|
@@ -359,22 +446,27 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
359
446
|
let content;
|
|
360
447
|
let status;
|
|
361
448
|
if (useAI) {
|
|
362
|
-
const ai = await generateValidatedAsync(() => generateWithAIAsync(journey, filePath, { title: pf.title, covers: pf.covers }, harnessPath), ext);
|
|
449
|
+
const ai = await generateValidatedAsync(() => generateWithAIAsync(journey, filePath, { title: pf.title, covers: pf.covers }, harnessPath, transport), ext, testGate);
|
|
363
450
|
if (ai !== null) {
|
|
364
451
|
content = ai;
|
|
365
452
|
status = 'ai';
|
|
366
453
|
}
|
|
367
454
|
else {
|
|
368
|
-
const built = buildTest(journey, false, ext, filePath);
|
|
455
|
+
const built = buildTest(journey, false, ext, filePath, framework);
|
|
369
456
|
content = built.content;
|
|
370
457
|
status = 'basic';
|
|
371
458
|
}
|
|
372
459
|
}
|
|
373
460
|
else {
|
|
374
|
-
const built = buildTest(journey, false, ext, filePath);
|
|
461
|
+
const built = buildTest(journey, false, ext, filePath, framework);
|
|
375
462
|
content = built.content;
|
|
376
463
|
status = 'basic';
|
|
377
464
|
}
|
|
465
|
+
// Stamp our marker (F6) so stale-file cleanup can recognise OUR
|
|
466
|
+
// files later without ever touching a developer's own tests.
|
|
467
|
+
if (!content.includes(GEN_MARKER)) {
|
|
468
|
+
content = `// ${GEN_MARKER}\n${content}`;
|
|
469
|
+
}
|
|
378
470
|
// Write IMMEDIATELY (not batched at the end of the journey), so an
|
|
379
471
|
// interruption preserves every file generated so far.
|
|
380
472
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
@@ -389,6 +481,15 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
389
481
|
const anyAI = testFiles.some((t) => t.status === 'ai');
|
|
390
482
|
const anyBasic = testFiles.some((t) => t.status === 'basic');
|
|
391
483
|
const status = anyAI || !anyBasic ? 'ai' : 'basic';
|
|
484
|
+
// F10 — complete-generation honesty: any file that fell back to a
|
|
485
|
+
// deferred stub is NOT real coverage. Surface it clearly (and the
|
|
486
|
+
// journey stays uncached below, so a later run retries it with AI).
|
|
487
|
+
const deferred = testFiles.filter((t) => t.status === 'basic');
|
|
488
|
+
if (useAI && deferred.length) {
|
|
489
|
+
warnings.push(`${deferred.length} file(s) could not be generated with AI and were left as DEFERRED stubs (pending, not real coverage): ${deferred
|
|
490
|
+
.map((t) => t.slug)
|
|
491
|
+
.join(', ')}`);
|
|
492
|
+
}
|
|
392
493
|
// 2. Seed file (only when using AI — basic mode has no seed concept).
|
|
393
494
|
let seedStatus = 'none';
|
|
394
495
|
if (useAI) {
|
|
@@ -433,5 +534,49 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
433
534
|
});
|
|
434
535
|
}
|
|
435
536
|
saveCache(CACHE_PATH, cache);
|
|
537
|
+
writeCoverageManifest(results);
|
|
436
538
|
return results;
|
|
437
539
|
}
|
|
540
|
+
/**
|
|
541
|
+
* F8 — write an honest coverage manifest. For every generated file it
|
|
542
|
+
* records real (has assertions) vs deferred (pending only), so a "green"
|
|
543
|
+
* run is never mistaken for full coverage. Written to
|
|
544
|
+
* .testforge/coverage.json and summarised to the console.
|
|
545
|
+
*/
|
|
546
|
+
function writeCoverageManifest(results) {
|
|
547
|
+
const journeys = results
|
|
548
|
+
.filter((r) => r.testFiles.length)
|
|
549
|
+
.map((r) => {
|
|
550
|
+
const files = r.testFiles.map((t) => {
|
|
551
|
+
let a = { activeTests: 0, pending: 0, assertions: 0, real: false };
|
|
552
|
+
try {
|
|
553
|
+
a = analyzeFile(readFileSync(t.path, 'utf-8'));
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
/* file unreadable — count as empty */
|
|
557
|
+
}
|
|
558
|
+
return { file: t.path, status: t.status, ...a };
|
|
559
|
+
});
|
|
560
|
+
return {
|
|
561
|
+
journey: r.journeyName,
|
|
562
|
+
real: files.filter((f) => f.real).length,
|
|
563
|
+
deferred: files.filter((f) => !f.real).length,
|
|
564
|
+
files,
|
|
565
|
+
};
|
|
566
|
+
});
|
|
567
|
+
if (!journeys.length)
|
|
568
|
+
return;
|
|
569
|
+
const totalReal = journeys.reduce((n, j) => n + j.real, 0);
|
|
570
|
+
const totalDeferred = journeys.reduce((n, j) => n + j.deferred, 0);
|
|
571
|
+
try {
|
|
572
|
+
mkdirSync('.testforge', { recursive: true });
|
|
573
|
+
writeFileSync(join('.testforge', 'coverage.json'), JSON.stringify({ real: totalReal, deferred: totalDeferred, journeys }, null, 2), 'utf-8');
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
/* best-effort */
|
|
577
|
+
}
|
|
578
|
+
console.log(`\nCoverage: ${totalReal} real test file(s), ${totalDeferred} deferred/placeholder — see .testforge/coverage.json`);
|
|
579
|
+
if (totalDeferred) {
|
|
580
|
+
console.log(` (deferred files are pending, NOT real coverage — regenerate or implement them.)`);
|
|
581
|
+
}
|
|
582
|
+
}
|
package/dist/core/validate.js
CHANGED
|
@@ -20,6 +20,86 @@ import { execFileSync } from 'node:child_process';
|
|
|
20
20
|
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { tmpdir } from 'node:os';
|
|
23
|
+
/**
|
|
24
|
+
* Semantic gate for a generated test file. Returns a human-readable
|
|
25
|
+
* reason string if the content violates a guardrail, or null if it is
|
|
26
|
+
* clean. Runs AFTER syntax validation.
|
|
27
|
+
*/
|
|
28
|
+
export function checkTestGates(content, _ext, ctx) {
|
|
29
|
+
// F3 — runner correctness: a Jest/backend area must never pull in
|
|
30
|
+
// Playwright (not installed there; a top-level import breaks the suite).
|
|
31
|
+
if (ctx.framework === 'jest' && /@playwright\/test/.test(content)) {
|
|
32
|
+
return "jest area must not import '@playwright/test'";
|
|
33
|
+
}
|
|
34
|
+
// F4 — goto guard: `page.goto(...)` must take a real route/URL, not a
|
|
35
|
+
// pasted description. Literal args containing whitespace are prose.
|
|
36
|
+
const gotoRe = /\.goto\(\s*(['"])([\s\S]*?)\1/g;
|
|
37
|
+
let m;
|
|
38
|
+
while ((m = gotoRe.exec(content)) !== null) {
|
|
39
|
+
if (/\s/.test(m[2])) {
|
|
40
|
+
return `goto() argument looks like prose, not a URL/path: "${m[2].slice(0, 40)}…"`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// F2 — no silent false-green: a file with RUNNABLE tests must assert
|
|
44
|
+
// something. Pending placeholders (test.todo/skip/fixme) are exempt —
|
|
45
|
+
// they don't count as passing. This is what stops an empty-but-green
|
|
46
|
+
// file (the worst QA outcome) from ever being written.
|
|
47
|
+
const activeTests = (content.match(/\b(?:it|test)\s*\(/g) || []).length +
|
|
48
|
+
(content.match(/\b(?:it|test)\.(?:each|concurrent)\b/g) || []).length;
|
|
49
|
+
const assertions = (content.match(/\bexpect\s*\(/g) || []).length +
|
|
50
|
+
(content.match(/\bassert\b/g) || []).length;
|
|
51
|
+
if (activeTests > 0 && assertions === 0) {
|
|
52
|
+
return 'has runnable test(s) but no assertions — would pass without testing anything';
|
|
53
|
+
}
|
|
54
|
+
// F1 — dependency awareness: reject imports/requires of a module the
|
|
55
|
+
// project can't resolve (bare specifier not in allowedModules).
|
|
56
|
+
if (ctx.allowedModules) {
|
|
57
|
+
for (const mod of importedModules(content)) {
|
|
58
|
+
const top = topLevelModule(mod);
|
|
59
|
+
if (!ctx.allowedModules.has(top)) {
|
|
60
|
+
return `imports '${mod}' which the project does not have installed`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* F8 — lightweight coverage stats for one generated file, so a manifest
|
|
68
|
+
* can tell REAL coverage from deferred placeholders (a green run is never
|
|
69
|
+
* mistaken for full coverage).
|
|
70
|
+
*/
|
|
71
|
+
export function analyzeFile(content) {
|
|
72
|
+
const activeTests = (content.match(/\b(?:it|test)\s*\(/g) || []).length +
|
|
73
|
+
(content.match(/\b(?:it|test)\.(?:each|concurrent)\b/g) || []).length;
|
|
74
|
+
const pending = (content.match(/\b(?:it|test|describe)\.(?:todo|skip|fixme)\b/g) || []).length;
|
|
75
|
+
const assertions = (content.match(/\bexpect\s*\(/g) || []).length;
|
|
76
|
+
return { activeTests, pending, assertions, real: activeTests > 0 && assertions > 0 };
|
|
77
|
+
}
|
|
78
|
+
/** Extract bare module specifiers from import/require (skips relative). */
|
|
79
|
+
function importedModules(content) {
|
|
80
|
+
const mods = new Set();
|
|
81
|
+
const patterns = [
|
|
82
|
+
/\bimport\b[^'"]*?from\s*(['"])([^'"]+)\1/g,
|
|
83
|
+
/\bimport\s*(['"])([^'"]+)\1/g,
|
|
84
|
+
/\brequire\(\s*(['"])([^'"]+)\1\s*\)/g,
|
|
85
|
+
];
|
|
86
|
+
for (const re of patterns) {
|
|
87
|
+
let m;
|
|
88
|
+
while ((m = re.exec(content)) !== null) {
|
|
89
|
+
const spec = m[2];
|
|
90
|
+
if (!spec.startsWith('.') && !spec.startsWith('/'))
|
|
91
|
+
mods.add(spec);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [...mods];
|
|
95
|
+
}
|
|
96
|
+
/** `@scope/pkg/sub` -> `@scope/pkg`; `pkg/sub` -> `pkg`; `node:fs` -> `node:fs`. */
|
|
97
|
+
function topLevelModule(spec) {
|
|
98
|
+
if (spec.startsWith('node:'))
|
|
99
|
+
return spec;
|
|
100
|
+
const parts = spec.split('/');
|
|
101
|
+
return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
|
|
102
|
+
}
|
|
23
103
|
/** True if `content` parses (or, for TS, passes a structural check). */
|
|
24
104
|
export function isSyntaxValid(content, ext) {
|
|
25
105
|
if (!content.trim())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@activate-core/test-forge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "AI-powered QA automation. Scans a project, drafts journey docs, and generates seeded Jest/Playwright tests. Works in any project, with any AI CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|