@activate-core/test-forge 0.18.3 → 0.18.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/ai-generator.js +8 -2
- package/dist/core/run-generate.js +128 -20
- package/package.json +1 -1
|
@@ -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);
|
|
@@ -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
|
}
|
|
@@ -386,6 +433,7 @@ 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;
|
|
389
437
|
// Only journey docs are generated from. A file at the docs root, under a
|
|
390
438
|
// "_reference/" folder, or without a "# Journey:" heading is reference
|
|
391
439
|
// material — report it once and leave it alone rather than inventing a
|
|
@@ -455,32 +503,60 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
455
503
|
// replaces the degraded row instead of reporting the journey twice (once as
|
|
456
504
|
// stubs, once real) and counting the stale stubs in the coverage header.
|
|
457
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();
|
|
511
|
+
// Areas whose test framework is not installed. A Playwright spec MUST import
|
|
512
|
+
// "@playwright/test"; without it every generated file is refused by the
|
|
513
|
+
// dependency gate and becomes a deferred stub. That is an environment
|
|
514
|
+
// problem, not something to discover thirteen times over a night, so the
|
|
515
|
+
// area is skipped with the install command and no AI is called at all.
|
|
516
|
+
const blockedAreas = new Map();
|
|
458
517
|
for (let i = 0; i < files.length; i++) {
|
|
459
518
|
const file = files[i];
|
|
519
|
+
// Redo the journey BEFORE this one: it degraded into stubs while the AI
|
|
520
|
+
// was going down. Its real files are kept by per-file resume, so the redo
|
|
521
|
+
// costs only the stubs — once, then carry on. A shared harness that failed
|
|
522
|
+
// during the outage was memoised as "none" for its area; forget that so
|
|
523
|
+
// the redo rebuilds it. Returns true when a redo was scheduled.
|
|
524
|
+
const redoPrevious = () => {
|
|
525
|
+
for (const [k, v] of harnessByArea)
|
|
526
|
+
if (v === null)
|
|
527
|
+
harnessByArea.delete(k);
|
|
528
|
+
if (i === 0 || redone.has(i - 1))
|
|
529
|
+
return false;
|
|
530
|
+
redone.add(i - 1);
|
|
531
|
+
const stale = resultRow.get(files[i - 1]);
|
|
532
|
+
if (stale !== undefined) {
|
|
533
|
+
results.splice(stale, 1);
|
|
534
|
+
resultRow.delete(files[i - 1]);
|
|
535
|
+
}
|
|
536
|
+
i -= 2;
|
|
537
|
+
return true;
|
|
538
|
+
};
|
|
539
|
+
// Silent failures say nothing to match on — ask the CLI directly. If it
|
|
540
|
+
// was failing a moment ago and answers now, the outage has already passed:
|
|
541
|
+
// redo what it broke and carry on.
|
|
542
|
+
if (useAI && (await confirmAiAlive()) && redoPrevious())
|
|
543
|
+
continue;
|
|
544
|
+
if (useAI &&
|
|
545
|
+
!aiUnavailable &&
|
|
546
|
+
consecutiveAiFailures === 0 &&
|
|
547
|
+
i > 0 &&
|
|
548
|
+
stubbed.has(files[i - 1]) &&
|
|
549
|
+
redoPrevious()) {
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
460
552
|
// The AI is gone (limit reached, logged out, not installed). Every further
|
|
461
553
|
// journey would only become deferred stubs. Either wait for it to come back
|
|
462
554
|
// (--wait-for-limit) or stop here; either way the stubs already written are
|
|
463
555
|
// re-attempted automatically on the next run.
|
|
464
556
|
if (aiUnavailable && useAI) {
|
|
465
557
|
if (waitForLimit && (await waitForAi(config))) {
|
|
466
|
-
|
|
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;
|
|
558
|
+
if (redoPrevious())
|
|
482
559
|
continue;
|
|
483
|
-
}
|
|
484
560
|
}
|
|
485
561
|
else {
|
|
486
562
|
notAttempted += 1;
|
|
@@ -522,6 +598,19 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
522
598
|
// AI can't fall back to an uninstalled convention (e.g. supertest).
|
|
523
599
|
const allowedModules = installedModules(testDir);
|
|
524
600
|
const transport = transportHint(testDir, framework);
|
|
601
|
+
// Playwright is the one framework whose import is mandatory in every spec
|
|
602
|
+
// (jest's describe/it/expect are ambient, so a jest area generates fine
|
|
603
|
+
// either way). If it is missing, stop before spending a single AI call.
|
|
604
|
+
if (framework === 'playwright' && !allowedModules.has('@playwright/test')) {
|
|
605
|
+
const blocked = blockedAreas.get(journey.area) ?? {
|
|
606
|
+
pkg: '@playwright/test',
|
|
607
|
+
dir: areaDir,
|
|
608
|
+
count: 0,
|
|
609
|
+
};
|
|
610
|
+
blocked.count += 1;
|
|
611
|
+
blockedAreas.set(journey.area, blocked);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
525
614
|
// F10 — selector grounding (Playwright areas only): the real test-hooks
|
|
526
615
|
// in this area's components, so a generated UI test can't invent one.
|
|
527
616
|
let knownSelectors;
|
|
@@ -734,6 +823,10 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
734
823
|
}
|
|
735
824
|
saveCache(CACHE_PATH, cache);
|
|
736
825
|
resultRow.set(file, results.length);
|
|
826
|
+
if (useAI && testFiles.some((t) => t.status === 'basic'))
|
|
827
|
+
stubbed.add(file);
|
|
828
|
+
else
|
|
829
|
+
stubbed.delete(file);
|
|
737
830
|
results.push({
|
|
738
831
|
journeyName: journey.name,
|
|
739
832
|
testDir,
|
|
@@ -744,6 +837,21 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
744
837
|
warnings: warnings.length ? warnings : undefined,
|
|
745
838
|
});
|
|
746
839
|
}
|
|
840
|
+
for (const [area, b] of blockedAreas) {
|
|
841
|
+
results.push({
|
|
842
|
+
journeyName: `${b.count} ${area} journey(s) not attempted — ${b.pkg} is not installed`,
|
|
843
|
+
testDir: '',
|
|
844
|
+
testFiles: [],
|
|
845
|
+
seedPath: '',
|
|
846
|
+
status: 'skipped',
|
|
847
|
+
seedStatus: 'none',
|
|
848
|
+
warnings: [
|
|
849
|
+
`every ${area} spec must import ${b.pkg}, so without it the dependency gate ` +
|
|
850
|
+
`refuses each generated file and leaves a deferred stub — hours of AI calls for nothing. ` +
|
|
851
|
+
`Install it, then re-run: npm i -D ${b.pkg} && npx playwright install`,
|
|
852
|
+
],
|
|
853
|
+
});
|
|
854
|
+
}
|
|
747
855
|
if (notAttempted > 0 && aiUnavailable) {
|
|
748
856
|
// One line, not one per journey: the operator needs the reason and the
|
|
749
857
|
// count, then to re-run once the CLI is back.
|
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.5",
|
|
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": {
|