@activate-core/test-forge 0.18.1 → 0.18.2

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 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
- console.log(` [skip] ${r.journeyName} (unchanged)`);
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]';
@@ -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();
@@ -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() {
@@ -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 (decode`,
142
- ` the minted token's subject/_id claim, or call the profile/whoami`,
143
- ` endpoint) and seed the OWNER fixtures (whichever role in this`,
144
- ` product owns the data the tests read) under THAT id — then set`,
145
- ` the TEST_IDS owner ids to it.`,
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.`,
@@ -72,7 +72,7 @@ function parseField(line) {
72
72
  * output location and framework. When omitted (a journey read outside an
73
73
  * area folder), it falls back to the parsed `Type:`.
74
74
  */
75
- export function parseJourney(markdown, area) {
75
+ export function parseJourney(markdown, area, fallbackName) {
76
76
  const lines = markdown.split('\n').map((l) => l.trim());
77
77
  let name = '';
78
78
  let type = 'web';
@@ -115,6 +115,16 @@ export function parseJourney(markdown, area) {
115
115
  }
116
116
  }
117
117
  }
118
+ // Not every doc opens with "# Journey: …". A reference dropped straight
119
+ // into docs/ usually carries a plain title; take that, and failing any
120
+ // heading at all, the file's own name. An empty name became an empty id and
121
+ // a test folder literally called "<file>.md", listed with no journey name.
122
+ if (!name) {
123
+ const heading = lines.find((l) => /^#{1,6}\s+\S/.test(l) && !/^#{1,6}\s+TODO\b/i.test(l));
124
+ name = heading
125
+ ? heading.replace(/^#{1,6}\s+/, '').replace(/^Journey:\s*/i, '').trim()
126
+ : (fallbackName ?? '').trim();
127
+ }
118
128
  return {
119
129
  id: toId(name),
120
130
  name,
@@ -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
23
  import { parseJourney } 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,7 +373,7 @@ 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) {
376
+ export async function runGenerate(docsDir, useAI = true, force = false, only, areaOnly, opts = {}) {
322
377
  const files = findMarkdownFiles(docsDir);
323
378
  if (files.length === 0) {
324
379
  return [];
@@ -329,6 +384,8 @@ 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;
332
389
  // Real UI test-hooks per area, extracted once from the components — feeds
333
390
  // the F10 selector-grounding gate so generated UI tests can only assert on
334
391
  // selectors that genuinely exist (never an invented data-testid).
@@ -363,15 +420,58 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
363
420
  harnessByArea.set(area, info.path);
364
421
  return info.path;
365
422
  }
366
- for (const file of files) {
423
+ const waitForLimit = opts.waitForLimit ?? config.waitForLimit ?? true;
424
+ let notAttempted = 0;
425
+ const redone = new Set();
426
+ // Where each journey's result row sits, so a redo after the AI comes back
427
+ // replaces the degraded row instead of reporting the journey twice (once as
428
+ // stubs, once real) and counting the stale stubs in the coverage header.
429
+ const resultRow = new Map();
430
+ for (let i = 0; i < files.length; i++) {
431
+ const file = files[i];
432
+ // The AI is gone (limit reached, logged out, not installed). Every further
433
+ // journey would only become deferred stubs. Either wait for it to come back
434
+ // (--wait-for-limit) or stop here; either way the stubs already written are
435
+ // re-attempted automatically on the next run.
436
+ if (aiUnavailable && useAI) {
437
+ if (waitForLimit && (await waitForAi(config))) {
438
+ // A shared harness that failed while the AI was down was memoised as
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;
454
+ continue;
455
+ }
456
+ }
457
+ else {
458
+ notAttempted += 1;
459
+ continue;
460
+ }
461
+ }
367
462
  const markdown = readFileSync(join(docsDir, file), 'utf-8');
368
463
  const hash = hashContent(markdown);
369
464
  // The area is the doc's top-level folder (api/web/ops/lambda/...). It
370
465
  // decides where the test goes and which framework is used — NOT the
371
466
  // "Type:" line, which only says UI-vs-endpoint. This keeps ops/lambda
372
467
  // journeys in their own package instead of leaking into web/api.
373
- const area = file.split(/[\\/]/)[0] || undefined;
374
- const journey = parseJourney(markdown, area);
468
+ //
469
+ // A doc dropped straight into docs/ has NO folder: splitting on "/" then
470
+ // returns the file name itself, which became the "area" and produced a
471
+ // test folder literally called generated-tests/<file>.md/. No folder means
472
+ // no area — the parser falls back to the doc's Type.
473
+ const area = /[\\/]/.test(file) ? file.split(/[\\/]/)[0] : undefined;
474
+ const journey = parseJourney(markdown, area, basename(file, extname(file)));
375
475
  // --area <a>: only this area's journeys (e.g. do all of api first).
376
476
  if (areaOnly && journey.area !== areaOnly)
377
477
  continue;
@@ -553,7 +653,7 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
553
653
  if (useAI && deferred.length) {
554
654
  warnings.push(`${deferred.length} file(s) could not be generated with AI and were left as DEFERRED stubs (pending, not real coverage): ${deferred
555
655
  .map((t) => t.slug)
556
- .join(', ')}`);
656
+ .join(', ')}${aiFailureNote()}`);
557
657
  }
558
658
  // 2. Seed file (only when using AI — basic mode has no seed concept).
559
659
  let seedStatus = 'none';
@@ -568,7 +668,7 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
568
668
  // Do NOT write a broken seed. Report it so the user sees which
569
669
  // seeds were not produced.
570
670
  seedStatus = 'failed';
571
- warnings.push(`seed generation failed after ${MAX_GEN_ATTEMPTS} attempts — no seed written`);
671
+ warnings.push(`seed generation failed after ${MAX_GEN_ATTEMPTS} attempts — no seed written${aiFailureNote()}`);
572
672
  }
573
673
  }
574
674
  // Only mark a journey "done" in the cache when it actually SUCCEEDED
@@ -605,6 +705,7 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
605
705
  delete cache[file];
606
706
  }
607
707
  saveCache(CACHE_PATH, cache);
708
+ resultRow.set(file, results.length);
608
709
  results.push({
609
710
  journeyName: journey.name,
610
711
  testDir,
@@ -615,6 +716,21 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
615
716
  warnings: warnings.length ? warnings : undefined,
616
717
  });
617
718
  }
719
+ if (notAttempted > 0 && aiUnavailable) {
720
+ // One line, not one per journey: the operator needs the reason and the
721
+ // count, then to re-run once the CLI is back.
722
+ results.push({
723
+ journeyName: `${notAttempted} journey(s) not attempted — AI unavailable`,
724
+ testDir: '',
725
+ testFiles: [],
726
+ seedPath: '',
727
+ status: 'skipped',
728
+ seedStatus: 'none',
729
+ warnings: [
730
+ `${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.`,
731
+ ],
732
+ });
733
+ }
618
734
  saveCache(CACHE_PATH, cache);
619
735
  writeCoverageManifest(results);
620
736
  return results;
@@ -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.1",
3
+ "version": "0.18.2",
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": {