@activate-core/test-forge 0.18.10 → 0.19.1
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 +6 -0
- package/dist/core/docs/grounding.js +36 -2
- package/dist/core/docs/observe.js +223 -0
- package/dist/core/docs/schema.js +136 -0
- package/dist/core/gates/coverage.js +20 -1
- package/dist/core/generate/ai-generator.js +66 -1
- package/dist/core/generate/run-generate.js +63 -3
- package/dist/core/generate/verify.js +108 -0
- package/dist/core/run/run-tests.js +61 -31
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -78,6 +78,7 @@ function printUsage() {
|
|
|
78
78
|
console.log(' testforge generate --basic Use the basic generator only');
|
|
79
79
|
console.log(' testforge generate --force Regenerate everything');
|
|
80
80
|
console.log(' testforge generate --no-wait Stop when the AI hits its usage limit (default: wait for the reset and resume)');
|
|
81
|
+
console.log(' testforge generate --no-verify Skip running each journey once after generating (default: run and repair)');
|
|
81
82
|
console.log(' testforge generate --journey <name> Generate just ONE journey');
|
|
82
83
|
console.log(' testforge generate --area <area> Generate a whole area (e.g. api)');
|
|
83
84
|
console.log(' testforge discover Draft journey docs (asks: map or reference)');
|
|
@@ -154,6 +155,11 @@ async function main() {
|
|
|
154
155
|
: process.argv.includes('--wait-for-limit')
|
|
155
156
|
? true
|
|
156
157
|
: undefined, // undefined -> config.waitForLimit (default on)
|
|
158
|
+
verify: process.argv.includes('--no-verify')
|
|
159
|
+
? false
|
|
160
|
+
: process.argv.includes('--verify')
|
|
161
|
+
? true
|
|
162
|
+
: undefined, // undefined -> config.verify (default on)
|
|
157
163
|
});
|
|
158
164
|
if (results.length === 0) {
|
|
159
165
|
console.log('No journey files found in docs/. Add a .md file and try again.');
|
|
@@ -23,7 +23,19 @@ import { dirname, join } from 'node:path';
|
|
|
23
23
|
import { loadConfig, splitCommand, withReadOnlyTools } from '../project/config.js';
|
|
24
24
|
import { hashProjectSource } from './cache.js';
|
|
25
25
|
import { STATE_DIR } from '../paths.js';
|
|
26
|
+
import { loadEnvFile } from '../project/config.js';
|
|
27
|
+
import { originsFrom, pathsFrom, observeApp } from './observe.js';
|
|
28
|
+
import { observeSchema } from './schema.js';
|
|
26
29
|
const MAP_PATH = 'testforge.md';
|
|
30
|
+
/**
|
|
31
|
+
* Bumped whenever the SHAPE of a facts block changes.
|
|
32
|
+
*
|
|
33
|
+
* The cache is keyed on the document and the source, neither of which moves
|
|
34
|
+
* when testforge itself gains a new kind of fact. Without this, a project
|
|
35
|
+
* that upgrades keeps serving the facts it cached under the old version —
|
|
36
|
+
* so the new facts never reach a single prompt and the upgrade does nothing.
|
|
37
|
+
*/
|
|
38
|
+
const FACTS_VERSION = 2;
|
|
27
39
|
/** Where a journey's grounded facts live, mirroring the plan cache layout. */
|
|
28
40
|
function groundingPath(journey) {
|
|
29
41
|
const area = (journey.area || 'misc').replace(/[^a-z0-9-]/gi, '-');
|
|
@@ -105,7 +117,7 @@ function buildGroundingPrompt(journey, map, srcDir) {
|
|
|
105
117
|
* Never throws. If the AI call fails we return null and the caller falls back
|
|
106
118
|
* to telling each file to read the source itself — slower, but still correct.
|
|
107
119
|
*/
|
|
108
|
-
export function groundJourney(journey, docHash, srcDir = 'src') {
|
|
120
|
+
export async function groundJourney(journey, docHash, srcDir = 'src', markdown = '') {
|
|
109
121
|
const path = groundingPath(journey);
|
|
110
122
|
const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
|
|
111
123
|
let sourceHash = '';
|
|
@@ -115,7 +127,7 @@ export function groundJourney(journey, docHash, srcDir = 'src') {
|
|
|
115
127
|
catch {
|
|
116
128
|
// unreadable source tree — fall back to keying on the doc alone
|
|
117
129
|
}
|
|
118
|
-
const key =
|
|
130
|
+
const key = `v${FACTS_VERSION}:${docHash}:${sourceHash}`;
|
|
119
131
|
if (existsSync(path)) {
|
|
120
132
|
try {
|
|
121
133
|
const cached = JSON.parse(readFileSync(path, 'utf-8'));
|
|
@@ -143,6 +155,28 @@ export function groundJourney(journey, docHash, srcDir = 'src') {
|
|
|
143
155
|
}
|
|
144
156
|
if (!facts)
|
|
145
157
|
return null;
|
|
158
|
+
// Everything above was READ from source. Measure the rest: which origin
|
|
159
|
+
// actually serves each path, what the URL looks like once the app has had
|
|
160
|
+
// its say. A test encodes those literally, so an inferred one is a test
|
|
161
|
+
// that fails for a reason the product is not responsible for.
|
|
162
|
+
try {
|
|
163
|
+
const config2 = loadConfig();
|
|
164
|
+
const env = loadEnvFile(config2.envFile ?? '.env.testing');
|
|
165
|
+
const origins = await originsFrom(env);
|
|
166
|
+
const observed = await observeApp(origins, pathsFrom(markdown, facts));
|
|
167
|
+
if (observed)
|
|
168
|
+
facts = `${facts}\n\n${observed}`;
|
|
169
|
+
// The schema is a query, never an inference. A seed that invents a table
|
|
170
|
+
// name, omits a NOT NULL column or hands a boolean to a smallint fails at
|
|
171
|
+
// run time as a SKIPPED test — which reads as "fine" rather than
|
|
172
|
+
// "nothing was checked".
|
|
173
|
+
const schema = await observeSchema(process.cwd(), env, `${markdown}\n${facts}`);
|
|
174
|
+
if (schema)
|
|
175
|
+
facts = `${facts}\n\n${schema}`;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// the app not being up is not a fact about the app — carry on without it
|
|
179
|
+
}
|
|
146
180
|
try {
|
|
147
181
|
mkdirSync(dirname(path), { recursive: true });
|
|
148
182
|
writeFileSync(path, JSON.stringify({ key, facts }, null, 2), 'utf-8');
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — facts MEASURED from the running application, not inferred.
|
|
3
|
+
*
|
|
4
|
+
* Everything else the generator knows about mechanics comes from reading
|
|
5
|
+
* source code: routes, selectors, shapes. Reading tells you what the code
|
|
6
|
+
* intends. It does not tell you what the deployment actually serves — which
|
|
7
|
+
* origin answers a path, whether the URL keeps its trailing slash, whether a
|
|
8
|
+
* locale segment survives. Those are the facts a test encodes literally, and
|
|
9
|
+
* guessing them produces a test that looks right and fails for no reason
|
|
10
|
+
* connected to the product.
|
|
11
|
+
*
|
|
12
|
+
* So we ask the application. Read-only: GET, against the origins the
|
|
13
|
+
* project's own test env already names, following redirects and recording
|
|
14
|
+
* where each one lands.
|
|
15
|
+
*/
|
|
16
|
+
const MAX_PATHS = 24;
|
|
17
|
+
const PROBE_TIMEOUT = 15_000;
|
|
18
|
+
/**
|
|
19
|
+
* Probes run a few at a time. A dev server answering its first request for a
|
|
20
|
+
* route can take seconds, and forty at once simply times them all out — which
|
|
21
|
+
* reads back as "the app does not serve this", the exact wrong fact.
|
|
22
|
+
*/
|
|
23
|
+
const PROBE_CONCURRENCY = 4;
|
|
24
|
+
async function inPool(items, fn) {
|
|
25
|
+
const out = new Array(items.length);
|
|
26
|
+
let next = 0;
|
|
27
|
+
await Promise.all(Array.from({ length: Math.min(PROBE_CONCURRENCY, items.length) }, async () => {
|
|
28
|
+
for (;;) {
|
|
29
|
+
const i = next++;
|
|
30
|
+
if (i >= items.length)
|
|
31
|
+
return;
|
|
32
|
+
out[i] = await fn(items[i]);
|
|
33
|
+
}
|
|
34
|
+
}));
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
/** Loopback, link-local and RFC1918 — the addresses a test environment uses. */
|
|
38
|
+
function isPrivateAddress(ip) {
|
|
39
|
+
if (/^127\./.test(ip) || ip === '::1')
|
|
40
|
+
return true;
|
|
41
|
+
if (/^10\./.test(ip) || /^192\.168\./.test(ip) || /^169\.254\./.test(ip))
|
|
42
|
+
return true;
|
|
43
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip))
|
|
44
|
+
return true;
|
|
45
|
+
if (/^(fc|fd)/i.test(ip))
|
|
46
|
+
return true;
|
|
47
|
+
// Docker/compose networks frequently sit on a private /8 that is not in the
|
|
48
|
+
// list above only because the host chose it; anything non-routable is fine.
|
|
49
|
+
return /^2[0-2][0-9]\.|^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Origins named by the project's own test env, narrowed to the ones that
|
|
53
|
+
* resolve to a PRIVATE address.
|
|
54
|
+
*
|
|
55
|
+
* An env file also names third-party services — a payment sandbox, an identity
|
|
56
|
+
* provider. Probing those would send this machine's traffic to somebody else's
|
|
57
|
+
* system to learn nothing about the project, so a host that resolves to a
|
|
58
|
+
* public address is never contacted. Duplicates collapse, and vars named like
|
|
59
|
+
* a base URL are preferred so an alias does not crowd out the real thing.
|
|
60
|
+
*/
|
|
61
|
+
export async function originsFrom(env) {
|
|
62
|
+
const { lookup } = await import('node:dns/promises');
|
|
63
|
+
const byUrl = new Map();
|
|
64
|
+
const entries = Object.entries(env).sort((a, b) => rank(a[0]) - rank(b[0]) || a[0].localeCompare(b[0]));
|
|
65
|
+
for (const [name, raw] of entries) {
|
|
66
|
+
const value = (raw ?? '').trim().replace(/\/+$/, '');
|
|
67
|
+
if (!/^https?:\/\/[^/\s]+$/i.test(value))
|
|
68
|
+
continue;
|
|
69
|
+
if (/\b(DB|DATABASE|REDIS|QUEUE|MAIL|SMTP)\b/i.test(name))
|
|
70
|
+
continue;
|
|
71
|
+
if (!byUrl.has(value))
|
|
72
|
+
byUrl.set(value, name);
|
|
73
|
+
}
|
|
74
|
+
const kept = [];
|
|
75
|
+
for (const [url, name] of byUrl) {
|
|
76
|
+
if (kept.length >= 6)
|
|
77
|
+
break;
|
|
78
|
+
let host;
|
|
79
|
+
try {
|
|
80
|
+
host = new URL(url).hostname;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (host === 'localhost') {
|
|
86
|
+
kept.push({ name, url });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const { address } = await lookup(host);
|
|
91
|
+
if (isPrivateAddress(address))
|
|
92
|
+
kept.push({ name, url });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// unresolvable — not reachable, so not worth probing either
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return kept;
|
|
99
|
+
}
|
|
100
|
+
/** Vars that name the application's own roots come first. */
|
|
101
|
+
function rank(name) {
|
|
102
|
+
if (/BASE_URL$/i.test(name))
|
|
103
|
+
return 0;
|
|
104
|
+
if (/^(APP|WEB|API|SPA)_/i.test(name) || /_(APP|WEB|API|SPA)_URL$/i.test(name))
|
|
105
|
+
return 1;
|
|
106
|
+
return 2;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Paths worth probing, taken from the journey document and whatever the
|
|
110
|
+
* source-reading step already reported. A path is any absolute, literal
|
|
111
|
+
* route; templated segments ({id}, :id) are dropped because a probe of a
|
|
112
|
+
* placeholder measures nothing.
|
|
113
|
+
*/
|
|
114
|
+
export function pathsFrom(...texts) {
|
|
115
|
+
const found = new Set();
|
|
116
|
+
for (const text of texts) {
|
|
117
|
+
for (const m of (text || '').matchAll(/(?<![\w:/])\/[a-z0-9][a-z0-9\-/]*/gi)) {
|
|
118
|
+
const p = m[0].replace(/[.,)\]]+$/, '');
|
|
119
|
+
if (p.length < 2 || p.length > 80)
|
|
120
|
+
continue;
|
|
121
|
+
if (/\.(js|ts|tsx|jsx|json|md|php|css|png|svg)$/i.test(p))
|
|
122
|
+
continue;
|
|
123
|
+
if (/^\/(src|app|tests?|node_modules|vendor|storage|public)\b/i.test(p))
|
|
124
|
+
continue;
|
|
125
|
+
found.add(p);
|
|
126
|
+
if (found.size >= MAX_PATHS)
|
|
127
|
+
return [...found];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return [...found];
|
|
131
|
+
}
|
|
132
|
+
async function probe(url) {
|
|
133
|
+
const ac = new AbortController();
|
|
134
|
+
const timer = setTimeout(() => ac.abort(), PROBE_TIMEOUT);
|
|
135
|
+
try {
|
|
136
|
+
const res = await fetch(url, { redirect: 'follow', signal: ac.signal });
|
|
137
|
+
return { status: res.status, final: res.url || url };
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Strip the origin so the block shows paths, not repeated hostnames. */
|
|
147
|
+
function pathOf(full, origin) {
|
|
148
|
+
return full.startsWith(origin) ? full.slice(origin.length) || '/' : full;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Probe every path against every origin and render the result as a facts
|
|
152
|
+
* block. Returns null when nothing could be reached — the app being down is
|
|
153
|
+
* not a fact about the app.
|
|
154
|
+
*/
|
|
155
|
+
export async function observeApp(origins, paths) {
|
|
156
|
+
if (!origins.length || !paths.length)
|
|
157
|
+
return null;
|
|
158
|
+
const jobs = paths.flatMap((path) => origins.map((o) => ({ path, o })));
|
|
159
|
+
const answered = await inPool(jobs, async ({ path, o }) => {
|
|
160
|
+
const r = await probe(`${o.url}${path}`);
|
|
161
|
+
return {
|
|
162
|
+
path,
|
|
163
|
+
origin: o.name,
|
|
164
|
+
status: r ? r.status : 0,
|
|
165
|
+
final: r ? pathOf(r.final, o.url) : path,
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
const probes = paths.map((path) => ({
|
|
169
|
+
path,
|
|
170
|
+
results: answered.filter((a) => a.path === path && a.status !== 0),
|
|
171
|
+
}));
|
|
172
|
+
const reachable = probes.filter((p) => p.results.some((r) => r.status < 500));
|
|
173
|
+
if (!reachable.length)
|
|
174
|
+
return null;
|
|
175
|
+
const lines = [
|
|
176
|
+
'OBSERVED — measured against the running application just now. These are',
|
|
177
|
+
'facts, not inferences: prefer them over anything read from source code.',
|
|
178
|
+
'',
|
|
179
|
+
` Origins probed: ${origins.map((o) => `${o.name}=${o.url}`).join(' ')}`,
|
|
180
|
+
'',
|
|
181
|
+
' Path -> how each origin answered (status, and where it landed):',
|
|
182
|
+
];
|
|
183
|
+
for (const p of reachable) {
|
|
184
|
+
const cells = p.results
|
|
185
|
+
.map((r) => `${r.origin} ${r.status}${r.final !== p.path ? ` -> ${r.final}` : ''}`)
|
|
186
|
+
.join(' ');
|
|
187
|
+
lines.push(` ${p.path.padEnd(42)} ${cells}`);
|
|
188
|
+
}
|
|
189
|
+
// URL style, read off the probes rather than asserted.
|
|
190
|
+
const slashed = reachable.filter((p) => p.results.some((r) => r.status === 200 && r.final === `${p.path}/`)).length;
|
|
191
|
+
if (slashed) {
|
|
192
|
+
lines.push('', ` URL STYLE: the application serves ${slashed} of these paths with a TRAILING`, ' SLASH. Any URL assertion, and any href-suffix selector, must include it.');
|
|
193
|
+
}
|
|
194
|
+
// The primary origin is the one that answers most of these paths — the
|
|
195
|
+
// remaining probes are worth spending on it rather than on an alias.
|
|
196
|
+
const tally = new Map();
|
|
197
|
+
for (const p of reachable)
|
|
198
|
+
for (const r of p.results)
|
|
199
|
+
if (r.status === 200)
|
|
200
|
+
tally.set(r.origin, (tally.get(r.origin) ?? 0) + 1);
|
|
201
|
+
const primaryName = [...tally].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
202
|
+
const primary = origins.find((o) => o.name === primaryName);
|
|
203
|
+
if (primary) {
|
|
204
|
+
// A single-page app often answers 200 for EVERY path, including ones it
|
|
205
|
+
// does not implement. Without this, "200" reads as "the page exists" and
|
|
206
|
+
// a test is written against a catch-all shell that renders nothing it
|
|
207
|
+
// expects. Ask for a path nothing could implement and see what happens.
|
|
208
|
+
const miss = await probe(`${primary.url}/__testforge_no_such_path__`);
|
|
209
|
+
if (miss && miss.status === 200) {
|
|
210
|
+
lines.push('', ` CAUTION: ${primary.name} answers 200 for a path that cannot exist, so it`, ' serves a catch-all. On this origin a 200 is NOT evidence that a screen', ' exists — confirm by content the document names, not by status code.');
|
|
211
|
+
}
|
|
212
|
+
// A locale segment that disappears on the way is a segment the app does
|
|
213
|
+
// not use — a test that hard-codes it asserts against a redirect.
|
|
214
|
+
const sample = reachable.find((p) => p.results.some((r) => r.origin === primary.name && r.status === 200));
|
|
215
|
+
if (sample) {
|
|
216
|
+
const r = await probe(`${primary.url}/en${sample.path}`);
|
|
217
|
+
if (r && r.status < 400 && !r.final.includes('/en/')) {
|
|
218
|
+
lines.push('', ` LOCALE: /en${sample.path} lands on ${pathOf(r.final, primary.url)} — the application`, ' does NOT keep a locale segment in its URLs. Do not build one into a path.');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return lines.join('\n');
|
|
223
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — the database's own account of itself.
|
|
3
|
+
*
|
|
4
|
+
* A seed is the part of a test most easily written from memory: a plausible
|
|
5
|
+
* table name, a boolean where the column is a smallint, a row that omits a
|
|
6
|
+
* NOT NULL column. None of that is visible in the test file, so every gate
|
|
7
|
+
* passes and the failure only appears when the suite runs — usually as a
|
|
8
|
+
* skipped test, which reads as "fine" rather than "nothing was checked".
|
|
9
|
+
*
|
|
10
|
+
* The schema is not something to infer. It is a query. We ask the project's
|
|
11
|
+
* own test database, through the project's own driver, and hand the answer
|
|
12
|
+
* to the generator as fact.
|
|
13
|
+
*/
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
/** Cap the block: a generator needs the tables in play, not the whole database. */
|
|
16
|
+
const MAX_TABLES_DETAILED = 12;
|
|
17
|
+
const MAX_TABLE_NAMES = 250;
|
|
18
|
+
/** Connection settings, in the order a project's test env usually provides them. */
|
|
19
|
+
function connectionFrom(env) {
|
|
20
|
+
const url = env.DATABASE_URL || env.DB_URL || env.PG_URL;
|
|
21
|
+
// Laravel writes pgsql://, node-postgres only understands postgres(ql)://.
|
|
22
|
+
// Same server either way, so normalise rather than refuse to look.
|
|
23
|
+
const scheme = url?.match(/^([a-z+]+):\/\//i)?.[1]?.toLowerCase();
|
|
24
|
+
if (url && scheme && ['postgres', 'postgresql', 'pgsql', 'pg'].includes(scheme)) {
|
|
25
|
+
return { connectionString: url.replace(/^[a-z+]+:\/\//i, 'postgresql://') };
|
|
26
|
+
}
|
|
27
|
+
const host = env.DB_HOST || env.PGHOST;
|
|
28
|
+
const database = env.DB_DATABASE || env.DB_NAME || env.PGDATABASE;
|
|
29
|
+
if (!host || !database)
|
|
30
|
+
return null;
|
|
31
|
+
return {
|
|
32
|
+
host,
|
|
33
|
+
port: Number(env.DB_PORT || env.PGPORT || 5432),
|
|
34
|
+
database,
|
|
35
|
+
user: env.DB_USERNAME || env.DB_USER || env.PGUSER,
|
|
36
|
+
password: env.DB_PASSWORD || env.PGPASSWORD,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The project's Postgres driver. testforge has no runtime dependencies and
|
|
41
|
+
* should not grow one for this: a project whose tests touch a database
|
|
42
|
+
* already ships a driver, and using theirs guarantees the same version the
|
|
43
|
+
* generated seed will run under.
|
|
44
|
+
*/
|
|
45
|
+
function loadDriver(projectRoot) {
|
|
46
|
+
try {
|
|
47
|
+
const require = createRequire(`${projectRoot.replace(/\/+$/, '')}/package.json`);
|
|
48
|
+
return require('pg');
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Short type names keep the block readable; the distinction that matters is kept. */
|
|
55
|
+
function shortType(t) {
|
|
56
|
+
return t
|
|
57
|
+
.replace('character varying', 'varchar')
|
|
58
|
+
.replace('timestamp without time zone', 'timestamp')
|
|
59
|
+
.replace('timestamp with time zone', 'timestamptz')
|
|
60
|
+
.replace('double precision', 'float8');
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Read the schema and render the facts a seed actually needs. Returns null
|
|
64
|
+
* whenever the database cannot be reached — an unreachable database is not a
|
|
65
|
+
* fact about the schema, and generation carries on without the block.
|
|
66
|
+
*/
|
|
67
|
+
export async function observeSchema(projectRoot, env, mentionedIn) {
|
|
68
|
+
const config = connectionFrom(env);
|
|
69
|
+
const pg = config && loadDriver(projectRoot);
|
|
70
|
+
if (!config || !pg)
|
|
71
|
+
return null;
|
|
72
|
+
const client = new pg.Client({ ...config, connectionTimeoutMillis: 8_000 });
|
|
73
|
+
try {
|
|
74
|
+
await client.connect();
|
|
75
|
+
const names = (await client.query(`select table_name from information_schema.tables
|
|
76
|
+
where table_schema = 'public' and table_type = 'BASE TABLE'
|
|
77
|
+
order by table_name`)).rows.map((r) => r.table_name);
|
|
78
|
+
if (!names.length)
|
|
79
|
+
return null;
|
|
80
|
+
// Detail the tables this journey actually talks about. Matching against
|
|
81
|
+
// the REAL list is what makes an invented name impossible to carry
|
|
82
|
+
// forward: a name nobody has is simply not in the block.
|
|
83
|
+
const text = mentionedIn.toLowerCase();
|
|
84
|
+
const wanted = names
|
|
85
|
+
.filter((n) => text.includes(n.toLowerCase()))
|
|
86
|
+
.sort((a, b) => b.length - a.length)
|
|
87
|
+
.slice(0, MAX_TABLES_DETAILED);
|
|
88
|
+
const lines = [
|
|
89
|
+
'SCHEMA — read from this project\'s own test database just now. These are',
|
|
90
|
+
'facts: a table or column that is not listed here does not exist.',
|
|
91
|
+
'',
|
|
92
|
+
` Tables (${names.length}):`,
|
|
93
|
+
];
|
|
94
|
+
for (let i = 0; i < Math.min(names.length, MAX_TABLE_NAMES); i += 6) {
|
|
95
|
+
lines.push(` ${names.slice(i, i + 6).join(', ')}`);
|
|
96
|
+
}
|
|
97
|
+
if (wanted.length) {
|
|
98
|
+
const cols = (await client.query(`select table_name, column_name, data_type, is_nullable, column_default
|
|
99
|
+
from information_schema.columns
|
|
100
|
+
where table_schema = 'public' and table_name = any($1)
|
|
101
|
+
order by table_name, ordinal_position`, [wanted])).rows;
|
|
102
|
+
for (const table of wanted) {
|
|
103
|
+
const own = cols.filter((c) => c.table_name === table);
|
|
104
|
+
if (!own.length)
|
|
105
|
+
continue;
|
|
106
|
+
const required = own.filter((c) => c.is_nullable === 'NO' && !c.column_default);
|
|
107
|
+
// Only the columns a seed is likely to hand a boolean: a smallint
|
|
108
|
+
// that reads like a flag or a state. Listing every integer would bury
|
|
109
|
+
// that under ids, counts and measurements, and "pass 0 or 1" is not
|
|
110
|
+
// even true for a column like doors.
|
|
111
|
+
const smallish = own.filter((c) => c.data_type === 'smallint' &&
|
|
112
|
+
!/^id$/.test(c.column_name) &&
|
|
113
|
+
!/_id$/.test(c.column_name));
|
|
114
|
+
lines.push('', ` ${table}:`);
|
|
115
|
+
if (required.length) {
|
|
116
|
+
lines.push(' MUST be provided by a seed (NOT NULL, no default):', ` ${required.map((c) => `${c.column_name}:${shortType(c.data_type)}`).join(', ')}`);
|
|
117
|
+
}
|
|
118
|
+
if (smallish.length) {
|
|
119
|
+
lines.push(' smallint columns — NEVER pass a JS boolean. The driver sends the', ' literal "false" and the column rejects it (invalid input syntax', ' for type smallint). Use the numeric value the column expects:', ` ${smallish.map((c) => c.column_name).join(', ')}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return lines.join('\n');
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
try {
|
|
130
|
+
await client.end();
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
/* already closed */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -33,6 +33,10 @@ export function writeCoverageManifest(results) {
|
|
|
33
33
|
journey: r.journeyName,
|
|
34
34
|
real: files.filter((f) => f.real).length,
|
|
35
35
|
deferred: files.filter((f) => !f.real).length,
|
|
36
|
+
// Whether these files were RUN and passed. A file can assert plenty
|
|
37
|
+
// and still target something the app does not have, so "real" alone
|
|
38
|
+
// was never enough to call the coverage honest.
|
|
39
|
+
verified: r.verified,
|
|
36
40
|
files,
|
|
37
41
|
};
|
|
38
42
|
});
|
|
@@ -42,12 +46,27 @@ export function writeCoverageManifest(results) {
|
|
|
42
46
|
const totalDeferred = journeys.reduce((n, j) => n + j.deferred, 0);
|
|
43
47
|
try {
|
|
44
48
|
mkdirSync(STATE_DIR, { recursive: true });
|
|
45
|
-
writeFileSync(COVERAGE_PATH, JSON.stringify({
|
|
49
|
+
writeFileSync(COVERAGE_PATH, JSON.stringify({
|
|
50
|
+
real: totalReal,
|
|
51
|
+
deferred: totalDeferred,
|
|
52
|
+
verified: journeys.filter((j) => j.verified === true).length,
|
|
53
|
+
failedVerify: journeys.filter((j) => j.verified === false).length,
|
|
54
|
+
notVerified: journeys.filter((j) => j.verified === undefined).length,
|
|
55
|
+
journeys,
|
|
56
|
+
}, null, 2), 'utf-8');
|
|
46
57
|
}
|
|
47
58
|
catch {
|
|
48
59
|
/* best-effort */
|
|
49
60
|
}
|
|
50
61
|
console.log(`\nCoverage: ${totalReal} real test file(s), ${totalDeferred} deferred/placeholder — see ${COVERAGE_PATH}`);
|
|
62
|
+
const ranGreen = journeys.filter((j) => j.verified === true).length;
|
|
63
|
+
const ranRed = journeys.filter((j) => j.verified === false).length;
|
|
64
|
+
const unrun = journeys.filter((j) => j.verified === undefined).length;
|
|
65
|
+
if (ranGreen || ranRed || unrun) {
|
|
66
|
+
console.log(`Verified: ${ranGreen} journey(s) ran green` +
|
|
67
|
+
(ranRed ? `, ${ranRed} still failing` : '') +
|
|
68
|
+
(unrun ? `, ${unrun} not run` : ''));
|
|
69
|
+
}
|
|
51
70
|
if (totalDeferred) {
|
|
52
71
|
console.log(` (deferred files are pending, NOT real coverage — regenerate or implement them.)`);
|
|
53
72
|
}
|
|
@@ -485,7 +485,18 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
|
|
|
485
485
|
// written, and handed unchanged to every file in it. Sits with the map and
|
|
486
486
|
// the journey so the whole block stays a stable prefix across the journey.
|
|
487
487
|
...(grounding
|
|
488
|
-
? [
|
|
488
|
+
? [
|
|
489
|
+
`--- CODE FACTS (read from this project's source) ---`,
|
|
490
|
+
grounding,
|
|
491
|
+
``,
|
|
492
|
+
`Where this block carries an "OBSERVED" or "SCHEMA" section, those`,
|
|
493
|
+
`lines were MEASURED — against the running application and its own`,
|
|
494
|
+
`database — not read from code. They outrank everything else here.`,
|
|
495
|
+
`Take the origin, the exact URL shape (including a trailing slash),`,
|
|
496
|
+
`any locale note, and every table and column name from them verbatim.`,
|
|
497
|
+
`Never invent a path, origin, table or column that appears nowhere above.`,
|
|
498
|
+
``,
|
|
499
|
+
]
|
|
489
500
|
: []),
|
|
490
501
|
`--- JOURNEY ---`,
|
|
491
502
|
`It may include sections beyond fields — actions, expected results,`,
|
|
@@ -554,6 +565,52 @@ export function generateWithAI(journey, testPath, focus, grounding) {
|
|
|
554
565
|
* journey's suite can be generated concurrently (each spawns its own
|
|
555
566
|
* `claude` process). Same prompt, same read-only exploration.
|
|
556
567
|
*/
|
|
568
|
+
/**
|
|
569
|
+
* Repair a generated test from the runner's own failure output.
|
|
570
|
+
*
|
|
571
|
+
* The failure is evidence the generator did not have: the real selector, the
|
|
572
|
+
* real URL shape, the real column type. So the repair prompt leads with it
|
|
573
|
+
* and forbids the two easy ways out — deleting the assertion, or skipping the
|
|
574
|
+
* test — because either turns a caught defect into a silent one.
|
|
575
|
+
*/
|
|
576
|
+
export async function repairWithAIAsync(journey, testPath, current, failure, harnessPath, transport, grounding) {
|
|
577
|
+
const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
|
|
578
|
+
const base = buildPrompt(journey, map, testPath, undefined, harnessPath, transport, grounding);
|
|
579
|
+
const prompt = [
|
|
580
|
+
base,
|
|
581
|
+
'',
|
|
582
|
+
'=== REPAIR ===',
|
|
583
|
+
`The file below was generated for ${testPath} and RUN against the real`,
|
|
584
|
+
'application. It FAILED. The runner output is the ground truth: it tells',
|
|
585
|
+
'you what the application actually renders, routes and stores.',
|
|
586
|
+
'',
|
|
587
|
+
'Rewrite the file so it passes for the RIGHT reason.',
|
|
588
|
+
'',
|
|
589
|
+
'Rules for this repair, in addition to every rule above:',
|
|
590
|
+
' 1. Do NOT delete, weaken or invert an assertion to make it pass.',
|
|
591
|
+
' 2. Do NOT add test.skip / test.fixme / try-catch to swallow the failure.',
|
|
592
|
+
' 3. If the output shows the app uses a different selector, URL shape,',
|
|
593
|
+
' path prefix, origin, table name or column type than the file assumed,',
|
|
594
|
+
' the FILE is wrong — correct it to match the application.',
|
|
595
|
+
' 4. If a value is genuinely missing because the page has not finished',
|
|
596
|
+
' loading, wait for it properly; do not raise timeouts blindly.',
|
|
597
|
+
' 5. If the output shows the APPLICATION is genuinely wrong (it contradicts',
|
|
598
|
+
' the journey document), keep the assertion exactly as it is and change',
|
|
599
|
+
' nothing — a real defect must stay caught.',
|
|
600
|
+
'',
|
|
601
|
+
'--- CURRENT FILE ---',
|
|
602
|
+
current,
|
|
603
|
+
'--- RUNNER OUTPUT ---',
|
|
604
|
+
failure,
|
|
605
|
+
'--- END ---',
|
|
606
|
+
'',
|
|
607
|
+
'Output ONLY the corrected file. No commentary.',
|
|
608
|
+
].join('\n');
|
|
609
|
+
const config = loadConfig();
|
|
610
|
+
const { program, args } = withReadOnlyTools(splitCommand(config.aiCommand));
|
|
611
|
+
const stdout = await runWithStdin(program, args, prompt, config.aiTimeoutMs);
|
|
612
|
+
return cleanOutput(stdout);
|
|
613
|
+
}
|
|
557
614
|
export async function generateWithAIAsync(journey, testPath, focus, harnessPath, transport, grounding) {
|
|
558
615
|
const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
|
|
559
616
|
const prompt = buildPrompt(journey, map, testPath, focus, harnessPath, transport, grounding);
|
|
@@ -592,6 +649,14 @@ function looksLikeCode(line) {
|
|
|
592
649
|
t.startsWith('function ') ||
|
|
593
650
|
t.startsWith('async ') ||
|
|
594
651
|
t.startsWith('require(') ||
|
|
652
|
+
// A test file often opens straight on its first construct, with no
|
|
653
|
+
// import and no comment above it. Without these an unfenced but
|
|
654
|
+
// perfectly good file is thrown away as "no recognizable code".
|
|
655
|
+
t.startsWith('describe(') ||
|
|
656
|
+
t.startsWith('test(') ||
|
|
657
|
+
t.startsWith('it(') ||
|
|
658
|
+
t.startsWith('module.exports') ||
|
|
659
|
+
t.startsWith('class ') ||
|
|
595
660
|
t.startsWith("'use strict'") ||
|
|
596
661
|
t.startsWith('"use strict"'));
|
|
597
662
|
}
|
|
@@ -26,7 +26,8 @@ import { cleanStaleFiles, collisionWarning, hasDeferredStubs, isDeferredStub, is
|
|
|
26
26
|
import { aiFailureNote, aiUnavailableReason, confirmAiAlive, lastAiFailureWasProcess, noteAiFailure, noteAiRejected, noteAiSuccess, resetAiSession, waitForAi, } from './ai-session.js';
|
|
27
27
|
import { generateTestCases } from './generator.js';
|
|
28
28
|
import { writeDeferredStub } from './playwright-writer.js';
|
|
29
|
-
import { generateWithAI, generateWithAIAsync, } from './ai-generator.js';
|
|
29
|
+
import { generateWithAI, generateWithAIAsync, repairWithAIAsync, } from './ai-generator.js';
|
|
30
|
+
import { verifyFiles } from './verify.js';
|
|
30
31
|
import { groundJourney } from '../docs/grounding.js';
|
|
31
32
|
import { generateSeed } from './seed-generator.js';
|
|
32
33
|
import { isSyntaxValid, checkTestGates } from '../gates/validate.js';
|
|
@@ -173,6 +174,12 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
173
174
|
return [];
|
|
174
175
|
}
|
|
175
176
|
const config = loadConfig();
|
|
177
|
+
// Verify is on unless the project or the caller turns it off. verifyFiles
|
|
178
|
+
// itself reports (and skips) the cases where the app cannot be reached, so
|
|
179
|
+
// "on" never means "fail the run because nothing is running".
|
|
180
|
+
const verifyOn = opts.verify ?? config.verify ?? true;
|
|
181
|
+
const verifyAttempts = Math.max(0, config.verifyAttempts ?? 2);
|
|
182
|
+
const verifySkipNoted = new Set();
|
|
176
183
|
const ext = config.testExtension ?? '.test.ts';
|
|
177
184
|
const seedDir = config.seedOutput ?? 'generated-tests/seed';
|
|
178
185
|
const seedExt = ext.endsWith('.js') ? '.seed.js' : '.seed.ts';
|
|
@@ -434,7 +441,7 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
434
441
|
// fails, `grounding` stays undefined and each file falls back to reading
|
|
435
442
|
// the source itself, which is slower but still correct.
|
|
436
443
|
const grounding = useAI
|
|
437
|
-
? groundJourney(journey, hash) ?? undefined
|
|
444
|
+
? (await groundJourney(journey, hash, 'src', markdown)) ?? undefined
|
|
438
445
|
: undefined;
|
|
439
446
|
const staleRemoved = cleanStaleFiles(testDir, journey.id, ext, flat, plannedSlugs, prevSlugs);
|
|
440
447
|
if (staleRemoved.length) {
|
|
@@ -561,7 +568,59 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
561
568
|
warnings.push(`lint: ${lint.remaining.length} problem(s) the linter could not fix — ` +
|
|
562
569
|
lint.remaining.slice(0, 5).join('; '));
|
|
563
570
|
}
|
|
564
|
-
|
|
571
|
+
// Run what we just wrote, once, against the real application. A test
|
|
572
|
+
// that passes every static gate can still target a selector, route or
|
|
573
|
+
// column the app does not have — only running it can tell. The runner's
|
|
574
|
+
// own output is the repair prompt; a journey that still fails afterwards
|
|
575
|
+
// is reported and left UNCACHED, so it is retried rather than trusted.
|
|
576
|
+
let verified;
|
|
577
|
+
const targets = testFiles.filter((t) => t.status === 'ai').map((t) => t.path);
|
|
578
|
+
if (useAI && verifyOn && targets.length) {
|
|
579
|
+
for (let attempt = 0; attempt <= verifyAttempts; attempt += 1) {
|
|
580
|
+
const outcome = verifyFiles(config, journey.area, targets);
|
|
581
|
+
if (!outcome.ran) {
|
|
582
|
+
// Say WHY once. The reason is a property of the project (no env
|
|
583
|
+
// file, no runner), not of this journey, so repeating it per
|
|
584
|
+
// journey buries the rest of the output.
|
|
585
|
+
if (outcome.reason && !verifySkipNoted.has(outcome.reason)) {
|
|
586
|
+
verifySkipNoted.add(outcome.reason);
|
|
587
|
+
console.log(` not verified — ${outcome.reason}`);
|
|
588
|
+
}
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
if (outcome.ok) {
|
|
592
|
+
verified = true;
|
|
593
|
+
if (attempt > 0)
|
|
594
|
+
console.log(` verified after ${attempt} repair(s)`);
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
verified = false;
|
|
598
|
+
if (attempt === verifyAttempts) {
|
|
599
|
+
warnings.push(`verify: still failing after ${verifyAttempts} repair attempt(s) — ` +
|
|
600
|
+
'this may be a real defect in the application, or a generation ' +
|
|
601
|
+
'defect this run could not correct. Left uncached for a retry.');
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
console.log(` failed when run — repairing (attempt ${attempt + 1}/${verifyAttempts})`);
|
|
605
|
+
for (const target of targets) {
|
|
606
|
+
let current;
|
|
607
|
+
try {
|
|
608
|
+
current = readFileSync(target, 'utf-8');
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
const repaired = await generateValidatedAsync(() => repairWithAIAsync(journey, target, current, outcome.output, harnessPath, transport, grounding), ext, testGate);
|
|
614
|
+
if (repaired === null)
|
|
615
|
+
continue;
|
|
616
|
+
const stamped = repaired.includes(GEN_MARKER)
|
|
617
|
+
? repaired
|
|
618
|
+
: `// ${GEN_MARKER}\n${repaired}`;
|
|
619
|
+
writeFileSync(target, stamped, 'utf-8');
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const degraded = useAI && (status === 'basic' || seedStatus === 'failed' || verified === false);
|
|
565
624
|
if (!degraded) {
|
|
566
625
|
cache[file] = hash;
|
|
567
626
|
}
|
|
@@ -584,6 +643,7 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
584
643
|
seedPath,
|
|
585
644
|
status,
|
|
586
645
|
seedStatus,
|
|
646
|
+
verified,
|
|
587
647
|
warnings: warnings.length ? warnings : undefined,
|
|
588
648
|
});
|
|
589
649
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — run a freshly generated test once, before calling it done.
|
|
3
|
+
*
|
|
4
|
+
* The static gates read the FILE: does it assert, does it parse, is it a
|
|
5
|
+
* stub. They cannot see the running application, so a test that looks
|
|
6
|
+
* perfect and targets a selector, URL shape or column type the app does not
|
|
7
|
+
* have still passes every gate. Those tests fail later in a way that says
|
|
8
|
+
* nothing about the product — the expensive kind of noise.
|
|
9
|
+
*
|
|
10
|
+
* So after a journey's files and seed are written we execute them once. A
|
|
11
|
+
* failure here is a generation defect until proven otherwise, and its output
|
|
12
|
+
* is the repair prompt: the runner's own error is far better evidence than
|
|
13
|
+
* anything we could infer.
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { existsSync } from 'node:fs';
|
|
17
|
+
import { relative } from 'node:path';
|
|
18
|
+
import { loadEnvFile, testDirForType, resolveFramework, } from '../project/config.js';
|
|
19
|
+
import { runnerCommand, packageRoot } from '../run/run-tests.js';
|
|
20
|
+
/** How much runner output to carry into a repair prompt. */
|
|
21
|
+
const MAX_OUTPUT = 12_000;
|
|
22
|
+
/**
|
|
23
|
+
* Keep the END of the output: runners print the failure detail and the
|
|
24
|
+
* summary last, and that is the part a repair needs.
|
|
25
|
+
*/
|
|
26
|
+
function trimOutput(s) {
|
|
27
|
+
const clean = s.replace(/\u001b\[[0-9;]*m/g, '');
|
|
28
|
+
return clean.length <= MAX_OUTPUT
|
|
29
|
+
? clean.trim()
|
|
30
|
+
: `…(${clean.length - MAX_OUTPUT} chars trimmed)…\n${clean.slice(-MAX_OUTPUT).trim()}`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* What the runner actually executed. Both Jest and Playwright print these
|
|
34
|
+
* counts; anything we cannot read comes back null and is not guessed at.
|
|
35
|
+
*/
|
|
36
|
+
function countsFrom(output) {
|
|
37
|
+
const n = (re) => {
|
|
38
|
+
const m = output.match(re);
|
|
39
|
+
return m ? Number(m[1]) : null;
|
|
40
|
+
};
|
|
41
|
+
const passed = n(/(\d+)\s+passed/i) ?? n(/Tests:.*?(\d+)\s+passed/i);
|
|
42
|
+
const skipped = n(/(\d+)\s+skipped/i) ?? n(/(\d+)\s+todo/i) ?? n(/(\d+)\s+pending/i);
|
|
43
|
+
if (passed === null && skipped === null)
|
|
44
|
+
return null;
|
|
45
|
+
return { passed: passed ?? 0, skipped: skipped ?? 0 };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Run exactly the given generated files. Never throws: a verify step that
|
|
49
|
+
* breaks generation would be worse than no verify step at all.
|
|
50
|
+
*/
|
|
51
|
+
export function verifyFiles(config, area, files) {
|
|
52
|
+
if (!files.length)
|
|
53
|
+
return { ran: false, ok: false, output: '', reason: 'no files to verify' };
|
|
54
|
+
const testDir = testDirForType(config, area);
|
|
55
|
+
const root = packageRoot(testDir);
|
|
56
|
+
const rel = relative(root, testDir) || '.';
|
|
57
|
+
const framework = resolveFramework(config, area, area);
|
|
58
|
+
const present = files.filter((f) => existsSync(f));
|
|
59
|
+
if (!present.length) {
|
|
60
|
+
return { ran: false, ok: false, output: '', reason: 'generated files are not on disk' };
|
|
61
|
+
}
|
|
62
|
+
const cmd = runnerCommand(config, area, framework, root, rel, undefined, present);
|
|
63
|
+
const envFile = config.envFile ?? '.env.testing';
|
|
64
|
+
const envVars = loadEnvFile(envFile);
|
|
65
|
+
if (!Object.keys(envVars).length && !existsSync(envFile)) {
|
|
66
|
+
return {
|
|
67
|
+
ran: false,
|
|
68
|
+
ok: false,
|
|
69
|
+
output: '',
|
|
70
|
+
reason: `no ${envFile} — cannot reach the app to verify`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const res = spawnSync(cmd, {
|
|
74
|
+
shell: true,
|
|
75
|
+
cwd: root,
|
|
76
|
+
env: { ...process.env, ...envVars },
|
|
77
|
+
encoding: 'utf-8',
|
|
78
|
+
timeout: config.verifyTimeoutMs ?? 600_000,
|
|
79
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
80
|
+
});
|
|
81
|
+
if (res.error) {
|
|
82
|
+
return { ran: false, ok: false, output: '', reason: res.error.message };
|
|
83
|
+
}
|
|
84
|
+
const output = trimOutput(`${res.stdout ?? ''}\n${res.stderr ?? ''}`);
|
|
85
|
+
// A runner that could not start at all (no binary, bad config) is not a
|
|
86
|
+
// failing test — reporting it as one would send the AI chasing a ghost.
|
|
87
|
+
if (res.status === null) {
|
|
88
|
+
return { ran: false, ok: false, output, reason: 'runner timed out' };
|
|
89
|
+
}
|
|
90
|
+
if (/command not found|Cannot find module '@playwright|is not recognized/i.test(output)) {
|
|
91
|
+
return { ran: false, ok: false, output, reason: 'test runner is not installed' };
|
|
92
|
+
}
|
|
93
|
+
if (res.status !== 0)
|
|
94
|
+
return { ran: true, ok: false, output };
|
|
95
|
+
// A suite where every test skipped ALSO exits 0. Treating that as verified
|
|
96
|
+
// is the exact dishonesty this step exists to remove: the seed could not be
|
|
97
|
+
// established, or a precondition was not met, and nothing was checked at
|
|
98
|
+
// all. Report it as a failure, with the reason the runner already printed.
|
|
99
|
+
const counts = countsFrom(output);
|
|
100
|
+
if (counts && counts.passed === 0 && counts.skipped > 0) {
|
|
101
|
+
return {
|
|
102
|
+
ran: true,
|
|
103
|
+
ok: false,
|
|
104
|
+
output: `Every test was SKIPPED (${counts.skipped} skipped, 0 passed) — nothing was actually verified.\nThe precondition or seed below could not be established; fix that so the tests run.\n\n${output}`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return { ran: true, ok: true, output };
|
|
108
|
+
}
|
|
@@ -17,7 +17,7 @@ import { existsSync } from 'node:fs';
|
|
|
17
17
|
import { join, dirname, resolve, relative } from 'node:path';
|
|
18
18
|
import { loadConfig, loadEnvFile, testDirForType, resolveFramework, } from '../project/config.js';
|
|
19
19
|
/** Nearest ancestor dir containing a package.json (the runner's cwd). */
|
|
20
|
-
function packageRoot(dir) {
|
|
20
|
+
export function packageRoot(dir) {
|
|
21
21
|
let d = resolve(dir);
|
|
22
22
|
for (;;) {
|
|
23
23
|
if (existsSync(join(d, 'package.json')))
|
|
@@ -28,6 +28,65 @@ function packageRoot(dir) {
|
|
|
28
28
|
d = parent;
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* The command that runs an area's tests, and nothing else — so `generate`'s
|
|
33
|
+
* verify step and `run` can never drift into invoking the suite differently.
|
|
34
|
+
* `targets` narrows the run to specific files (used when verifying a single
|
|
35
|
+
* journey); without it the whole area runs.
|
|
36
|
+
*/
|
|
37
|
+
export function runnerCommand(config, area, framework, root, rel, journey, targets) {
|
|
38
|
+
const explicit = config.testCommand?.[area];
|
|
39
|
+
const paths = (targets ?? []).map((t) => relative(root, t) || t);
|
|
40
|
+
if (explicit)
|
|
41
|
+
return paths.length ? `${explicit} ${paths.join(' ')}` : explicit;
|
|
42
|
+
if (framework === 'playwright') {
|
|
43
|
+
const target = paths.length
|
|
44
|
+
? paths.join(' ')
|
|
45
|
+
: journey
|
|
46
|
+
? `${rel}/${journey}`
|
|
47
|
+
: rel;
|
|
48
|
+
// Name the config explicitly. It is written beside the AREA's tests
|
|
49
|
+
// (tests/Feature/playwright.config.js for tests/Feature/Web), not at
|
|
50
|
+
// the package root, so Playwright's own discovery finds nothing and
|
|
51
|
+
// silently falls back to defaults — no globalSetup, no reporters, and
|
|
52
|
+
// therefore no run published to the dashboard. Walk up from the test
|
|
53
|
+
// directory to the package root and take the first config found.
|
|
54
|
+
let cfgFlag = '';
|
|
55
|
+
for (let dir = rel;; dir = dirname(dir)) {
|
|
56
|
+
const hit = ['playwright.config.js', 'playwright.config.ts']
|
|
57
|
+
.map((f) => (dir === '.' ? f : join(dir, f)))
|
|
58
|
+
.find((f) => existsSync(join(root, f)));
|
|
59
|
+
if (hit) {
|
|
60
|
+
cfgFlag = ` --config=${hit}`;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
if (dir === '.' || dir === dirname(dir))
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
return `npx playwright test${cfgFlag} ${target}`;
|
|
67
|
+
}
|
|
68
|
+
// Jest needs its config named for the same reason Playwright does: the one
|
|
69
|
+
// testforge writes sits beside the AREA's tests, not at the package root, so
|
|
70
|
+
// Jest's own discovery misses it — and with it the reporter that publishes
|
|
71
|
+
// the run. Walk up from the test directory and take the first config found.
|
|
72
|
+
let jestCfg = '';
|
|
73
|
+
for (let dir = rel;; dir = dirname(dir)) {
|
|
74
|
+
const hit = ['jest.config.js', 'jest.config.cjs', 'jest.config.ts']
|
|
75
|
+
.map((f) => (dir === '.' ? f : join(dir, f)))
|
|
76
|
+
.find((f) => existsSync(join(root, f)));
|
|
77
|
+
if (hit) {
|
|
78
|
+
jestCfg = ` --config=${hit}`;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
if (dir === '.' || dir === dirname(dir))
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
if (paths.length)
|
|
85
|
+
return `npx jest${jestCfg} ${paths.join(' ')}`;
|
|
86
|
+
return journey
|
|
87
|
+
? `npx jest${jestCfg} --testPathPattern "${journey}"`
|
|
88
|
+
: `npx jest${jestCfg} ${rel}`;
|
|
89
|
+
}
|
|
31
90
|
export function runTests(opts = {}) {
|
|
32
91
|
const config = loadConfig();
|
|
33
92
|
const envFile = opts.envFile ?? config.envFile ?? '.env.testing';
|
|
@@ -55,36 +114,7 @@ export function runTests(opts = {}) {
|
|
|
55
114
|
const framework = resolveFramework(config, area, area);
|
|
56
115
|
const root = packageRoot(testDir);
|
|
57
116
|
const rel = relative(root, testDir) || '.';
|
|
58
|
-
let cmd = config.
|
|
59
|
-
if (!cmd) {
|
|
60
|
-
if (framework === 'playwright') {
|
|
61
|
-
const target = opts.journey ? `${rel}/${opts.journey}` : rel;
|
|
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}`;
|
|
81
|
-
}
|
|
82
|
-
else {
|
|
83
|
-
cmd = opts.journey
|
|
84
|
-
? `npx jest --testPathPattern "${opts.journey}"`
|
|
85
|
-
: `npx jest ${rel}`;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
117
|
+
let cmd = runnerCommand(config, area, framework, root, rel, opts.journey);
|
|
88
118
|
if (opts.passThrough?.length)
|
|
89
119
|
cmd += ` ${opts.passThrough.join(' ')}`;
|
|
90
120
|
ranAnything = true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@activate-core/test-forge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
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": {
|