@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 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]';
@@ -166,9 +175,13 @@ async function main() {
166
175
  }
167
176
  }
168
177
  const generated = results.filter((r) => r.status !== 'skipped').length;
169
- const skipped = results.filter((r) => r.status === 'skipped').length;
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();
@@ -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() {
@@ -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);
@@ -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.`,
@@ -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,
@@ -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 files = findMarkdownFiles(docsDir);
323
- if (files.length === 0) {
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
- for (const file of files) {
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
- const area = file.split(/[\\/]/)[0] || undefined;
374
- const journey = parseJourney(markdown, area);
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;
@@ -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.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": {