@activate-core/test-forge 0.18.3 → 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.
@@ -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
- reject(new Error(`${program} exited ${code}: ${err.slice(0, 500)}`));
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
- const msg = (e instanceof Error ? e.message : String(e)).replace(/\s+/g, ' ').trim();
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
- if (isSyntaxValid(content, ext) && !(gate && gate(content)))
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
- if (isSyntaxValid(content, ext) && !(gate && gate(content))) {
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,54 @@ 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();
458
511
  for (let i = 0; i < files.length; i++) {
459
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
+ }
460
546
  // The AI is gone (limit reached, logged out, not installed). Every further
461
547
  // journey would only become deferred stubs. Either wait for it to come back
462
548
  // (--wait-for-limit) or stop here; either way the stubs already written are
463
549
  // re-attempted automatically on the next run.
464
550
  if (aiUnavailable && useAI) {
465
551
  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;
552
+ if (redoPrevious())
482
553
  continue;
483
- }
484
554
  }
485
555
  else {
486
556
  notAttempted += 1;
@@ -734,6 +804,10 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
734
804
  }
735
805
  saveCache(CACHE_PATH, cache);
736
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);
737
811
  results.push({
738
812
  journeyName: journey.name,
739
813
  testDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@activate-core/test-forge",
3
- "version": "0.18.3",
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": {