@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 +11 -2
- package/dist/core/ai-generator.js +38 -0
- package/dist/core/config.js +3 -0
- package/dist/core/harness.js +24 -5
- package/dist/core/parser.js +11 -1
- package/dist/core/run-generate.js +127 -11
- package/dist/core/validate.js +26 -0
- package/package.json +1 -1
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
|
-
|
|
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();
|
package/dist/core/config.js
CHANGED
|
@@ -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() {
|
package/dist/core/harness.js
CHANGED
|
@@ -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
|
|
142
|
-
`
|
|
143
|
-
`
|
|
144
|
-
`
|
|
145
|
-
` the
|
|
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.`,
|
package/dist/core/parser.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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;
|
package/dist/core/validate.js
CHANGED
|
@@ -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.
|
|
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": {
|