@activate-core/test-forge 0.18.1 → 0.18.3
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 +17 -4
- package/dist/core/ai-generator.js +38 -0
- package/dist/core/config.js +3 -0
- package/dist/core/discover.js +28 -3
- package/dist/core/harness.js +24 -5
- package/dist/core/parser.js +29 -1
- package/dist/core/reference.js +4 -1
- package/dist/core/run-generate.js +158 -14
- package/dist/core/validate.js +26 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -77,6 +77,7 @@ function printUsage() {
|
|
|
77
77
|
console.log(' testforge generate Generate tests from the journey docs');
|
|
78
78
|
console.log(' testforge generate --basic Use the basic generator only');
|
|
79
79
|
console.log(' testforge generate --force Regenerate everything');
|
|
80
|
+
console.log(' testforge generate --no-wait Stop when the AI hits its usage limit (default: wait for the reset and resume)');
|
|
80
81
|
console.log(' testforge generate --journey <name> Generate just ONE journey');
|
|
81
82
|
console.log(' testforge generate --area <area> Generate a whole area (e.g. api)');
|
|
82
83
|
console.log(' testforge discover Draft journey docs (asks: map or reference)');
|
|
@@ -136,14 +137,22 @@ async function main() {
|
|
|
136
137
|
console.log(useAI
|
|
137
138
|
? 'Generating tests with AI for changed journeys...\n'
|
|
138
139
|
: 'Generating tests with the basic generator...\n');
|
|
139
|
-
const results = await runGenerate('docs', useAI, force, flagValue('--journey'), flagValue('--area')
|
|
140
|
+
const results = await runGenerate('docs', useAI, force, flagValue('--journey'), flagValue('--area'), {
|
|
141
|
+
waitForLimit: process.argv.includes('--no-wait')
|
|
142
|
+
? false
|
|
143
|
+
: process.argv.includes('--wait-for-limit')
|
|
144
|
+
? true
|
|
145
|
+
: undefined, // undefined -> config.waitForLimit (default on)
|
|
146
|
+
});
|
|
140
147
|
if (results.length === 0) {
|
|
141
148
|
console.log('No journey files found in docs/. Add a .md file and try again.');
|
|
142
149
|
}
|
|
143
150
|
else {
|
|
144
151
|
for (const r of results) {
|
|
145
152
|
if (r.status === 'skipped') {
|
|
146
|
-
|
|
153
|
+
// A row with no test dir is the run-level notice (AI unavailable), not an
|
|
154
|
+
// unchanged journey — do not label it as one.
|
|
155
|
+
console.log(r.testDir ? ` [skip] ${r.journeyName} (unchanged)` : ` [skip] ${r.journeyName}`);
|
|
147
156
|
}
|
|
148
157
|
else {
|
|
149
158
|
const tag = r.status === 'ai' ? '[AI]' : '[basic]';
|
|
@@ -166,9 +175,13 @@ async function main() {
|
|
|
166
175
|
}
|
|
167
176
|
}
|
|
168
177
|
const generated = results.filter((r) => r.status !== 'skipped').length;
|
|
169
|
-
|
|
178
|
+
// Rows with no test dir are notices (a non-journey file, the AI going
|
|
179
|
+
// away), not journeys that were unchanged — count them apart.
|
|
180
|
+
const notices = results.filter((r) => r.status === 'skipped' && !r.testDir).length;
|
|
181
|
+
const skipped = results.filter((r) => r.status === 'skipped' && r.testDir).length;
|
|
170
182
|
const seedFailures = results.filter((r) => r.seedStatus === 'failed').length;
|
|
171
|
-
console.log(`\nDone. ${generated} generated, ${skipped} skipped (unchanged)
|
|
183
|
+
console.log(`\nDone. ${generated} generated, ${skipped} skipped (unchanged)` +
|
|
184
|
+
(notices ? `, ${notices} notice(s) above.` : '.'));
|
|
172
185
|
if (seedFailures > 0) {
|
|
173
186
|
console.log(`Warning: ${seedFailures} seed file(s) could not be generated — see notes above.`);
|
|
174
187
|
}
|
|
@@ -254,6 +254,28 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
|
|
|
254
254
|
` the fixture and every later suite reads it as real — one defect`,
|
|
255
255
|
` becomes a hundred failures in journeys that were never broken, and`,
|
|
256
256
|
` the run cannot be trusted until someone repairs the data by hand.`,
|
|
257
|
+
` CLEAN UP BY A HANDLE THAT CANNOT GO MISSING. If cleanup matches a`,
|
|
258
|
+
` name prefix, check that every record the file creates can actually`,
|
|
259
|
+
` carry that prefix — validation minimums, enum values and generated`,
|
|
260
|
+
` identifiers routinely forbid it. One record the filter cannot see`,
|
|
261
|
+
` survives, and the next run collides with its own leftovers and`,
|
|
262
|
+
` reports the collision as a product error. Prefer an owning field`,
|
|
263
|
+
` that is always set (a parent, an owner id, a namespace).`,
|
|
264
|
+
`(12) A ROLE'S CREDENTIAL MUST BE THAT ROLE'S — NEVER FALL THROUGH TO`,
|
|
265
|
+
` ANOTHER ONE. When a test needs a specific actor (an admin, a staff`,
|
|
266
|
+
` user, a second customer) and that actor's credential is missing or`,
|
|
267
|
+
` expired, the answer is to SKIP, not to reach for whichever token`,
|
|
268
|
+
` the harness happens to hold. A live token for the wrong actor`,
|
|
269
|
+
` passes every "is it present / is it in date" check and then gets`,
|
|
270
|
+
` 401/403 from every route the real actor is allowed to use — which`,
|
|
271
|
+
` reads exactly like the product refusing legitimate access. This is`,
|
|
272
|
+
` the single most expensive mistake available to you: one fallback in`,
|
|
273
|
+
` a shared helper turns a whole area's suite into false defects, and`,
|
|
274
|
+
` nothing in the output says the wrong identity was used. So: resolve`,
|
|
275
|
+
` each role from its OWN source, never chain roles in a fallback`,
|
|
276
|
+
` list, and treat a template placeholder (DUMMY, replace_me, an empty`,
|
|
277
|
+
` value, a bracketed name) as absent rather than as a credential.`,
|
|
278
|
+
` Say which actor was missing in the skip reason.`,
|
|
257
279
|
`When rules conflict, prefer reporting LESS: a pending test costs a line`,
|
|
258
280
|
`in the report; a false failure costs the team a release.`,
|
|
259
281
|
``,
|
|
@@ -522,6 +544,22 @@ export async function generateWithAIAsync(journey, testPath, focus, harnessPath,
|
|
|
522
544
|
const stdout = await runWithStdin(program, args, prompt, config.aiTimeoutMs);
|
|
523
545
|
return cleanOutput(stdout);
|
|
524
546
|
}
|
|
547
|
+
/**
|
|
548
|
+
* True if the AI CLI answers at all. Used to wait out a usage limit: the CLI
|
|
549
|
+
* exits non-zero with a limit message until the window resets, then answers
|
|
550
|
+
* again — this is the cheapest question that tells the two apart.
|
|
551
|
+
*/
|
|
552
|
+
export async function pingAi(timeoutMs = 60_000) {
|
|
553
|
+
try {
|
|
554
|
+
const config = loadConfig();
|
|
555
|
+
const { program, args } = splitCommand(config.aiCommand);
|
|
556
|
+
const out = await runWithStdin(program, args, 'Reply with exactly: PONG', timeoutMs);
|
|
557
|
+
return /PONG/.test(out);
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
525
563
|
/** Does this line look like the start (or body) of real code? */
|
|
526
564
|
function looksLikeCode(line) {
|
|
527
565
|
const t = line.trim();
|
package/dist/core/config.js
CHANGED
|
@@ -39,6 +39,9 @@ const DEFAULT_CONFIG = {
|
|
|
39
39
|
testCommand: {},
|
|
40
40
|
envFile: '.env.testing',
|
|
41
41
|
externalModules: [],
|
|
42
|
+
waitForLimit: true,
|
|
43
|
+
aiWaitPollMs: 20 * 60_000,
|
|
44
|
+
aiWaitMaxMs: 8 * 3_600_000,
|
|
42
45
|
};
|
|
43
46
|
/** Load config from the project, falling back to defaults. */
|
|
44
47
|
export function loadConfig() {
|
package/dist/core/discover.js
CHANGED
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* keeps this simple and works for projects of any size.
|
|
16
16
|
*/
|
|
17
17
|
import { execFileSync } from 'node:child_process';
|
|
18
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
19
|
-
import { join, relative, sep } from 'node:path';
|
|
18
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, renameSync, } from 'node:fs';
|
|
19
|
+
import { join, relative, sep, basename } from 'node:path';
|
|
20
|
+
import { notAJourneyDoc } from './parser.js';
|
|
20
21
|
import { loadConfig, splitCommand, withEditTools } from './config.js';
|
|
21
22
|
import { DEEP_DOC_GUIDANCE } from './doc-guidance.js';
|
|
22
23
|
import { promptConfirm } from './prompt.js';
|
|
@@ -58,7 +59,10 @@ export function buildDiscoverPrompt(srcDir, docsDir, journey) {
|
|
|
58
59
|
` "${docsDir}/web/sign-in.md"`,
|
|
59
60
|
` "${docsDir}/ops/login.md"`,
|
|
60
61
|
`Do NOT put area in the file name (no "api-payments.md") — the area`,
|
|
61
|
-
`is the folder. Create the area subfolders as needed
|
|
62
|
+
`is the folder. Create the area subfolders as needed. Write NOTHING at`,
|
|
63
|
+
`the root of "${docsDir}" and no file that is not a journey doc — a`,
|
|
64
|
+
`shared note that every journey should know about goes under`,
|
|
65
|
+
`"${docsDir}/_reference/" and is never a journey.`,
|
|
62
66
|
``,
|
|
63
67
|
`Each file should be a RICH description of the journey. Start with`,
|
|
64
68
|
`this header (required):`,
|
|
@@ -208,6 +212,27 @@ export async function runDiscover(srcDir, docsDir, opts = {}, journey) {
|
|
|
208
212
|
else if (prior !== text)
|
|
209
213
|
rewritten.push(path);
|
|
210
214
|
}
|
|
215
|
+
// The prompt says "<area>/<journey>.md". The model still sometimes leaves a
|
|
216
|
+
// shared side-note at the docs root ("data traps every journey must know
|
|
217
|
+
// about"). Useful text, wrong place: generate would read it as a journey
|
|
218
|
+
// and build a suite named after the file. Move such files under
|
|
219
|
+
// _reference/ — kept, never a journey — and drop them from the record.
|
|
220
|
+
const quarantined = [];
|
|
221
|
+
for (const path of [...added]) {
|
|
222
|
+
const why = notAJourneyDoc(path, after.get(path));
|
|
223
|
+
if (!why)
|
|
224
|
+
continue;
|
|
225
|
+
const dest = join('_reference', basename(path));
|
|
226
|
+
mkdirSync(join(docsDir, '_reference'), { recursive: true });
|
|
227
|
+
renameSync(join(docsDir, path), join(docsDir, dest));
|
|
228
|
+
added.splice(added.indexOf(path), 1);
|
|
229
|
+
quarantined.push(`${path} -> ${dest} (${why})`);
|
|
230
|
+
}
|
|
231
|
+
if (quarantined.length) {
|
|
232
|
+
console.log(`\n${quarantined.length} file(s) were not journey docs and were moved under ` +
|
|
233
|
+
`${docsDir}/_reference/ (kept as notes, never generated from):\n ` +
|
|
234
|
+
quarantined.join('\n '));
|
|
235
|
+
}
|
|
211
236
|
// Put back anything it reached for that this reference does not own. The
|
|
212
237
|
// prompt asks it not to; this makes it true whether or not it complied.
|
|
213
238
|
const restored = rewritten.filter((path) => wholeProject ? ownedElsewhere.has(path) : true);
|
package/dist/core/harness.js
CHANGED
|
@@ -138,11 +138,30 @@ export const HARNESS_CONTRACT = [
|
|
|
138
138
|
`from the AUTHENTICATED TOKEN, not from a query param — and a route`,
|
|
139
139
|
`declared auth:false may STILL populate request.user via a pre-auth`,
|
|
140
140
|
`extension when a Bearer is sent. Therefore:`,
|
|
141
|
-
` - setup() MUST resolve the authenticated identity's REAL id
|
|
142
|
-
`
|
|
143
|
-
`
|
|
144
|
-
`
|
|
145
|
-
` the
|
|
141
|
+
` - setup() MUST resolve the authenticated identity's REAL id by ASKING`,
|
|
142
|
+
` THE PRODUCT — the profile/whoami/detail endpoint — and seed the`,
|
|
143
|
+
` OWNER fixtures (whichever role in this product owns the data the`,
|
|
144
|
+
` tests read) under THAT id, then set the TEST_IDS owner ids to it.`,
|
|
145
|
+
` The token's subject is the IDENTITY PROVIDER's handle, not the`,
|
|
146
|
+
` app's record id: "sms|<hex>" and "auth0|<hex>" look like an id with`,
|
|
147
|
+
` the prefix stripped, and the value underneath is routinely a`,
|
|
148
|
+
` DIFFERENT record from the one the app stores. Decode the subject`,
|
|
149
|
+
` only as a fallback when the endpoint cannot be reached, and say so`,
|
|
150
|
+
` where you do it. Seeding from a provider subject writes an id the`,
|
|
151
|
+
` app can never cast: owner reads come back empty and writes answer`,
|
|
152
|
+
` 500, both of which read as the product being broken.`,
|
|
153
|
+
` - A seeded BROWSER session carries the same id. Whatever the app`,
|
|
154
|
+
` stores about the signed-in user (its web storage keys) must hold`,
|
|
155
|
+
` the resolved app id, because the client copies it into the`,
|
|
156
|
+
` payloads it sends. And resolve it FOR THE ROLE being seeded — a`,
|
|
157
|
+
` maker session built from the wearer's identity tests nothing.`,
|
|
158
|
+
` - Each role resolves from its OWN credential. Never chain roles in a`,
|
|
159
|
+
` fallback list: a live token for the wrong actor passes every`,
|
|
160
|
+
` presence and expiry check and then collects 401/403 from every`,
|
|
161
|
+
` route the real actor is allowed to use. Treat a template`,
|
|
162
|
+
` placeholder (DUMMY, replace_me, empty, <bracketed>) as absent, and`,
|
|
163
|
+
` when a role's credential is missing, SKIP with its name in the`,
|
|
164
|
+
` reason.`,
|
|
146
165
|
` - Do NOT hardcode an owner id the token does not own: the seeded rows`,
|
|
147
166
|
` would never be returned to the authenticated caller, and every`,
|
|
148
167
|
` owner-scoped test would fail for a fake reason.`,
|
package/dist/core/parser.js
CHANGED
|
@@ -65,6 +65,24 @@ function parseField(line) {
|
|
|
65
65
|
const required = parts.includes('required');
|
|
66
66
|
return { name, type, required };
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Is this file a journey doc at all? The layout discover promises — and every
|
|
70
|
+
* downstream step assumes — is "<area>/<journey>.md" with a "# Journey:"
|
|
71
|
+
* heading. Anything else under docs/ is reference material: a project's own
|
|
72
|
+
* notes checked in before TestForge arrived, or a shared side-note the AI wrote
|
|
73
|
+
* next to the journeys. Treating those as journeys produced test folders named
|
|
74
|
+
* after the file and suites with no name. Returns the reason when it is not.
|
|
75
|
+
*/
|
|
76
|
+
export function notAJourneyDoc(relPath, markdown) {
|
|
77
|
+
const parts = relPath.split(/[\\/]/).filter(Boolean);
|
|
78
|
+
if (parts.length < 2)
|
|
79
|
+
return 'sits at the docs root, not in an area folder';
|
|
80
|
+
if (parts[0].startsWith('_'))
|
|
81
|
+
return `lives under ${parts[0]}/, which holds reference material`;
|
|
82
|
+
if (!/^#\s*Journey:/m.test(markdown))
|
|
83
|
+
return 'has no "# Journey:" heading';
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
68
86
|
/**
|
|
69
87
|
* Parse a full markdown string into a Journey object.
|
|
70
88
|
*
|
|
@@ -72,7 +90,7 @@ function parseField(line) {
|
|
|
72
90
|
* output location and framework. When omitted (a journey read outside an
|
|
73
91
|
* area folder), it falls back to the parsed `Type:`.
|
|
74
92
|
*/
|
|
75
|
-
export function parseJourney(markdown, area) {
|
|
93
|
+
export function parseJourney(markdown, area, fallbackName) {
|
|
76
94
|
const lines = markdown.split('\n').map((l) => l.trim());
|
|
77
95
|
let name = '';
|
|
78
96
|
let type = 'web';
|
|
@@ -115,6 +133,16 @@ export function parseJourney(markdown, area) {
|
|
|
115
133
|
}
|
|
116
134
|
}
|
|
117
135
|
}
|
|
136
|
+
// Not every doc opens with "# Journey: …". A reference dropped straight
|
|
137
|
+
// into docs/ usually carries a plain title; take that, and failing any
|
|
138
|
+
// heading at all, the file's own name. An empty name became an empty id and
|
|
139
|
+
// a test folder literally called "<file>.md", listed with no journey name.
|
|
140
|
+
if (!name) {
|
|
141
|
+
const heading = lines.find((l) => /^#{1,6}\s+\S/.test(l) && !/^#{1,6}\s+TODO\b/i.test(l));
|
|
142
|
+
name = heading
|
|
143
|
+
? heading.replace(/^#{1,6}\s+/, '').replace(/^Journey:\s*/i, '').trim()
|
|
144
|
+
: (fallbackName ?? '').trim();
|
|
145
|
+
}
|
|
118
146
|
return {
|
|
119
147
|
id: toId(name),
|
|
120
148
|
name,
|
package/dist/core/reference.js
CHANGED
|
@@ -175,7 +175,10 @@ export function buildReferenceDiscoverPrompt(srcDir, docsDir, reference, journey
|
|
|
175
175
|
`- Where the reference is silent on a needed detail, add a "# TODO:"`,
|
|
176
176
|
` line describing what the developer must verify.`,
|
|
177
177
|
`- Never include secrets — refer to env var names only.`,
|
|
178
|
-
`- Create the area subfolders as needed
|
|
178
|
+
`- Create the area subfolders as needed. Write NOTHING at the root of`,
|
|
179
|
+
` "${docsDir}" and no file that is not a journey doc — a shared note`,
|
|
180
|
+
` every journey should know about goes under "${docsDir}/_reference/"`,
|
|
181
|
+
` and is never a journey.`,
|
|
179
182
|
`- After creating the files, print the list of journeys you documented`,
|
|
180
183
|
` and remind the developer to review them.`,
|
|
181
184
|
].join('\n');
|
|
@@ -17,13 +17,13 @@
|
|
|
17
17
|
* generated-tests/ when not configured.
|
|
18
18
|
*/
|
|
19
19
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, } from 'node:fs';
|
|
20
|
-
import { join, dirname } from 'node:path';
|
|
20
|
+
import { join, dirname, basename, extname } from 'node:path';
|
|
21
21
|
import { CACHE_PATH, COVERAGE_PATH, PLANS_DIR, STATE_DIR } from './paths.js';
|
|
22
22
|
import { lintGenerated } from './lint.js';
|
|
23
|
-
import { parseJourney } from './parser.js';
|
|
23
|
+
import { parseJourney, notAJourneyDoc } from './parser.js';
|
|
24
24
|
import { generateTestCases } from './generator.js';
|
|
25
25
|
import { writeDeferredStub } from './playwright-writer.js';
|
|
26
|
-
import { generateWithAI, generateWithAIAsync } from './ai-generator.js';
|
|
26
|
+
import { generateWithAI, generateWithAIAsync, pingAi, } from './ai-generator.js';
|
|
27
27
|
import { groundJourney } from './grounding.js';
|
|
28
28
|
import { generateSeed } from './seed-generator.js';
|
|
29
29
|
import { planSuite } from './suite-planner.js';
|
|
@@ -57,14 +57,66 @@ async function pool(items, limit, fn) {
|
|
|
57
57
|
await Promise.all(Array.from({ length: n }, () => worker()));
|
|
58
58
|
return results;
|
|
59
59
|
}
|
|
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
|
+
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;
|
|
72
|
+
function noteAiFailure(e) {
|
|
73
|
+
const msg = (e instanceof Error ? e.message : String(e)).replace(/\s+/g, ' ').trim();
|
|
74
|
+
lastAiFailure = msg.slice(0, 300);
|
|
75
|
+
if (AI_UNAVAILABLE.test(msg))
|
|
76
|
+
aiUnavailable = lastAiFailure;
|
|
77
|
+
}
|
|
78
|
+
function aiFailureNote() {
|
|
79
|
+
return lastAiFailure ? ` — last AI error: ${lastAiFailure}` : '';
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Wait for the AI CLI to answer again after a usage limit. Polls with a
|
|
83
|
+
* one-line question every `aiWaitPollMs` for up to `aiWaitMaxMs`; true when
|
|
84
|
+
* it is back (and the failure state is cleared), false when the cap is hit.
|
|
85
|
+
* Progress goes to the console because a run that sits silent for hours
|
|
86
|
+
* looks hung.
|
|
87
|
+
*/
|
|
88
|
+
async function waitForAi(config) {
|
|
89
|
+
const poll = config.aiWaitPollMs ?? 20 * 60_000;
|
|
90
|
+
const max = config.aiWaitMaxMs ?? 8 * 3_600_000;
|
|
91
|
+
const mins = (ms) => `${Math.max(1, Math.round(ms / 60_000))} min`;
|
|
92
|
+
console.log(`\nAI unavailable: ${aiUnavailable}\n` +
|
|
93
|
+
`Waiting for it to come back — checking every ${mins(poll)}, for up to ${mins(max)}. ` +
|
|
94
|
+
`Ctrl-C is safe: re-running later resumes from exactly here.`);
|
|
95
|
+
const started = Date.now();
|
|
96
|
+
while (Date.now() - started < max) {
|
|
97
|
+
await new Promise((r) => setTimeout(r, poll));
|
|
98
|
+
if (await pingAi(60_000)) {
|
|
99
|
+
console.log(`AI is back (${new Date().toLocaleTimeString()}) — resuming.`);
|
|
100
|
+
aiUnavailable = null;
|
|
101
|
+
lastAiFailure = null;
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
console.log(`${new Date().toLocaleTimeString()} still unavailable — next check in ${mins(poll)}`);
|
|
105
|
+
}
|
|
106
|
+
console.log(`Gave up waiting after ${mins(max)}.`);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
60
109
|
/** Async version of generateValidated (for concurrent per-file gen). */
|
|
61
110
|
async function generateValidatedAsync(generate, ext, gate) {
|
|
62
111
|
let content = '';
|
|
63
112
|
for (let attempt = 0; attempt < MAX_GEN_ATTEMPTS; attempt++) {
|
|
113
|
+
if (aiUnavailable)
|
|
114
|
+
return null; // retrying a dead CLI only burns time
|
|
64
115
|
try {
|
|
65
116
|
content = await generate();
|
|
66
117
|
}
|
|
67
|
-
catch {
|
|
118
|
+
catch (e) {
|
|
119
|
+
noteAiFailure(e);
|
|
68
120
|
continue;
|
|
69
121
|
}
|
|
70
122
|
if (isSyntaxValid(content, ext) && !(gate && gate(content)))
|
|
@@ -80,11 +132,14 @@ async function generateValidatedAsync(generate, ext, gate) {
|
|
|
80
132
|
function generateValidated(generate, ext, gate) {
|
|
81
133
|
let content = '';
|
|
82
134
|
for (let attempt = 0; attempt < MAX_GEN_ATTEMPTS; attempt++) {
|
|
135
|
+
if (aiUnavailable)
|
|
136
|
+
return null; // retrying a dead CLI only burns time
|
|
83
137
|
try {
|
|
84
138
|
content = generate();
|
|
85
139
|
}
|
|
86
|
-
catch {
|
|
87
|
-
// generation (or output cleaning) threw — retry
|
|
140
|
+
catch (e) {
|
|
141
|
+
// generation (or output cleaning) threw — remember why, then retry
|
|
142
|
+
noteAiFailure(e);
|
|
88
143
|
continue;
|
|
89
144
|
}
|
|
90
145
|
if (isSyntaxValid(content, ext) && !(gate && gate(content))) {
|
|
@@ -318,9 +373,9 @@ function matchesJourney(file, journeyId, sel) {
|
|
|
318
373
|
norm(stem).endsWith(`-${n}`) ||
|
|
319
374
|
norm(stem).split('/').pop() === n);
|
|
320
375
|
}
|
|
321
|
-
export async function runGenerate(docsDir, useAI = true, force = false, only, areaOnly) {
|
|
322
|
-
const
|
|
323
|
-
if (
|
|
376
|
+
export async function runGenerate(docsDir, useAI = true, force = false, only, areaOnly, opts = {}) {
|
|
377
|
+
const allFiles = findMarkdownFiles(docsDir);
|
|
378
|
+
if (allFiles.length === 0) {
|
|
324
379
|
return [];
|
|
325
380
|
}
|
|
326
381
|
const config = loadConfig();
|
|
@@ -329,6 +384,36 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
329
384
|
const seedExt = ext.endsWith('.js') ? '.seed.js' : '.seed.ts';
|
|
330
385
|
const cache = loadCache(CACHE_PATH);
|
|
331
386
|
const results = [];
|
|
387
|
+
lastAiFailure = null;
|
|
388
|
+
aiUnavailable = null;
|
|
389
|
+
// Only journey docs are generated from. A file at the docs root, under a
|
|
390
|
+
// "_reference/" folder, or without a "# Journey:" heading is reference
|
|
391
|
+
// material — report it once and leave it alone rather than inventing a
|
|
392
|
+
// journey (and a test folder) out of it.
|
|
393
|
+
for (const file of allFiles) {
|
|
394
|
+
let why = null;
|
|
395
|
+
try {
|
|
396
|
+
why = notAJourneyDoc(file, readFileSync(join(docsDir, file), 'utf-8'));
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
why = 'could not be read';
|
|
400
|
+
}
|
|
401
|
+
if (!why)
|
|
402
|
+
continue;
|
|
403
|
+
results.push({
|
|
404
|
+
journeyName: file,
|
|
405
|
+
testDir: '',
|
|
406
|
+
testFiles: [],
|
|
407
|
+
seedPath: '',
|
|
408
|
+
status: 'skipped',
|
|
409
|
+
seedStatus: 'none',
|
|
410
|
+
warnings: [
|
|
411
|
+
`not a journey doc — it ${why}. Kept as reference material and ignored by generate. ` +
|
|
412
|
+
`Journeys live at ${docsDir}/<area>/<name>.md and open with "# Journey: …".`,
|
|
413
|
+
],
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
const files = allFiles.filter((f) => !results.some((r) => r.journeyName === f));
|
|
332
417
|
// Real UI test-hooks per area, extracted once from the components — feeds
|
|
333
418
|
// the F10 selector-grounding gate so generated UI tests can only assert on
|
|
334
419
|
// selectors that genuinely exist (never an invented data-testid).
|
|
@@ -363,15 +448,58 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
363
448
|
harnessByArea.set(area, info.path);
|
|
364
449
|
return info.path;
|
|
365
450
|
}
|
|
366
|
-
|
|
451
|
+
const waitForLimit = opts.waitForLimit ?? config.waitForLimit ?? true;
|
|
452
|
+
let notAttempted = 0;
|
|
453
|
+
const redone = new Set();
|
|
454
|
+
// Where each journey's result row sits, so a redo after the AI comes back
|
|
455
|
+
// replaces the degraded row instead of reporting the journey twice (once as
|
|
456
|
+
// stubs, once real) and counting the stale stubs in the coverage header.
|
|
457
|
+
const resultRow = new Map();
|
|
458
|
+
for (let i = 0; i < files.length; i++) {
|
|
459
|
+
const file = files[i];
|
|
460
|
+
// The AI is gone (limit reached, logged out, not installed). Every further
|
|
461
|
+
// journey would only become deferred stubs. Either wait for it to come back
|
|
462
|
+
// (--wait-for-limit) or stop here; either way the stubs already written are
|
|
463
|
+
// re-attempted automatically on the next run.
|
|
464
|
+
if (aiUnavailable && useAI) {
|
|
465
|
+
if (waitForLimit && (await waitForAi(config))) {
|
|
466
|
+
// A shared harness that failed while the AI was down was memoised as
|
|
467
|
+
// "none" for its area; forget that so the redo below rebuilds it.
|
|
468
|
+
for (const [k, v] of harnessByArea)
|
|
469
|
+
if (v === null)
|
|
470
|
+
harnessByArea.delete(k);
|
|
471
|
+
// The journey BEFORE this one degraded into stubs while the AI was
|
|
472
|
+
// going down. Its real files are kept by per-file resume, so redoing
|
|
473
|
+
// it now costs only the stubs — once, then carry on.
|
|
474
|
+
if (i > 0 && !redone.has(i - 1)) {
|
|
475
|
+
redone.add(i - 1);
|
|
476
|
+
const stale = resultRow.get(files[i - 1]);
|
|
477
|
+
if (stale !== undefined) {
|
|
478
|
+
results.splice(stale, 1);
|
|
479
|
+
resultRow.delete(files[i - 1]);
|
|
480
|
+
}
|
|
481
|
+
i -= 2;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
notAttempted += 1;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
367
490
|
const markdown = readFileSync(join(docsDir, file), 'utf-8');
|
|
368
491
|
const hash = hashContent(markdown);
|
|
369
492
|
// The area is the doc's top-level folder (api/web/ops/lambda/...). It
|
|
370
493
|
// decides where the test goes and which framework is used — NOT the
|
|
371
494
|
// "Type:" line, which only says UI-vs-endpoint. This keeps ops/lambda
|
|
372
495
|
// journeys in their own package instead of leaking into web/api.
|
|
373
|
-
|
|
374
|
-
|
|
496
|
+
//
|
|
497
|
+
// A doc dropped straight into docs/ has NO folder: splitting on "/" then
|
|
498
|
+
// returns the file name itself, which became the "area" and produced a
|
|
499
|
+
// test folder literally called generated-tests/<file>.md/. No folder means
|
|
500
|
+
// no area — the parser falls back to the doc's Type.
|
|
501
|
+
const area = /[\\/]/.test(file) ? file.split(/[\\/]/)[0] : undefined;
|
|
502
|
+
const journey = parseJourney(markdown, area, basename(file, extname(file)));
|
|
375
503
|
// --area <a>: only this area's journeys (e.g. do all of api first).
|
|
376
504
|
if (areaOnly && journey.area !== areaOnly)
|
|
377
505
|
continue;
|
|
@@ -553,7 +681,7 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
553
681
|
if (useAI && deferred.length) {
|
|
554
682
|
warnings.push(`${deferred.length} file(s) could not be generated with AI and were left as DEFERRED stubs (pending, not real coverage): ${deferred
|
|
555
683
|
.map((t) => t.slug)
|
|
556
|
-
.join(', ')}`);
|
|
684
|
+
.join(', ')}${aiFailureNote()}`);
|
|
557
685
|
}
|
|
558
686
|
// 2. Seed file (only when using AI — basic mode has no seed concept).
|
|
559
687
|
let seedStatus = 'none';
|
|
@@ -568,7 +696,7 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
568
696
|
// Do NOT write a broken seed. Report it so the user sees which
|
|
569
697
|
// seeds were not produced.
|
|
570
698
|
seedStatus = 'failed';
|
|
571
|
-
warnings.push(`seed generation failed after ${MAX_GEN_ATTEMPTS} attempts — no seed written`);
|
|
699
|
+
warnings.push(`seed generation failed after ${MAX_GEN_ATTEMPTS} attempts — no seed written${aiFailureNote()}`);
|
|
572
700
|
}
|
|
573
701
|
}
|
|
574
702
|
// Only mark a journey "done" in the cache when it actually SUCCEEDED
|
|
@@ -605,6 +733,7 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
605
733
|
delete cache[file];
|
|
606
734
|
}
|
|
607
735
|
saveCache(CACHE_PATH, cache);
|
|
736
|
+
resultRow.set(file, results.length);
|
|
608
737
|
results.push({
|
|
609
738
|
journeyName: journey.name,
|
|
610
739
|
testDir,
|
|
@@ -615,6 +744,21 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
615
744
|
warnings: warnings.length ? warnings : undefined,
|
|
616
745
|
});
|
|
617
746
|
}
|
|
747
|
+
if (notAttempted > 0 && aiUnavailable) {
|
|
748
|
+
// One line, not one per journey: the operator needs the reason and the
|
|
749
|
+
// count, then to re-run once the CLI is back.
|
|
750
|
+
results.push({
|
|
751
|
+
journeyName: `${notAttempted} journey(s) not attempted — AI unavailable`,
|
|
752
|
+
testDir: '',
|
|
753
|
+
testFiles: [],
|
|
754
|
+
seedPath: '',
|
|
755
|
+
status: 'skipped',
|
|
756
|
+
seedStatus: 'none',
|
|
757
|
+
warnings: [
|
|
758
|
+
`${aiUnavailable}. Nothing was written for these; re-run \`testforge generate\` when the AI CLI is back — deferred stubs from this run are re-attempted automatically.`,
|
|
759
|
+
],
|
|
760
|
+
});
|
|
761
|
+
}
|
|
618
762
|
saveCache(CACHE_PATH, cache);
|
|
619
763
|
writeCoverageManifest(results);
|
|
620
764
|
return results;
|
package/dist/core/validate.js
CHANGED
|
@@ -70,6 +70,32 @@ export function checkTestGates(content, _ext, ctx) {
|
|
|
70
70
|
return `goto() has an unresolved route placeholder — fill it with a real value: "${arg.slice(0, 40)}"`;
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
+
// F13 — a swallowed action must be bounded. `.click({ force: true })`
|
|
74
|
+
// followed by `.catch(() => {})` is the shape used for a tap that is
|
|
75
|
+
// EXPECTED to do nothing (the handler has already disabled the control).
|
|
76
|
+
// `force` skips the actionability checks but still waits for the element,
|
|
77
|
+
// and with no timeout that wait is unbounded: the swallowed promise sits
|
|
78
|
+
// until the test deadline and the failure then surfaces on whatever line
|
|
79
|
+
// ran next, naming an innocent call. Any `.catch()`-swallowed action must
|
|
80
|
+
// carry its own timeout.
|
|
81
|
+
const swallowed = /\.(?:click|fill|press|hover|check|uncheck|selectOption|tap)\(\s*\{[^}]*\}\s*\)\s*\.catch\s*\(/g;
|
|
82
|
+
let sw;
|
|
83
|
+
while ((sw = swallowed.exec(content)) !== null) {
|
|
84
|
+
if (!/timeout\s*:/.test(sw[0])) {
|
|
85
|
+
return ('an action whose failure is swallowed with .catch() has no timeout — ' +
|
|
86
|
+
'it will wait until the whole test times out and blame a later line');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// F14 — arm the wait BEFORE the thing it waits for. A waitForResponse /
|
|
90
|
+
// waitForRequest / waitForEvent written AFTER the navigation or click that
|
|
91
|
+
// triggers it is a race the test usually loses: the response has already
|
|
92
|
+
// arrived, so the wait sits out its full timeout waiting for a second one
|
|
93
|
+
// that never comes. Hold the promise first, then act, then await it.
|
|
94
|
+
const lateWait = /\b(?:await\s+)?page\.goto\([^\n]*\);?\s*\n(?:\s*\/\/[^\n]*\n)*\s*await\s+page\.waitFor(?:Response|Request|Event)\s*\(/;
|
|
95
|
+
if (lateWait.test(content)) {
|
|
96
|
+
return ('page.waitForResponse/Request is armed AFTER the navigation that triggers ' +
|
|
97
|
+
'it — hold the promise first, then navigate, then await it');
|
|
98
|
+
}
|
|
73
99
|
// F2 — no silent false-green: a file with RUNNABLE tests must assert
|
|
74
100
|
// something. Pending placeholders (test.todo/skip/fixme) are exempt —
|
|
75
101
|
// they don't count as passing. This is what stops an empty-but-green
|
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.3",
|
|
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": {
|