@activate-core/test-forge 0.18.2 → 0.18.4
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 -2
- package/dist/core/ai-generator.js +8 -2
- package/dist/core/discover.js +28 -3
- package/dist/core/parser.js +18 -0
- package/dist/core/reference.js +4 -1
- package/dist/core/run-generate.js +125 -23
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -175,9 +175,13 @@ async function main() {
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
const generated = results.filter((r) => r.status !== 'skipped').length;
|
|
178
|
-
|
|
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;
|
|
179
182
|
const seedFailures = results.filter((r) => r.seedStatus === 'failed').length;
|
|
180
|
-
console.log(`\nDone. ${generated} generated, ${skipped} skipped (unchanged)
|
|
183
|
+
console.log(`\nDone. ${generated} generated, ${skipped} skipped (unchanged)` +
|
|
184
|
+
(notices ? `, ${notices} notice(s) above.` : '.'));
|
|
181
185
|
if (seedFailures > 0) {
|
|
182
186
|
console.log(`Warning: ${seedFailures} seed file(s) could not be generated — see notes above.`);
|
|
183
187
|
}
|
|
@@ -49,8 +49,14 @@ function runWithStdin(program, args, input, timeoutMs = 300_000) {
|
|
|
49
49
|
finish(() => {
|
|
50
50
|
if (code === 0)
|
|
51
51
|
resolve(out);
|
|
52
|
-
else
|
|
53
|
-
|
|
52
|
+
else {
|
|
53
|
+
// The limit message is not always on stderr — some CLI versions
|
|
54
|
+
// print it to stdout, some print nothing at all. Say what we have,
|
|
55
|
+
// and say plainly when that is nothing, so a silent exit is never
|
|
56
|
+
// mistaken for a message-less mystery downstream.
|
|
57
|
+
const detail = (err.trim() || out.trim()).slice(0, 500) || '(no output)';
|
|
58
|
+
reject(new Error(`${program} exited ${code}: ${detail}`));
|
|
59
|
+
}
|
|
54
60
|
});
|
|
55
61
|
});
|
|
56
62
|
child.stdin.end(input);
|
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/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
|
*
|
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');
|
|
@@ -20,7 +20,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync
|
|
|
20
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
26
|
import { generateWithAI, generateWithAIAsync, pingAi, } from './ai-generator.js';
|
|
@@ -68,13 +68,52 @@ async function pool(items, limit, fn) {
|
|
|
68
68
|
// journeys: the deferred stubs are re-attempted automatically on the next run.
|
|
69
69
|
let lastAiFailure = null;
|
|
70
70
|
let aiUnavailable = null;
|
|
71
|
+
// Process failures in a row with no success between them. The CLI does not
|
|
72
|
+
// always SAY it is out — on wagonex it exited 1 with empty stderr for every
|
|
73
|
+
// call and the message-matching below saw nothing to match, so thirteen
|
|
74
|
+
// journeys were burned before anyone noticed. Two silent failures in a row
|
|
75
|
+
// earn a ping; if the ping fails too, the AI is unavailable, message or not.
|
|
76
|
+
let consecutiveAiFailures = 0;
|
|
71
77
|
const AI_UNAVAILABLE = /usage limit|spend limit|limit will reset|rate limit|\b429\b|not logged in|please log ?in|unauthori[sz]ed|invalid api key|credit balance|quota|spawn \S+ ENOENT/i;
|
|
72
78
|
function noteAiFailure(e) {
|
|
73
|
-
|
|
79
|
+
// execFileSync attaches what the process said; node's own message is just
|
|
80
|
+
// "Command failed: <cmd>", which explains nothing.
|
|
81
|
+
const x = e;
|
|
82
|
+
const said = [x.stderr, x.stdout].map((v) => String(v ?? '').trim()).find(Boolean);
|
|
83
|
+
let msg = (x.message ?? String(e)).replace(/\s+/g, ' ').trim();
|
|
84
|
+
if (said && !msg.includes(said.slice(0, 40)))
|
|
85
|
+
msg += `: ${said}`;
|
|
86
|
+
if (x.signal === 'SIGKILL' && !/timed out/.test(msg))
|
|
87
|
+
msg += ' (killed — hit aiTimeoutMs)';
|
|
74
88
|
lastAiFailure = msg.slice(0, 300);
|
|
89
|
+
consecutiveAiFailures += 1;
|
|
75
90
|
if (AI_UNAVAILABLE.test(msg))
|
|
76
91
|
aiUnavailable = lastAiFailure;
|
|
77
92
|
}
|
|
93
|
+
function noteAiRejected(why) {
|
|
94
|
+
// The AI answered, but with something we will not write. Not a sign it is
|
|
95
|
+
// gone — do not count it towards unavailability — but the operator still
|
|
96
|
+
// deserves to know why the file is a stub.
|
|
97
|
+
lastAiFailure = `AI output rejected: ${why}`.slice(0, 300);
|
|
98
|
+
}
|
|
99
|
+
function noteAiSuccess() {
|
|
100
|
+
consecutiveAiFailures = 0;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Two silent failures in a row: ask the CLI directly whether it is alive.
|
|
104
|
+
* Returns true when it was failing but answers now — the caller then redoes
|
|
105
|
+
* the journey those failures degraded, exactly as after a wait.
|
|
106
|
+
*/
|
|
107
|
+
async function confirmAiAlive() {
|
|
108
|
+
if (aiUnavailable || consecutiveAiFailures < 2)
|
|
109
|
+
return false;
|
|
110
|
+
if (!(await pingAi(60_000))) {
|
|
111
|
+
aiUnavailable = lastAiFailure ?? 'the AI CLI is not answering';
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
consecutiveAiFailures = 0;
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
78
117
|
function aiFailureNote() {
|
|
79
118
|
return lastAiFailure ? ` — last AI error: ${lastAiFailure}` : '';
|
|
80
119
|
}
|
|
@@ -119,8 +158,11 @@ async function generateValidatedAsync(generate, ext, gate) {
|
|
|
119
158
|
noteAiFailure(e);
|
|
120
159
|
continue;
|
|
121
160
|
}
|
|
122
|
-
|
|
161
|
+
noteAiSuccess();
|
|
162
|
+
const gateMsg = gate ? gate(content) : null;
|
|
163
|
+
if (isSyntaxValid(content, ext) && !gateMsg)
|
|
123
164
|
return content;
|
|
165
|
+
noteAiRejected(gateMsg || `not valid ${ext} (syntax)`);
|
|
124
166
|
}
|
|
125
167
|
return null;
|
|
126
168
|
}
|
|
@@ -142,7 +184,12 @@ function generateValidated(generate, ext, gate) {
|
|
|
142
184
|
noteAiFailure(e);
|
|
143
185
|
continue;
|
|
144
186
|
}
|
|
145
|
-
|
|
187
|
+
noteAiSuccess();
|
|
188
|
+
const gateMsg = gate ? gate(content) : null;
|
|
189
|
+
if (!(isSyntaxValid(content, ext) && !gateMsg)) {
|
|
190
|
+
noteAiRejected(gateMsg || `not valid ${ext} (syntax)`);
|
|
191
|
+
}
|
|
192
|
+
if (isSyntaxValid(content, ext) && !gateMsg) {
|
|
146
193
|
return content;
|
|
147
194
|
}
|
|
148
195
|
}
|
|
@@ -374,8 +421,8 @@ function matchesJourney(file, journeyId, sel) {
|
|
|
374
421
|
norm(stem).split('/').pop() === n);
|
|
375
422
|
}
|
|
376
423
|
export async function runGenerate(docsDir, useAI = true, force = false, only, areaOnly, opts = {}) {
|
|
377
|
-
const
|
|
378
|
-
if (
|
|
424
|
+
const allFiles = findMarkdownFiles(docsDir);
|
|
425
|
+
if (allFiles.length === 0) {
|
|
379
426
|
return [];
|
|
380
427
|
}
|
|
381
428
|
const config = loadConfig();
|
|
@@ -386,6 +433,35 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
386
433
|
const results = [];
|
|
387
434
|
lastAiFailure = null;
|
|
388
435
|
aiUnavailable = null;
|
|
436
|
+
consecutiveAiFailures = 0;
|
|
437
|
+
// Only journey docs are generated from. A file at the docs root, under a
|
|
438
|
+
// "_reference/" folder, or without a "# Journey:" heading is reference
|
|
439
|
+
// material — report it once and leave it alone rather than inventing a
|
|
440
|
+
// journey (and a test folder) out of it.
|
|
441
|
+
for (const file of allFiles) {
|
|
442
|
+
let why = null;
|
|
443
|
+
try {
|
|
444
|
+
why = notAJourneyDoc(file, readFileSync(join(docsDir, file), 'utf-8'));
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
why = 'could not be read';
|
|
448
|
+
}
|
|
449
|
+
if (!why)
|
|
450
|
+
continue;
|
|
451
|
+
results.push({
|
|
452
|
+
journeyName: file,
|
|
453
|
+
testDir: '',
|
|
454
|
+
testFiles: [],
|
|
455
|
+
seedPath: '',
|
|
456
|
+
status: 'skipped',
|
|
457
|
+
seedStatus: 'none',
|
|
458
|
+
warnings: [
|
|
459
|
+
`not a journey doc — it ${why}. Kept as reference material and ignored by generate. ` +
|
|
460
|
+
`Journeys live at ${docsDir}/<area>/<name>.md and open with "# Journey: …".`,
|
|
461
|
+
],
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
const files = allFiles.filter((f) => !results.some((r) => r.journeyName === f));
|
|
389
465
|
// Real UI test-hooks per area, extracted once from the components — feeds
|
|
390
466
|
// the F10 selector-grounding gate so generated UI tests can only assert on
|
|
391
467
|
// selectors that genuinely exist (never an invented data-testid).
|
|
@@ -427,32 +503,54 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
427
503
|
// replaces the degraded row instead of reporting the journey twice (once as
|
|
428
504
|
// stubs, once real) and counting the stale stubs in the coverage header.
|
|
429
505
|
const resultRow = new Map();
|
|
506
|
+
// Journeys that ended this run with at least one deferred stub while the AI
|
|
507
|
+
// was failing. An outage that ends MID-journey (say, during its seed) leaves
|
|
508
|
+
// the counter reset and the stubs behind; redo such a journey once, as soon
|
|
509
|
+
// as the AI is answering — the stubs are all it costs.
|
|
510
|
+
const stubbed = new Set();
|
|
430
511
|
for (let i = 0; i < files.length; i++) {
|
|
431
512
|
const file = files[i];
|
|
513
|
+
// Redo the journey BEFORE this one: it degraded into stubs while the AI
|
|
514
|
+
// was going down. Its real files are kept by per-file resume, so the redo
|
|
515
|
+
// costs only the stubs — once, then carry on. A shared harness that failed
|
|
516
|
+
// during the outage was memoised as "none" for its area; forget that so
|
|
517
|
+
// the redo rebuilds it. Returns true when a redo was scheduled.
|
|
518
|
+
const redoPrevious = () => {
|
|
519
|
+
for (const [k, v] of harnessByArea)
|
|
520
|
+
if (v === null)
|
|
521
|
+
harnessByArea.delete(k);
|
|
522
|
+
if (i === 0 || redone.has(i - 1))
|
|
523
|
+
return false;
|
|
524
|
+
redone.add(i - 1);
|
|
525
|
+
const stale = resultRow.get(files[i - 1]);
|
|
526
|
+
if (stale !== undefined) {
|
|
527
|
+
results.splice(stale, 1);
|
|
528
|
+
resultRow.delete(files[i - 1]);
|
|
529
|
+
}
|
|
530
|
+
i -= 2;
|
|
531
|
+
return true;
|
|
532
|
+
};
|
|
533
|
+
// Silent failures say nothing to match on — ask the CLI directly. If it
|
|
534
|
+
// was failing a moment ago and answers now, the outage has already passed:
|
|
535
|
+
// redo what it broke and carry on.
|
|
536
|
+
if (useAI && (await confirmAiAlive()) && redoPrevious())
|
|
537
|
+
continue;
|
|
538
|
+
if (useAI &&
|
|
539
|
+
!aiUnavailable &&
|
|
540
|
+
consecutiveAiFailures === 0 &&
|
|
541
|
+
i > 0 &&
|
|
542
|
+
stubbed.has(files[i - 1]) &&
|
|
543
|
+
redoPrevious()) {
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
432
546
|
// The AI is gone (limit reached, logged out, not installed). Every further
|
|
433
547
|
// journey would only become deferred stubs. Either wait for it to come back
|
|
434
548
|
// (--wait-for-limit) or stop here; either way the stubs already written are
|
|
435
549
|
// re-attempted automatically on the next run.
|
|
436
550
|
if (aiUnavailable && useAI) {
|
|
437
551
|
if (waitForLimit && (await waitForAi(config))) {
|
|
438
|
-
|
|
439
|
-
// "none" for its area; forget that so the redo below rebuilds it.
|
|
440
|
-
for (const [k, v] of harnessByArea)
|
|
441
|
-
if (v === null)
|
|
442
|
-
harnessByArea.delete(k);
|
|
443
|
-
// The journey BEFORE this one degraded into stubs while the AI was
|
|
444
|
-
// going down. Its real files are kept by per-file resume, so redoing
|
|
445
|
-
// it now costs only the stubs — once, then carry on.
|
|
446
|
-
if (i > 0 && !redone.has(i - 1)) {
|
|
447
|
-
redone.add(i - 1);
|
|
448
|
-
const stale = resultRow.get(files[i - 1]);
|
|
449
|
-
if (stale !== undefined) {
|
|
450
|
-
results.splice(stale, 1);
|
|
451
|
-
resultRow.delete(files[i - 1]);
|
|
452
|
-
}
|
|
453
|
-
i -= 2;
|
|
552
|
+
if (redoPrevious())
|
|
454
553
|
continue;
|
|
455
|
-
}
|
|
456
554
|
}
|
|
457
555
|
else {
|
|
458
556
|
notAttempted += 1;
|
|
@@ -706,6 +804,10 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
706
804
|
}
|
|
707
805
|
saveCache(CACHE_PATH, cache);
|
|
708
806
|
resultRow.set(file, results.length);
|
|
807
|
+
if (useAI && testFiles.some((t) => t.status === 'basic'))
|
|
808
|
+
stubbed.add(file);
|
|
809
|
+
else
|
|
810
|
+
stubbed.delete(file);
|
|
709
811
|
results.push({
|
|
710
812
|
journeyName: journey.name,
|
|
711
813
|
testDir,
|
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.4",
|
|
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": {
|