@activate-core/test-forge 0.16.0 → 0.17.0
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 +102 -30
- package/dist/core/grounding.js +143 -0
- package/dist/core/run-generate.js +13 -3
- package/package.json +1 -1
|
@@ -57,7 +57,7 @@ function runWithStdin(program, args, input, timeoutMs = 300_000) {
|
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
59
|
/** Build the prompt we send to the AI for one journey. */
|
|
60
|
-
export function buildPrompt(journey, map, testPath, focus, harnessPath, transport) {
|
|
60
|
+
export function buildPrompt(journey, map, testPath, focus, harnessPath, transport, grounding) {
|
|
61
61
|
const config = loadConfig();
|
|
62
62
|
// Opinionated: testforge only generates Jest (api/backend) or Playwright
|
|
63
63
|
// (web/UI) tests — any other framework (e.g. a project's Cypress setup)
|
|
@@ -118,6 +118,10 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
|
|
|
118
118
|
` shipping"). If the expectation is merely implied, inferred, or`,
|
|
119
119
|
` "good practice" — including a rule that sounds obviously right —`,
|
|
120
120
|
` it is NOT an oracle: write it ${pending} with a "// TODO:" note.`,
|
|
121
|
+
` But look properly before you conclude it is absent: the requirement`,
|
|
122
|
+
` is often stated in another section of the same document, in a §`,
|
|
123
|
+
` the text cross-references, or in an acceptance-criteria list rather`,
|
|
124
|
+
` than the prose. Only after checking those is it genuinely unstated.`,
|
|
121
125
|
` An assertion you cannot trace to a stated requirement is a false`,
|
|
122
126
|
` positive waiting to happen. Anything under a "Suggested (not a`,
|
|
123
127
|
` requirement…)" heading is explicitly NOT an oracle — never assert`,
|
|
@@ -129,6 +133,14 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
|
|
|
129
133
|
` test with a one-line reason naming exactly what was missing. Never`,
|
|
130
134
|
` let a missing precondition reach an assertion and report as a bug.`,
|
|
131
135
|
` Guard such tests at the top: check the precondition, skip if unmet.`,
|
|
136
|
+
` ESTABLISH IT BEFORE YOU SKIP, THOUGH. A precondition is missing far`,
|
|
137
|
+
` less often than it looks: the credential may be under a different`,
|
|
138
|
+
` env name, the second identity may already exist in the harness, the`,
|
|
139
|
+
` fixture may be one seed call away, the service may be on another`,
|
|
140
|
+
` port. Look for it the way you would look for a bug, and only skip`,
|
|
141
|
+
` when it genuinely is not there — then name exactly what you looked`,
|
|
142
|
+
` for. A suite that skips what it could have set up reports nothing`,
|
|
143
|
+
` about the product, and nobody can tell that from real coverage.`,
|
|
132
144
|
` A CREDENTIAL THAT EXISTS IS NOT A CREDENTIAL THAT WORKS. A supplied`,
|
|
133
145
|
` token expires; treat "present" and "usable" as different checks.`,
|
|
134
146
|
` Where the harness accepts a pre-minted token, verify it is still in`,
|
|
@@ -162,13 +174,30 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
|
|
|
162
174
|
` input is accepted and the invalid one is not. An input that only`,
|
|
163
175
|
` looks tampered turns correct behaviour into a reported breach, which`,
|
|
164
176
|
` is the most damaging false alarm there is.`,
|
|
165
|
-
`(6) A STUB MUST NEVER INTERCEPT THE APPLICATION ITSELF
|
|
166
|
-
`
|
|
167
|
-
` of the URL: a pattern like "**evil.example**" also matches
|
|
168
|
-
` page's "?next=https%3A%2F%2Fevil.example", so the app is
|
|
169
|
-
` stub, nothing renders, and the test fails on a screen that
|
|
170
|
-
` loaded
|
|
171
|
-
`
|
|
177
|
+
`(6) A STUB MUST NEVER INTERCEPT THE APPLICATION ITSELF — AND "I MIGHT`,
|
|
178
|
+
` HIT THE APP" IS NOT A REASON TO GIVE UP. Never match a third party by`,
|
|
179
|
+
` a substring of the URL: a pattern like "**evil.example**" also matches`,
|
|
180
|
+
` your own page's "?next=https%3A%2F%2Fevil.example", so the app is`,
|
|
181
|
+
` served the stub, nothing renders, and the test fails on a screen that`,
|
|
182
|
+
` never loaded.`,
|
|
183
|
+
` Instead, find the NARROWEST thing that tells the dependency's traffic`,
|
|
184
|
+
` apart from the app's own, and scope the stub to exactly that. Work`,
|
|
185
|
+
` down this list and stop at the first that separates them:`,
|
|
186
|
+
` - a different host — match the hostname, case-insensitively;`,
|
|
187
|
+
` - the same host on a different PORT — match host AND port: a`,
|
|
188
|
+
` service on :4000 is a different origin from an app on :3000, and`,
|
|
189
|
+
` scoping to the bare hostname would swallow both;`,
|
|
190
|
+
` - the same origin under a different PATH prefix (/socket.io/,`,
|
|
191
|
+
` /api/chat/) — match the prefix;`,
|
|
192
|
+
` - otherwise a predicate on the request itself: its method, its`,
|
|
193
|
+
` resource type, a header only that client sends.`,
|
|
194
|
+
` A dependency you cannot separate from the app by ANY of those is the`,
|
|
195
|
+
` only one you may leave pending, and then say which of these you`,
|
|
196
|
+
` checked. Do not abandon a whole area because the dependency happens to`,
|
|
197
|
+
` sit on the same hostname — that is usually just a different port, and`,
|
|
198
|
+
` writing it off costs the product its coverage for no reason.`,
|
|
199
|
+
` After installing stubs, satisfy yourself the page under test still`,
|
|
200
|
+
` loads.`,
|
|
172
201
|
`(7) BIND A FIXTURE TO THE IDENTITY THE SCREEN IS SCOPED BY. Before`,
|
|
173
202
|
` seeding for a role-scoped page, find the exact key the server scopes`,
|
|
174
203
|
` on — a user _id, a phone number, an account id — and which sign-in`,
|
|
@@ -184,11 +213,25 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
|
|
|
184
213
|
` fields that transition writes. A hand-assembled record combining`,
|
|
185
214
|
` fields no code path puts together is not a test case — the product`,
|
|
186
215
|
` owes it no behaviour, so whatever it does with it proves nothing.`,
|
|
187
|
-
` Before seeding a state, find the write that produces it
|
|
188
|
-
`
|
|
189
|
-
`
|
|
190
|
-
`
|
|
191
|
-
`
|
|
216
|
+
` Before seeding a state, find the write that produces it. Search for`,
|
|
217
|
+
` it properly — the transition may live in a cron job, a webhook`,
|
|
218
|
+
` handler, an admin route or a background worker rather than the`,
|
|
219
|
+
` obvious controller, and it may set the field under a different`,
|
|
220
|
+
` name. Only when no code path anywhere produces the state is the`,
|
|
221
|
+
` scenario imaginary — then drop it and say what you searched.`,
|
|
222
|
+
`(9) A TIMEOUT IS NOT A BUG REPORT — AND IT IS USUALLY YOU WAITING FOR`,
|
|
223
|
+
` THE WRONG THING. Before you accept a wait, ask what event actually`,
|
|
224
|
+
` arrives. A page whose job is to REDIRECT never fires a load event on`,
|
|
225
|
+
` the URL you asked for, so waiting for one burns the whole timeout on`,
|
|
226
|
+
` a page that already left: ask for the response instead and let the`,
|
|
227
|
+
` assertions that follow wait for the real outcome. A control the app`,
|
|
228
|
+
` hides from the accessibility tree is not there for a role query, so`,
|
|
229
|
+
` query it the way the app exposes it. A message that only renders`,
|
|
230
|
+
` after a round-trip needs a retrying assertion, not a longer sleep.`,
|
|
231
|
+
` Only once you are sure you are waiting for the right thing does`,
|
|
232
|
+
` availability come into it: verify it first and skip when absent. A`,
|
|
233
|
+
` test that times out tells the team nothing about the product, and`,
|
|
234
|
+
` "it timed out" is never the finding — find what it was waiting for.`,
|
|
192
235
|
`(10) THE HARNESS IS SOURCE TOO — READ IT BEFORE YOU CALL IT. Open the`,
|
|
193
236
|
` helper you are about to use and check what it actually RETURNS: a`,
|
|
194
237
|
` setup routine that publishes env and returns nothing hands you`,
|
|
@@ -214,16 +257,32 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
|
|
|
214
257
|
`When rules conflict, prefer reporting LESS: a pending test costs a line`,
|
|
215
258
|
`in the report; a false failure costs the team a release.`,
|
|
216
259
|
``,
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
260
|
+
...(grounding
|
|
261
|
+
? [
|
|
262
|
+
`THE MECHANICS ARE ALREADY ESTABLISHED. The CODE FACTS section`,
|
|
263
|
+
`below was read from this project's real source for this journey —`,
|
|
264
|
+
`routes with their full mounted paths, auth behaviour, request and`,
|
|
265
|
+
`response shapes, models, externals, and the selectors the screens`,
|
|
266
|
+
`genuinely expose. Use it. Do NOT re-derive it: opening the same`,
|
|
267
|
+
`controllers and components again for every file in a journey is`,
|
|
268
|
+
`what makes generation take an hour instead of minutes, and it`,
|
|
269
|
+
`arrives at the same answer. Read source only for a specific detail`,
|
|
270
|
+
`the CODE FACTS section marks "unknown" or genuinely does not`,
|
|
271
|
+
`cover. Those facts are the MECHANICS: HOW to reach and`,
|
|
272
|
+
`drive the behaviour. But the EXPECTED OUTCOME you`,
|
|
273
|
+
]
|
|
274
|
+
: [
|
|
275
|
+
`GROUND EVERYTHING IN THE REAL SOURCE. You are running inside the`,
|
|
276
|
+
`project's repository and have read-only tools (Read, Grep, Glob, LS).`,
|
|
277
|
+
`BEFORE writing tests, look up the actual code for this journey — use`,
|
|
278
|
+
`the route/endpoint named below to grep for the router, then read the`,
|
|
279
|
+
`route file, its controller/handler, the service it calls, and the`,
|
|
280
|
+
`models it touches. Confirm the REAL endpoint paths, HTTP methods,`,
|
|
281
|
+
`request/response shapes, status codes, auth requirements, and function`,
|
|
282
|
+
`signatures from the code — that is the MECHANICS: HOW to reach and`,
|
|
283
|
+
`drive the behaviour (paths, methods, payload/response shape,`,
|
|
284
|
+
`selectors, model/collection names). But the EXPECTED OUTCOME you`,
|
|
285
|
+
]),
|
|
227
286
|
`assert on is the ORACLE, and its source of truth is the JOURNEY`,
|
|
228
287
|
`DOCUMENT below — NOT what the code currently does. The code tells you`,
|
|
229
288
|
`HOW; the document tells you WHAT is correct. If the code's real`,
|
|
@@ -314,7 +373,7 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
|
|
|
314
373
|
`never a description/comment, and never a route-file placeholder`,
|
|
315
374
|
`like "/s/[shortCode]" or "/user/:id". Fill the path with a real`,
|
|
316
375
|
`value (a seeded id/code, or the concrete route) using a backtick`,
|
|
317
|
-
|
|
376
|
+
'template, e.g. page.goto(`${base}/s/${code}`). A goto with prose',
|
|
318
377
|
`or an unfilled [param]/:param is rejected by the gate.`,
|
|
319
378
|
`CRITICAL Playwright API: import { test, expect } from`,
|
|
320
379
|
`'@playwright/test'. There is NO test.todo / it.* / describe.skip`,
|
|
@@ -339,7 +398,11 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
|
|
|
339
398
|
].join('\n'),
|
|
340
399
|
``,
|
|
341
400
|
...(transport
|
|
342
|
-
? [
|
|
401
|
+
? [
|
|
402
|
+
transport,
|
|
403
|
+
`Do NOT import any library that is not already a dependency of this project.`,
|
|
404
|
+
``,
|
|
405
|
+
]
|
|
343
406
|
: []),
|
|
344
407
|
...(harnessPath
|
|
345
408
|
? [
|
|
@@ -378,6 +441,12 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
|
|
|
378
441
|
`--- PROJECT MAP (data layer) ---`,
|
|
379
442
|
map,
|
|
380
443
|
``,
|
|
444
|
+
// Read from the project's real source once per journey, before any file is
|
|
445
|
+
// written, and handed unchanged to every file in it. Sits with the map and
|
|
446
|
+
// the journey so the whole block stays a stable prefix across the journey.
|
|
447
|
+
...(grounding
|
|
448
|
+
? [`--- CODE FACTS (read from this project's source) ---`, grounding, ``]
|
|
449
|
+
: []),
|
|
381
450
|
`--- JOURNEY ---`,
|
|
382
451
|
`It may include sections beyond fields — actions, expected results,`,
|
|
383
452
|
`negative cases, race conditions, webhooks, security notes, and a`,
|
|
@@ -421,9 +490,9 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
|
|
|
421
490
|
* journey. Reads the project map (testforge.md) for data-layer
|
|
422
491
|
* context. Returns the test file content.
|
|
423
492
|
*/
|
|
424
|
-
export function generateWithAI(journey, testPath, focus) {
|
|
493
|
+
export function generateWithAI(journey, testPath, focus, grounding) {
|
|
425
494
|
const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
|
|
426
|
-
const prompt = buildPrompt(journey, map, testPath, focus);
|
|
495
|
+
const prompt = buildPrompt(journey, map, testPath, focus, undefined, undefined, grounding);
|
|
427
496
|
// Allow read-only exploration so the model grounds the test in the
|
|
428
497
|
// real source (routes/controllers/models); Write stays denied, so it
|
|
429
498
|
// still can't create files — it reads, then prints the code.
|
|
@@ -445,9 +514,9 @@ export function generateWithAI(journey, testPath, focus) {
|
|
|
445
514
|
* journey's suite can be generated concurrently (each spawns its own
|
|
446
515
|
* `claude` process). Same prompt, same read-only exploration.
|
|
447
516
|
*/
|
|
448
|
-
export async function generateWithAIAsync(journey, testPath, focus, harnessPath, transport) {
|
|
517
|
+
export async function generateWithAIAsync(journey, testPath, focus, harnessPath, transport, grounding) {
|
|
449
518
|
const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
|
|
450
|
-
const prompt = buildPrompt(journey, map, testPath, focus, harnessPath, transport);
|
|
519
|
+
const prompt = buildPrompt(journey, map, testPath, focus, harnessPath, transport, grounding);
|
|
451
520
|
const config = loadConfig();
|
|
452
521
|
const { program, args } = withReadOnlyTools(splitCommand(config.aiCommand));
|
|
453
522
|
const stdout = await runWithStdin(program, args, prompt, config.aiTimeoutMs);
|
|
@@ -513,5 +582,8 @@ export function cleanOutput(text) {
|
|
|
513
582
|
break;
|
|
514
583
|
}
|
|
515
584
|
}
|
|
516
|
-
return lines
|
|
585
|
+
return lines
|
|
586
|
+
.slice(codeStart, codeEnd + 1)
|
|
587
|
+
.join('\n')
|
|
588
|
+
.trim();
|
|
517
589
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — reading the journey's real code ONCE.
|
|
3
|
+
*
|
|
4
|
+
* Every per-file generation used to carry the same instruction: grep for the
|
|
5
|
+
* router, open the route file, its controller, its service, its models, and —
|
|
6
|
+
* for a UI journey — the page's components. A journey that splits into
|
|
7
|
+
* fourteen files therefore read the same handful of files fourteen times, and
|
|
8
|
+
* each of those reads is an agentic round-trip. That, not the size of the
|
|
9
|
+
* prompt text, is what made one journey take the better part of an hour.
|
|
10
|
+
*
|
|
11
|
+
* The mechanics a test needs — real paths, methods, status codes, model names,
|
|
12
|
+
* the selectors a page actually exposes — are the same for every file in the
|
|
13
|
+
* journey. So we establish them once, cache them beside the suite plan, and
|
|
14
|
+
* hand the same block to each file.
|
|
15
|
+
*
|
|
16
|
+
* What this is NOT: an oracle. It records only HOW to reach the behaviour. The
|
|
17
|
+
* journey document remains the sole source of what is CORRECT, and the
|
|
18
|
+
* generation prompt says so in the same breath as it presents these facts.
|
|
19
|
+
*/
|
|
20
|
+
import { execFileSync } from 'node:child_process';
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { loadConfig, splitCommand, withReadOnlyTools } from './config.js';
|
|
24
|
+
import { hashProjectSource } from './cache.js';
|
|
25
|
+
import { STATE_DIR } from './paths.js';
|
|
26
|
+
const MAP_PATH = 'testforge.md';
|
|
27
|
+
/** Where a journey's grounded facts live, mirroring the plan cache layout. */
|
|
28
|
+
function groundingPath(journey) {
|
|
29
|
+
const area = (journey.area || 'misc').replace(/[^a-z0-9-]/gi, '-');
|
|
30
|
+
const slug = (journey.name || 'journey')
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
33
|
+
.replace(/^-|-$/g, '');
|
|
34
|
+
return join(STATE_DIR, 'grounding', area, `${slug}.json`);
|
|
35
|
+
}
|
|
36
|
+
function buildGroundingPrompt(journey, map, srcDir) {
|
|
37
|
+
const isUi = journey.type !== 'api';
|
|
38
|
+
return [
|
|
39
|
+
`You are a senior engineer establishing the MECHANICS of one user`,
|
|
40
|
+
`journey so tests can be written against it without every writer`,
|
|
41
|
+
`re-reading the same source.`,
|
|
42
|
+
``,
|
|
43
|
+
`You are inside the project's repository with read-only tools (Read,`,
|
|
44
|
+
`Grep, Glob, LS). Read the real code for this journey under "${srcDir}":`,
|
|
45
|
+
`start from the route/endpoint the journey names, then the`,
|
|
46
|
+
`controller/handler, the service it calls, and the models it touches.`,
|
|
47
|
+
``,
|
|
48
|
+
`Report ONLY what you actually read. Never guess a path, a status code`,
|
|
49
|
+
`or a field name: a fact invented here is repeated into every test file`,
|
|
50
|
+
`in the journey, so one wrong line becomes a whole suite that cannot`,
|
|
51
|
+
`run. If something is genuinely not discoverable, write "unknown" for`,
|
|
52
|
+
`it rather than filling the gap.`,
|
|
53
|
+
``,
|
|
54
|
+
`Record, as compact plain text under these headings:`,
|
|
55
|
+
` ROUTES — every endpoint this journey uses, as the FULL mounted path`,
|
|
56
|
+
` the router registers, including any version/area prefix and the`,
|
|
57
|
+
` env var the prefix is read from. Give the HTTP method for each.`,
|
|
58
|
+
` AUTH — for each route: is it declared authenticated? Does a bearer`,
|
|
59
|
+
` still populate the request user via a pre-auth extension? Which`,
|
|
60
|
+
` field is ownership scoped on?`,
|
|
61
|
+
` SHAPES — the request payload and response body each route actually`,
|
|
62
|
+
` returns, and the status codes the handler can emit.`,
|
|
63
|
+
` DATA — the models/collections written or read, and the fields that`,
|
|
64
|
+
` matter to this journey (including enum values where they exist).`,
|
|
65
|
+
` EXTERNALS — any third-party client the code calls, the module name`,
|
|
66
|
+
` a test would have to stand in for, and the env var its base URL or`,
|
|
67
|
+
` key comes from.`,
|
|
68
|
+
...(isUi
|
|
69
|
+
? [
|
|
70
|
+
` SCREENS — the page/component files this journey renders, and the`,
|
|
71
|
+
` locators they GENUINELY expose: roles with accessible names,`,
|
|
72
|
+
` label/placeholder text, on-screen text, input name/id`,
|
|
73
|
+
` attributes, and any data-testid that truly exists in the`,
|
|
74
|
+
` source. Never list a selector you did not see.`,
|
|
75
|
+
]
|
|
76
|
+
: []),
|
|
77
|
+
``,
|
|
78
|
+
`Be terse. This is a reference sheet, not prose: no preamble, no`,
|
|
79
|
+
`restating the journey, no advice about testing. Aim for well under 200`,
|
|
80
|
+
`lines. Output the headings and their contents only.`,
|
|
81
|
+
``,
|
|
82
|
+
`--- PROJECT MAP ---`,
|
|
83
|
+
map,
|
|
84
|
+
``,
|
|
85
|
+
`--- JOURNEY ---`,
|
|
86
|
+
journey.rawMarkdown || journey.name,
|
|
87
|
+
].join('\n');
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Establish (or reuse) the journey's code facts. Cached against the doc hash
|
|
91
|
+
* AND the source hash: the mechanics only move when the code or the journey
|
|
92
|
+
* moves, so an unchanged re-run costs nothing.
|
|
93
|
+
*
|
|
94
|
+
* Never throws. If the AI call fails we return null and the caller falls back
|
|
95
|
+
* to telling each file to read the source itself — slower, but still correct.
|
|
96
|
+
*/
|
|
97
|
+
export function groundJourney(journey, docHash, srcDir = 'src') {
|
|
98
|
+
const path = groundingPath(journey);
|
|
99
|
+
const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
|
|
100
|
+
let sourceHash = '';
|
|
101
|
+
try {
|
|
102
|
+
sourceHash = hashProjectSource(srcDir);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// unreadable source tree — fall back to keying on the doc alone
|
|
106
|
+
}
|
|
107
|
+
const key = `${docHash}:${sourceHash}`;
|
|
108
|
+
if (existsSync(path)) {
|
|
109
|
+
try {
|
|
110
|
+
const cached = JSON.parse(readFileSync(path, 'utf-8'));
|
|
111
|
+
if (cached.key === key && typeof cached.facts === 'string' && cached.facts.trim()) {
|
|
112
|
+
return cached.facts;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// corrupt cache — re-ground
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const config = loadConfig();
|
|
120
|
+
const { program, args } = withReadOnlyTools(splitCommand(config.aiCommand));
|
|
121
|
+
let facts;
|
|
122
|
+
try {
|
|
123
|
+
facts = execFileSync(program, args, {
|
|
124
|
+
input: buildGroundingPrompt(journey, map, srcDir),
|
|
125
|
+
encoding: 'utf-8',
|
|
126
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
127
|
+
timeout: config.aiTimeoutMs ?? 300_000,
|
|
128
|
+
}).trim();
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
if (!facts)
|
|
134
|
+
return null;
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
137
|
+
writeFileSync(path, JSON.stringify({ key, facts }, null, 2), 'utf-8');
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// best-effort cache
|
|
141
|
+
}
|
|
142
|
+
return facts;
|
|
143
|
+
}
|
|
@@ -24,6 +24,7 @@ import { parseJourney } from './parser.js';
|
|
|
24
24
|
import { generateTestCases } from './generator.js';
|
|
25
25
|
import { writeDeferredStub } from './playwright-writer.js';
|
|
26
26
|
import { generateWithAI, generateWithAIAsync } from './ai-generator.js';
|
|
27
|
+
import { groundJourney } from './grounding.js';
|
|
27
28
|
import { generateSeed } from './seed-generator.js';
|
|
28
29
|
import { planSuite } from './suite-planner.js';
|
|
29
30
|
import { isSyntaxValid, checkTestGates, analyzeFile } from './validate.js';
|
|
@@ -97,9 +98,9 @@ function generateValidated(generate, ext, gate) {
|
|
|
97
98
|
* syntax gate + retry) and falls back to the deterministic basic
|
|
98
99
|
* generator if AI output can't be made valid.
|
|
99
100
|
*/
|
|
100
|
-
function buildTest(journey, useAI, ext, testPath, framework, focus) {
|
|
101
|
+
function buildTest(journey, useAI, ext, testPath, framework, focus, grounding) {
|
|
101
102
|
if (useAI) {
|
|
102
|
-
const ai = generateValidated(() => generateWithAI(journey, testPath, focus), ext);
|
|
103
|
+
const ai = generateValidated(() => generateWithAI(journey, testPath, focus, grounding), ext);
|
|
103
104
|
if (ai !== null) {
|
|
104
105
|
return { content: ai, status: 'ai' };
|
|
105
106
|
}
|
|
@@ -459,6 +460,15 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
459
460
|
? loadOrPlan(journey, hash)
|
|
460
461
|
: [{ slug: journey.id, title: journey.name, covers: '' }];
|
|
461
462
|
const plannedSlugs = planned.map((p) => p.slug);
|
|
463
|
+
// Read the journey's real code ONCE, before any file is written, and hand
|
|
464
|
+
// the same facts to every file. Without this each file re-opened the same
|
|
465
|
+
// routes, controllers and components — the same answer, N times over, and
|
|
466
|
+
// the single biggest cost in a multi-file journey. Best-effort: if it
|
|
467
|
+
// fails, `grounding` stays undefined and each file falls back to reading
|
|
468
|
+
// the source itself, which is slower but still correct.
|
|
469
|
+
const grounding = useAI
|
|
470
|
+
? groundJourney(journey, hash) ?? undefined
|
|
471
|
+
: undefined;
|
|
462
472
|
const staleRemoved = cleanStaleFiles(testDir, journey.id, ext, flat, plannedSlugs, prevSlugs);
|
|
463
473
|
if (staleRemoved.length) {
|
|
464
474
|
warnings.push(`removed ${staleRemoved.length} stale file(s) from a previous plan: ${staleRemoved.join(', ')}`);
|
|
@@ -495,7 +505,7 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
495
505
|
let content;
|
|
496
506
|
let status;
|
|
497
507
|
if (useAI) {
|
|
498
|
-
const ai = await generateValidatedAsync(() => generateWithAIAsync(journey, filePath, { title: pf.title, covers: pf.covers }, harnessPath, transport), ext, testGate);
|
|
508
|
+
const ai = await generateValidatedAsync(() => generateWithAIAsync(journey, filePath, { title: pf.title, covers: pf.covers }, harnessPath, transport, grounding), ext, testGate);
|
|
499
509
|
if (ai !== null) {
|
|
500
510
|
content = ai;
|
|
501
511
|
status = 'ai';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@activate-core/test-forge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
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": {
|