@activate-core/test-forge 0.14.1 → 0.16.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/README.md +42 -1
- package/dist/cli/index.js +8 -1
- package/dist/core/ai-generator.js +30 -8
- package/dist/core/config.js +1 -0
- package/dist/core/discover.js +90 -4
- package/dist/core/harness.js +10 -10
- package/dist/core/lint.js +105 -0
- package/dist/core/reference.js +22 -7
- package/dist/core/report.js +12 -2
- package/dist/core/run-generate.js +24 -1
- package/dist/core/source-of-truth.js +38 -3
- package/dist/core/update.js +10 -0
- package/dist/core/validate.js +25 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,6 +54,29 @@ testforge generate --journey refund-flow
|
|
|
54
54
|
testforge generate --area ops
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
### A feature that arrives on its own document
|
|
58
|
+
|
|
59
|
+
A ticket turns up with a PDF, or a spec for one feature. Point `discover` at it
|
|
60
|
+
directly — you do not name the journey, the AI reads the document and works out
|
|
61
|
+
which journey it describes:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
testforge discover --reference ./docs/tickets/zh-4521.pdf
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**A reference only writes the journeys it actually covers.** The ticket's PDF
|
|
68
|
+
has never heard of your other journeys, so their docs are left exactly as they
|
|
69
|
+
are, and the project's own source of truth stays where it was. TestForge then
|
|
70
|
+
remembers that this one journey came from that document, and refreshes it from
|
|
71
|
+
there on every later `update` — not from the project spec, which still does not
|
|
72
|
+
describe it.
|
|
73
|
+
|
|
74
|
+
When a reference really is the whole product's new specification, say so:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
testforge discover --reference ./docs/prd-v2.md --replace
|
|
78
|
+
```
|
|
79
|
+
|
|
57
80
|
A journey **is** its doc: `generate` reads `docs/` and treats every markdown
|
|
58
81
|
file there as one journey. So a doc you write by hand — for a feature the
|
|
59
82
|
reference does not cover yet, or one taken from a ticket — works exactly like a
|
|
@@ -136,6 +159,14 @@ becomes a Jest test (HTTP + database checks); `Type: web` becomes a
|
|
|
136
159
|
Playwright test. Other areas use the framework set in config. It also
|
|
137
160
|
generates a seed file that provisions the test data each test needs.
|
|
138
161
|
|
|
162
|
+
**generate** hands each file it writes to the project's own ESLint with
|
|
163
|
+
`--fix` before moving on. Formatting differences are corrected in place — CI
|
|
164
|
+
runs lint before it runs a test, and a file that is correct but indented
|
|
165
|
+
differently still fails the build. Anything the linter cannot fix (an unused
|
|
166
|
+
declaration, say) is reported as a warning against that journey, because a
|
|
167
|
+
declaration nothing checks is worth a person's attention. If the project has no
|
|
168
|
+
ESLint, the step is skipped.
|
|
169
|
+
|
|
139
170
|
**update** hashes your source code AND the reference the docs came from,
|
|
140
171
|
and compares both to the last run. Neither changed means no work and no AI
|
|
141
172
|
calls; either one changed triggers a refresh, and it says which. A reference it
|
|
@@ -272,7 +303,8 @@ The test runner produces its own pass/fail report.
|
|
|
272
303
|
"ops": "playwright"
|
|
273
304
|
},
|
|
274
305
|
"seedOutput": "packages/api/test/helpers/seed",
|
|
275
|
-
"testExtension": ".test.ts"
|
|
306
|
+
"testExtension": ".test.ts",
|
|
307
|
+
"externalModules": ["shipengine", "@customerio/cdp-analytics"]
|
|
276
308
|
}
|
|
277
309
|
```
|
|
278
310
|
|
|
@@ -289,6 +321,15 @@ The test runner produces its own pass/fail report.
|
|
|
289
321
|
- **framework** — which test framework each area uses.
|
|
290
322
|
- **seedOutput** — where seed files go.
|
|
291
323
|
- **testExtension** — `.test.ts` or `.test.js`.
|
|
324
|
+
- **externalModules** — the third-party network services this project
|
|
325
|
+
talks to, named as a test would import them. A generated backend test
|
|
326
|
+
that imports one without mocking it is rejected before it is written:
|
|
327
|
+
left alone it would reach the live service, so it breaks offline and
|
|
328
|
+
can fire real charges, messages or shipments. testforge already knows
|
|
329
|
+
the common SDKs (payment, SMS, email, cloud, identity) and this list is
|
|
330
|
+
added to them, never instead of them — so name only what is particular
|
|
331
|
+
to your product. Every domain has something: a shipping carrier, a CMS,
|
|
332
|
+
a ticketing or streaming backend, an ad network.
|
|
292
333
|
|
|
293
334
|
## Notes
|
|
294
335
|
|
package/dist/cli/index.js
CHANGED
|
@@ -45,7 +45,11 @@ function flagValue(name) {
|
|
|
45
45
|
}
|
|
46
46
|
/** Source-of-truth options shared by setup and discover. */
|
|
47
47
|
function sourceOptions() {
|
|
48
|
-
return {
|
|
48
|
+
return {
|
|
49
|
+
reference: flagValue('--reference'),
|
|
50
|
+
source: flagValue('--source'),
|
|
51
|
+
replace: process.argv.includes('--replace'),
|
|
52
|
+
};
|
|
49
53
|
}
|
|
50
54
|
/**
|
|
51
55
|
* The output dirs to scan for env vars the GENERATED suite reads (test
|
|
@@ -78,6 +82,9 @@ function printUsage() {
|
|
|
78
82
|
console.log(' testforge discover Draft journey docs (asks: map or reference)');
|
|
79
83
|
console.log(' testforge discover --journey <name> Draft just ONE journey doc');
|
|
80
84
|
console.log(' testforge discover --reference <file|url> Base docs on a reference');
|
|
85
|
+
console.log(' (a reference only writes the journeys it');
|
|
86
|
+
console.log(' describes — add --replace to let it');
|
|
87
|
+
console.log(' redraft the whole project)');
|
|
81
88
|
console.log(' testforge map Scan project, write journey + data map');
|
|
82
89
|
console.log(' testforge init Inspect project and write config');
|
|
83
90
|
console.log(' testforge update Re-check and update only what changed');
|
|
@@ -189,6 +189,28 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
|
|
|
189
189
|
`(9) A TIMEOUT IS NOT A BUG REPORT. If an action legitimately waits on`,
|
|
190
190
|
` something external, verify availability first and skip when absent.`,
|
|
191
191
|
` A test that times out tells the team nothing about the product.`,
|
|
192
|
+
`(10) THE HARNESS IS SOURCE TOO — READ IT BEFORE YOU CALL IT. Open the`,
|
|
193
|
+
` helper you are about to use and check what it actually RETURNS: a`,
|
|
194
|
+
` setup routine that publishes env and returns nothing hands you`,
|
|
195
|
+
` undefined, and every later reference through it dies in beforeAll,`,
|
|
196
|
+
` taking the whole file down as "failures" the product never caused.`,
|
|
197
|
+
` The same applies to locators: a query must resolve to exactly ONE`,
|
|
198
|
+
` node. Component libraries label a control twice (the <label> and the`,
|
|
199
|
+
` widget both answer getByLabel), so prefer the role the widget`,
|
|
200
|
+
` actually exposes. Where a sibling test in this journey already`,
|
|
201
|
+
` drives the same control, reuse ITS helper verbatim rather than`,
|
|
202
|
+
` writing a second one that has to rediscover the same trap.`,
|
|
203
|
+
`(11) LEAVE SHARED STATE AS YOU FOUND IT, EVEN WHEN YOU FAIL. A fixture`,
|
|
204
|
+
` every other suite reads — a shared account, the one seeded record`,
|
|
205
|
+
` the whole journey keys on — must be put back the moment you are`,
|
|
206
|
+
` done with it. Read the value first, restore it in a finally or an`,
|
|
207
|
+
` after-EACH hook, never an after-ALL: the specs that mutate shared`,
|
|
208
|
+
` state are usually the ones asserting the write is REFUSED, so they`,
|
|
209
|
+
` throw by design the day the product is wrong, and a worker that dies`,
|
|
210
|
+
` on that throw never reaches after-all. The forged value then sits in`,
|
|
211
|
+
` the fixture and every later suite reads it as real — one defect`,
|
|
212
|
+
` becomes a hundred failures in journeys that were never broken, and`,
|
|
213
|
+
` the run cannot be trusted until someone repairs the data by hand.`,
|
|
192
214
|
`When rules conflict, prefer reporting LESS: a pending test costs a line`,
|
|
193
215
|
`in the report; a false failure costs the team a release.`,
|
|
194
216
|
``,
|
|
@@ -220,8 +242,8 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
|
|
|
220
242
|
`call in the file.`,
|
|
221
243
|
`AUTH & SCOPING: a route may be declared auth:false yet STILL populate`,
|
|
222
244
|
`request.user when a Bearer token is sent (a pre-auth extension). So`,
|
|
223
|
-
`owner-scoped data ("my
|
|
224
|
-
`owner — NOT from a query param. Do NOT assume
|
|
245
|
+
`owner-scoped data ("my orders", "my messages") is derived from the`,
|
|
246
|
+
`TOKEN owner — NOT from a query param. Do NOT assume an "?ownerId="`,
|
|
225
247
|
`fallback overrides the token for an AUTHENTICATED call: the testkit`,
|
|
226
248
|
`seeds the owner fixtures under the authenticated identity, so expect`,
|
|
227
249
|
`the token owner's rows. A second, non-owner identity (for IDOR/scope`,
|
|
@@ -250,13 +272,13 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
|
|
|
250
272
|
`process.env.DB_URL) — never hardcode a connection string.`,
|
|
251
273
|
`MOCKING (MANDATORY — the test must be HERMETIC): the test must`,
|
|
252
274
|
`NEVER depend on a live external service. For EVERY external`,
|
|
253
|
-
`service the exercised code reaches
|
|
254
|
-
`
|
|
255
|
-
`read the source to see which client it imports (
|
|
256
|
-
`
|
|
275
|
+
`service the exercised code reaches — whatever this product happens`,
|
|
276
|
+
`to call out to — mock it AT THE SEAM THE CODE ACTUALLY USES —`,
|
|
277
|
+
`read the source to see which client it imports (an HTTP client, a`,
|
|
278
|
+
`vendor SDK) and how it is called, then jest.mock that EXACT module`,
|
|
257
279
|
`so the mock truly intercepts. A test that leaves any such call`,
|
|
258
|
-
`unmocked (so it would
|
|
259
|
-
`
|
|
280
|
+
`unmocked (so it would reach a real host rather than your mock)`,
|
|
281
|
+
`is WRONG — it will fail whenever that service is down and is`,
|
|
260
282
|
`not deterministic. Do NOT mock a client the code doesn't use.`,
|
|
261
283
|
`HERMETIC BASE URLS: if the client's target is built from an env`,
|
|
262
284
|
`var (e.g. process.env.IM_API_URL), do NOT rely on the ambient`,
|
package/dist/core/config.js
CHANGED
package/dist/core/discover.js
CHANGED
|
@@ -15,10 +15,12 @@
|
|
|
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
20
|
import { loadConfig, splitCommand, withEditTools } from './config.js';
|
|
19
21
|
import { DEEP_DOC_GUIDANCE } from './doc-guidance.js';
|
|
20
22
|
import { promptConfirm } from './prompt.js';
|
|
21
|
-
import { resolveDocsSource, saveSourceChoice, } from './source-of-truth.js';
|
|
23
|
+
import { loadSourceChoice, resolveDocsSource, saveJourneyReferences, saveSourceChoice, } from './source-of-truth.js';
|
|
22
24
|
import { analyzeReference, draftDocsFromReference, DIFF_PATH, } from './reference.js';
|
|
23
25
|
/** Scope line: one journey, or all of them. */
|
|
24
26
|
function scopeLines(journey) {
|
|
@@ -118,6 +120,30 @@ function runCodeDiscover(srcDir, docsDir, journey) {
|
|
|
118
120
|
});
|
|
119
121
|
return output.trim();
|
|
120
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Every journey doc currently on disk, keyed by its path relative to the docs
|
|
125
|
+
* dir ("web/refund-flow.md"), with its exact contents. Taking this BEFORE a
|
|
126
|
+
* draft is what lets us tell three things apart afterwards: a doc the
|
|
127
|
+
* reference newly created, one it rewrote, and one it should never have
|
|
128
|
+
* touched at all.
|
|
129
|
+
*/
|
|
130
|
+
function snapshotDocs(docsDir) {
|
|
131
|
+
const found = new Map();
|
|
132
|
+
if (!existsSync(docsDir))
|
|
133
|
+
return found;
|
|
134
|
+
const walk = (dir) => {
|
|
135
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
136
|
+
const full = join(dir, entry.name);
|
|
137
|
+
if (entry.isDirectory())
|
|
138
|
+
walk(full);
|
|
139
|
+
else if (entry.name.endsWith('.md')) {
|
|
140
|
+
found.set(relative(docsDir, full).split(sep).join('/'), readFileSync(full, 'utf-8'));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
walk(docsDir);
|
|
145
|
+
return found;
|
|
146
|
+
}
|
|
121
147
|
/**
|
|
122
148
|
* Run discovery. First decides the source of truth for the docs (the
|
|
123
149
|
* code-derived map, or an external reference — see source-of-truth.ts),
|
|
@@ -146,9 +172,69 @@ export async function runDiscover(srcDir, docsDir, opts = {}, journey) {
|
|
|
146
172
|
return runCodeDiscover(srcDir, docsDir, journey);
|
|
147
173
|
}
|
|
148
174
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
175
|
+
// Is this the product's specification, or one feature's? A ticket's PDF
|
|
176
|
+
// knows nothing about the other 26 journeys, so letting it rewrite them
|
|
177
|
+
// would swap a reviewed spec for whatever the code happens to do today —
|
|
178
|
+
// the exact inversion these tests exist to catch. So a reference only
|
|
179
|
+
// writes what it covers, unless it IS the project's spec or --replace
|
|
180
|
+
// says to treat it as one.
|
|
181
|
+
const stored = loadSourceChoice();
|
|
182
|
+
const before = snapshotDocs(docsDir);
|
|
183
|
+
const bootstrapping = before.size === 0;
|
|
184
|
+
const wholeProject = Boolean(opts.replace) ||
|
|
185
|
+
bootstrapping ||
|
|
186
|
+
!stored?.reference ||
|
|
187
|
+
stored.reference === source.reference;
|
|
188
|
+
// Docs that belong to a DIFFERENT reference — a ticket's spec, say — are
|
|
189
|
+
// off limits even to the project's own specification, which has never
|
|
190
|
+
// heard of that feature and would redraft it from the code.
|
|
191
|
+
const ownedElsewhere = new Set(Object.entries(stored?.journeys || {})
|
|
192
|
+
.filter(([, ref]) => ref !== source.reference)
|
|
193
|
+
.map(([doc]) => doc));
|
|
194
|
+
const offLimits = wholeProject
|
|
195
|
+
? [...ownedElsewhere]
|
|
196
|
+
: [...new Set([...before.keys(), ...ownedElsewhere])];
|
|
197
|
+
console.log(wholeProject
|
|
198
|
+
? '\nDrafting docs from the reference...\n'
|
|
199
|
+
: '\nDrafting docs from the reference (adding to the existing docs)...\n');
|
|
200
|
+
const out = draftDocsFromReference(srcDir, docsDir, source.reference, journey, offLimits.length ? offLimits : undefined);
|
|
201
|
+
const after = snapshotDocs(docsDir);
|
|
202
|
+
const added = [];
|
|
203
|
+
const rewritten = [];
|
|
204
|
+
for (const [path, text] of after) {
|
|
205
|
+
const prior = before.get(path);
|
|
206
|
+
if (prior === undefined)
|
|
207
|
+
added.push(path);
|
|
208
|
+
else if (prior !== text)
|
|
209
|
+
rewritten.push(path);
|
|
210
|
+
}
|
|
211
|
+
// Put back anything it reached for that this reference does not own. The
|
|
212
|
+
// prompt asks it not to; this makes it true whether or not it complied.
|
|
213
|
+
const restored = rewritten.filter((path) => wholeProject ? ownedElsewhere.has(path) : true);
|
|
214
|
+
for (const path of restored) {
|
|
215
|
+
writeFileSync(join(docsDir, path), before.get(path), 'utf-8');
|
|
216
|
+
}
|
|
217
|
+
if (wholeProject) {
|
|
218
|
+
saveSourceChoice({ mode: 'reference', reference: source.reference });
|
|
219
|
+
if (added.length)
|
|
220
|
+
saveJourneyReferences(added, source.reference);
|
|
221
|
+
if (restored.length) {
|
|
222
|
+
console.log(`\n${restored.length} doc(s) kept as they were — they come from a ` +
|
|
223
|
+
'reference of their own, not the project specification.');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
saveJourneyReferences(added, source.reference);
|
|
228
|
+
console.log(`\n${added.length} journey doc(s) written from this reference` +
|
|
229
|
+
(added.length ? `: ${added.join(', ')}` : '') + '.');
|
|
230
|
+
if (restored.length) {
|
|
231
|
+
console.log(`${restored.length} existing doc(s) left as they were — this ` +
|
|
232
|
+
'reference does not describe them.');
|
|
233
|
+
}
|
|
234
|
+
console.log('The project\'s source of truth is unchanged' +
|
|
235
|
+
(stored?.reference ? `: ${stored.reference}` : '') +
|
|
236
|
+
'. Use --replace to hand that role to this reference instead.');
|
|
237
|
+
}
|
|
152
238
|
return out;
|
|
153
239
|
}
|
|
154
240
|
saveSourceChoice({ mode: 'map' });
|
package/dist/core/harness.js
CHANGED
|
@@ -58,7 +58,7 @@ export const HARNESS_CONTRACT = [
|
|
|
58
58
|
` EVERY id MUST be env-overridable: resolve`,
|
|
59
59
|
` each as process.env.TEST_<NAME>_ID ||`,
|
|
60
60
|
` '<stable default>' (e.g. TEST_PROJECT_ID,`,
|
|
61
|
-
`
|
|
61
|
+
` TEST_OWNER_ID). This lets a caller point`,
|
|
62
62
|
` the suite at data created BY HAND on`,
|
|
63
63
|
` staging/prod (with SEED_MODE=existing)`,
|
|
64
64
|
` without editing code. Resolve non-id`,
|
|
@@ -116,8 +116,8 @@ export const HARNESS_CONTRACT = [
|
|
|
116
116
|
` the UI shows the seeded data.`,
|
|
117
117
|
`Use only env var NAMES for secrets/connection — never literal values.`,
|
|
118
118
|
`The ONLY credentials a developer should have to provide are the DB`,
|
|
119
|
-
`connection, a
|
|
120
|
-
`login) — everything else setup() derives at runtime.`,
|
|
119
|
+
`connection, a test key for any paid third party, and ONE seed/refresh`,
|
|
120
|
+
`token (or seeded login) — everything else setup() derives at runtime.`,
|
|
121
121
|
``,
|
|
122
122
|
`SAFETY — this testkit must NEVER be able to damage a real environment:`,
|
|
123
123
|
` - reset() and ANY delete/drop MUST refuse unless BOTH hold: the opt-in`,
|
|
@@ -140,9 +140,9 @@ export const HARNESS_CONTRACT = [
|
|
|
140
140
|
`extension when a Bearer is sent. Therefore:`,
|
|
141
141
|
` - setup() MUST resolve the authenticated identity's REAL id (decode`,
|
|
142
142
|
` the minted token's subject/_id claim, or call the profile/whoami`,
|
|
143
|
-
` endpoint) and seed the OWNER fixtures (
|
|
144
|
-
`
|
|
145
|
-
` owner ids to it.`,
|
|
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.`,
|
|
146
146
|
` - Do NOT hardcode an owner id the token does not own: the seeded rows`,
|
|
147
147
|
` would never be returned to the authenticated caller, and every`,
|
|
148
148
|
` owner-scoped test would fail for a fake reason.`,
|
|
@@ -392,15 +392,15 @@ export function fixturesDoc() {
|
|
|
392
392
|
``,
|
|
393
393
|
`## Running against data you created by hand (staging / prod)`,
|
|
394
394
|
`1. Create the fixture entities in the target database yourself (a`,
|
|
395
|
-
`
|
|
396
|
-
` what the seed builders in \`helpers/testkit\` produce.`,
|
|
395
|
+
` parent record, its line items, the users on each side, etc.) —`,
|
|
396
|
+
` mirror what the seed builders in \`helpers/testkit\` produce.`,
|
|
397
397
|
`2. Point the suite at them — every well-known id is env-overridable:`,
|
|
398
398
|
``,
|
|
399
399
|
` TEST_PROJECT_ID=<your project id>`,
|
|
400
|
-
`
|
|
400
|
+
` TEST_OWNER_ID=<...> # and any other TEST_*_ID the suite reads`,
|
|
401
401
|
``,
|
|
402
402
|
`3. Provide auth + connection env (DB url/name(s), a seed/refresh token`,
|
|
403
|
-
` or seeded login,
|
|
403
|
+
` or seeded login, any third-party test key).`,
|
|
404
404
|
`4. Run with \`SEED_MODE=existing\` so nothing is seeded or deleted.`,
|
|
405
405
|
``,
|
|
406
406
|
`## Safety`,
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testforge — run the project's own linter over what we generated.
|
|
3
|
+
*
|
|
4
|
+
* A generated file is written to a repository that already has a house style,
|
|
5
|
+
* and CI usually runs `npm run lint` before it runs a single test. Files that
|
|
6
|
+
* are correct but indented differently fail that build, and somebody then fixes
|
|
7
|
+
* two hundred formatting errors by hand — work the generator caused and the
|
|
8
|
+
* generator can undo.
|
|
9
|
+
*
|
|
10
|
+
* So: after writing, hand the files to the project's ESLint with --fix. Style
|
|
11
|
+
* problems disappear silently, because they were never interesting. Anything
|
|
12
|
+
* left is reported, because an unused declaration in a test is a claim nothing
|
|
13
|
+
* checks, and that is worth a human's attention.
|
|
14
|
+
*
|
|
15
|
+
* The project's config is used, never one of ours — the point is to match the
|
|
16
|
+
* repository, not to impose a preference.
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import { existsSync } from 'node:fs';
|
|
20
|
+
import { join, dirname } from 'node:path';
|
|
21
|
+
/** Walk up from a file to the nearest package that has ESLint installed. */
|
|
22
|
+
function eslintFor(file) {
|
|
23
|
+
let dir = dirname(file);
|
|
24
|
+
for (let i = 0; i < 8; i++) {
|
|
25
|
+
const bin = join(dir, 'node_modules', '.bin', 'eslint');
|
|
26
|
+
if (existsSync(bin))
|
|
27
|
+
return { cwd: dir, bin };
|
|
28
|
+
const up = dirname(dir);
|
|
29
|
+
if (up === dir)
|
|
30
|
+
break;
|
|
31
|
+
dir = up;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Lint (and fix) the given files. Returns what was fixed and what was not.
|
|
37
|
+
* Never throws: a missing or misconfigured linter must not fail a generation
|
|
38
|
+
* that otherwise succeeded.
|
|
39
|
+
*/
|
|
40
|
+
export function lintGenerated(files) {
|
|
41
|
+
const real = files.filter((f) => f && existsSync(f));
|
|
42
|
+
if (!real.length)
|
|
43
|
+
return { checked: 0, fixed: 0, remaining: [] };
|
|
44
|
+
// Group by the package whose ESLint owns them — a monorepo has one per app.
|
|
45
|
+
const groups = new Map();
|
|
46
|
+
for (const f of real) {
|
|
47
|
+
const found = eslintFor(f);
|
|
48
|
+
if (!found)
|
|
49
|
+
continue;
|
|
50
|
+
const g = groups.get(found.cwd) || { bin: found.bin, files: [] };
|
|
51
|
+
g.files.push(f);
|
|
52
|
+
groups.set(found.cwd, g);
|
|
53
|
+
}
|
|
54
|
+
if (!groups.size) {
|
|
55
|
+
return { checked: 0, fixed: 0, remaining: [], skipped: 'no ESLint found in the project' };
|
|
56
|
+
}
|
|
57
|
+
let checked = 0;
|
|
58
|
+
let fixed = 0;
|
|
59
|
+
const remaining = [];
|
|
60
|
+
for (const [cwd, g] of groups) {
|
|
61
|
+
const before = countProblems(g.bin, cwd, g.files);
|
|
62
|
+
spawnSync(g.bin, ['--fix', '--no-error-on-unmatched-pattern', ...g.files], {
|
|
63
|
+
cwd,
|
|
64
|
+
encoding: 'utf-8',
|
|
65
|
+
timeout: 120_000,
|
|
66
|
+
});
|
|
67
|
+
const after = readProblems(g.bin, cwd, g.files);
|
|
68
|
+
checked += g.files.length;
|
|
69
|
+
fixed += Math.max(0, before - after.count);
|
|
70
|
+
remaining.push(...after.lines);
|
|
71
|
+
}
|
|
72
|
+
return { checked, fixed, remaining };
|
|
73
|
+
}
|
|
74
|
+
function countProblems(bin, cwd, files) {
|
|
75
|
+
return readProblems(bin, cwd, files).count;
|
|
76
|
+
}
|
|
77
|
+
function readProblems(bin, cwd, files) {
|
|
78
|
+
const res = spawnSync(bin, ['-f', 'json', '--no-error-on-unmatched-pattern', ...files], {
|
|
79
|
+
cwd,
|
|
80
|
+
encoding: 'utf-8',
|
|
81
|
+
timeout: 120_000,
|
|
82
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
83
|
+
});
|
|
84
|
+
let parsed;
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(res.stdout || '[]');
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return { count: 0, lines: [] }; // linter unavailable or output unusable
|
|
90
|
+
}
|
|
91
|
+
const lines = [];
|
|
92
|
+
let count = 0;
|
|
93
|
+
for (const file of parsed) {
|
|
94
|
+
for (const m of file.messages) {
|
|
95
|
+
if (m.severity !== 2)
|
|
96
|
+
continue; // warnings do not fail a build
|
|
97
|
+
count += 1;
|
|
98
|
+
if (lines.length < 20) {
|
|
99
|
+
const rel = file.filePath.replace(cwd + '/', '');
|
|
100
|
+
lines.push(`${rel}:${m.line} — ${m.message}${m.ruleId ? ` (${m.ruleId})` : ''}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { count, lines };
|
|
105
|
+
}
|
package/dist/core/reference.js
CHANGED
|
@@ -88,7 +88,7 @@ export function buildAnalyzePrompt(reference, docsDir) {
|
|
|
88
88
|
* treating it as the source of truth for behavior. Mirrors the code-based
|
|
89
89
|
* discover format so the docs are interchangeable downstream.
|
|
90
90
|
*/
|
|
91
|
-
export function buildReferenceDiscoverPrompt(srcDir, docsDir, reference, journey) {
|
|
91
|
+
export function buildReferenceDiscoverPrompt(srcDir, docsDir, reference, journey, existingDocs) {
|
|
92
92
|
const scope = journey
|
|
93
93
|
? [
|
|
94
94
|
`SCOPE: document ONLY the single journey matching "${journey}"`,
|
|
@@ -123,11 +123,26 @@ export function buildReferenceDiscoverPrompt(srcDir, docsDir, reference, journey
|
|
|
123
123
|
`For EACH journey the reference describes, create one markdown file at`,
|
|
124
124
|
`"${docsDir}/<area>/<journey>.md" (area = folder such as api/web/ops,`,
|
|
125
125
|
`short kebab-case journey name; do NOT put the area in the file name).`,
|
|
126
|
-
`Also create a doc for any journey that exists in the map but NOT in`,
|
|
127
|
-
`the reference, drafted from the map, and add a line`,
|
|
128
|
-
`"# TODO: not covered by the reference — confirm expected behavior"`,
|
|
129
|
-
`at its top so it is clearly flagged.`,
|
|
130
126
|
``,
|
|
127
|
+
...(existingDocs
|
|
128
|
+
? [
|
|
129
|
+
`THIS REFERENCE COVERS PART OF THE PRODUCT, NOT ALL OF IT. The`,
|
|
130
|
+
`journeys below already have reviewed docs written from the`,
|
|
131
|
+
`project's own specification, which this reference has never heard`,
|
|
132
|
+
`of. Do NOT open, rewrite, re-draft or "improve" any of them, and do`,
|
|
133
|
+
`NOT add a not-covered note to them. Touch ONLY the journey or`,
|
|
134
|
+
`journeys this reference actually describes — creating a new file if`,
|
|
135
|
+
`the feature is new, or updating that one file if it already exists.`,
|
|
136
|
+
...existingDocs.map((d) => ` - ${d}`),
|
|
137
|
+
``,
|
|
138
|
+
]
|
|
139
|
+
: [
|
|
140
|
+
`Also create a doc for any journey that exists in the map but NOT in`,
|
|
141
|
+
`the reference, drafted from the map, and add a line`,
|
|
142
|
+
`"# TODO: not covered by the reference — confirm expected behavior"`,
|
|
143
|
+
`at its top so it is clearly flagged.`,
|
|
144
|
+
``,
|
|
145
|
+
]),
|
|
131
146
|
`Each file starts with this header (required):`,
|
|
132
147
|
``,
|
|
133
148
|
`# Journey: <Name>`,
|
|
@@ -181,6 +196,6 @@ export function analyzeReference(reference, docsDir) {
|
|
|
181
196
|
* Draft the journey docs from the reference. Returns the AI's summary of
|
|
182
197
|
* the journeys it documented.
|
|
183
198
|
*/
|
|
184
|
-
export function draftDocsFromReference(srcDir, docsDir, reference, journey) {
|
|
185
|
-
return runAI(buildReferenceDiscoverPrompt(srcDir, docsDir, reference, journey));
|
|
199
|
+
export function draftDocsFromReference(srcDir, docsDir, reference, journey, existingDocs) {
|
|
200
|
+
return runAI(buildReferenceDiscoverPrompt(srcDir, docsDir, reference, journey, existingDocs));
|
|
186
201
|
}
|
package/dist/core/report.js
CHANGED
|
@@ -207,8 +207,15 @@ for (const f of ['jest.config.js','jest.config.cjs','jest.config.json']) {
|
|
|
207
207
|
if (fs.existsSync(path.join(__dirname, f))) { base = require('./' + f); break; }
|
|
208
208
|
}
|
|
209
209
|
if (!Object.keys(base).length && fs.existsSync(path.join(__dirname,'package.json'))) base = require('./package.json').jest || {};
|
|
210
|
+
// Strip the project's reporters. Spreading them in meant OUR OWN publish
|
|
211
|
+
// reporter ran during a count — and since every test body is a no-op here, it
|
|
212
|
+
// published a full, entirely green run over the real results. A counting pass
|
|
213
|
+
// must be invisible: it reports nothing, because it tested nothing.
|
|
214
|
+
const { reporters, ...rest } = base;
|
|
215
|
+
void reporters;
|
|
210
216
|
module.exports = {
|
|
211
|
-
...
|
|
217
|
+
...rest,
|
|
218
|
+
reporters: ['default'],
|
|
212
219
|
testEnvironment: 'node', // plain node env for a count-only run
|
|
213
220
|
setupFilesAfterEnv: [...(base.setupFilesAfterEnv || []), path.resolve(__dirname, '.tf-collect-patch.cjs')],
|
|
214
221
|
};
|
|
@@ -276,7 +283,10 @@ function jestCount(dir, ext, category, out) {
|
|
|
276
283
|
*/
|
|
277
284
|
function playwrightList(pkgDir, category, out) {
|
|
278
285
|
try {
|
|
279
|
-
|
|
286
|
+
// `--reporter=list` so the project's own reporters — ours included — do
|
|
287
|
+
// not run during a count. Listing publishes nothing on its own, but a
|
|
288
|
+
// reporter that fires on end would report a run that never happened.
|
|
289
|
+
const res = execSync('npx playwright test --list --reporter=list', {
|
|
280
290
|
cwd: pkgDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 180000,
|
|
281
291
|
});
|
|
282
292
|
const counts = {};
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, } from 'node:fs';
|
|
20
20
|
import { join, dirname } from 'node:path';
|
|
21
21
|
import { CACHE_PATH, COVERAGE_PATH, PLANS_DIR, STATE_DIR } from './paths.js';
|
|
22
|
+
import { lintGenerated } from './lint.js';
|
|
22
23
|
import { parseJourney } from './parser.js';
|
|
23
24
|
import { generateTestCases } from './generator.js';
|
|
24
25
|
import { writeDeferredStub } from './playwright-writer.js';
|
|
@@ -402,7 +403,12 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
402
403
|
selectorsByArea.set(areaDir, knownSelectors);
|
|
403
404
|
}
|
|
404
405
|
}
|
|
405
|
-
const testGate = (content) => checkTestGates(content, ext, {
|
|
406
|
+
const testGate = (content) => checkTestGates(content, ext, {
|
|
407
|
+
framework,
|
|
408
|
+
allowedModules,
|
|
409
|
+
knownSelectors,
|
|
410
|
+
externalModules: config.externalModules,
|
|
411
|
+
});
|
|
406
412
|
// Skip if unchanged — but NOT if the journey still has deferred stubs
|
|
407
413
|
// to retry (a resume pass fills those in without touching real files).
|
|
408
414
|
if (!force && cache[file] === hash && !hasDeferredStubs(testDir, ext)) {
|
|
@@ -561,6 +567,23 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
|
|
|
561
567
|
// it — so a later `generate` (once the limit resets) RE-DOES it with
|
|
562
568
|
// AI instead of skipping it as "done". Persist per-journey so an
|
|
563
569
|
// interrupted run still resumes from where it stopped.
|
|
570
|
+
// Hand what we just wrote to the project's own linter. CI runs lint before
|
|
571
|
+
// it runs a test, so a file that is correct but formatted differently fails
|
|
572
|
+
// the build — and somebody fixes it by hand afterwards. Style is fixed in
|
|
573
|
+
// place; whatever survives is reported, because an unused declaration in a
|
|
574
|
+
// test is a claim nothing checks.
|
|
575
|
+
const lintTargets = [
|
|
576
|
+
...testFiles.map((t) => (typeof t === 'string' ? t : t.path)),
|
|
577
|
+
...(seedPath ? [seedPath] : []),
|
|
578
|
+
];
|
|
579
|
+
const lint = lintGenerated(lintTargets);
|
|
580
|
+
if (lint.fixed > 0) {
|
|
581
|
+
console.log(` tidied ${lint.fixed} lint problem(s) in ${lint.checked} generated file(s)`);
|
|
582
|
+
}
|
|
583
|
+
if (lint.remaining.length) {
|
|
584
|
+
warnings.push(`lint: ${lint.remaining.length} problem(s) the linter could not fix — ` +
|
|
585
|
+
lint.remaining.slice(0, 5).join('; '));
|
|
586
|
+
}
|
|
564
587
|
const degraded = useAI && (status === 'basic' || seedStatus === 'failed');
|
|
565
588
|
if (!degraded) {
|
|
566
589
|
cache[file] = hash;
|
|
@@ -30,7 +30,13 @@ export function loadSourceChoice() {
|
|
|
30
30
|
try {
|
|
31
31
|
const parsed = JSON.parse(readFileSync(SOURCE_PATH, 'utf-8'));
|
|
32
32
|
if (parsed && (parsed.mode === 'map' || parsed.mode === 'reference')) {
|
|
33
|
-
return {
|
|
33
|
+
return {
|
|
34
|
+
mode: parsed.mode,
|
|
35
|
+
reference: parsed.reference,
|
|
36
|
+
journeys: parsed.journeys && typeof parsed.journeys === 'object'
|
|
37
|
+
? parsed.journeys
|
|
38
|
+
: undefined,
|
|
39
|
+
};
|
|
34
40
|
}
|
|
35
41
|
}
|
|
36
42
|
catch {
|
|
@@ -38,10 +44,39 @@ export function loadSourceChoice() {
|
|
|
38
44
|
}
|
|
39
45
|
return null;
|
|
40
46
|
}
|
|
41
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Remember the PROJECT's source choice. The per-journey map is carried over
|
|
49
|
+
* untouched: a ticket-sized reference must never cost the project the record
|
|
50
|
+
* of where its other journeys came from.
|
|
51
|
+
*/
|
|
42
52
|
export function saveSourceChoice(choice) {
|
|
53
|
+
const existing = loadSourceChoice();
|
|
54
|
+
const journeys = choice.journeys ?? existing?.journeys;
|
|
55
|
+
const next = { mode: choice.mode };
|
|
56
|
+
if (choice.reference)
|
|
57
|
+
next.reference = choice.reference;
|
|
58
|
+
if (journeys && Object.keys(journeys).length)
|
|
59
|
+
next.journeys = journeys;
|
|
43
60
|
mkdirSync(dirname(SOURCE_PATH), { recursive: true });
|
|
44
|
-
writeFileSync(SOURCE_PATH, JSON.stringify(
|
|
61
|
+
writeFileSync(SOURCE_PATH, JSON.stringify(next, null, 2), 'utf-8');
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Record that these docs came from a reference of their own — a ticket, a
|
|
65
|
+
* one-feature PDF — so a later refresh goes back to THAT document and not to
|
|
66
|
+
* the project-wide spec, which has never heard of the feature.
|
|
67
|
+
*/
|
|
68
|
+
export function saveJourneyReferences(docPaths, reference) {
|
|
69
|
+
if (!docPaths.length)
|
|
70
|
+
return;
|
|
71
|
+
const existing = loadSourceChoice() || { mode: 'reference' };
|
|
72
|
+
const journeys = { ...(existing.journeys || {}) };
|
|
73
|
+
for (const d of docPaths)
|
|
74
|
+
journeys[d] = reference;
|
|
75
|
+
saveSourceChoice({
|
|
76
|
+
mode: existing.mode,
|
|
77
|
+
reference: existing.reference,
|
|
78
|
+
journeys,
|
|
79
|
+
});
|
|
45
80
|
}
|
|
46
81
|
/**
|
|
47
82
|
* Decide where the docs should come from for this run. May prompt the
|
package/dist/core/update.js
CHANGED
|
@@ -70,6 +70,16 @@ export async function runUpdate(srcDir, opts = {}) {
|
|
|
70
70
|
// without prompting — an update is a refresh, not a re-decision.
|
|
71
71
|
console.log('Updating journey docs...');
|
|
72
72
|
await runDiscover(srcDir, 'docs', { noPrompt: true });
|
|
73
|
+
// Journeys that arrived on their own document — a ticket's attached spec —
|
|
74
|
+
// are refreshed from THAT document. The project specification does not
|
|
75
|
+
// describe them, so refreshing them from it would quietly replace a written
|
|
76
|
+
// requirement with whatever the code currently does.
|
|
77
|
+
const ownRefs = Object.entries(src.journeys || {}).filter(([, ref]) => ref !== src.reference);
|
|
78
|
+
for (const [doc, ref] of ownRefs) {
|
|
79
|
+
const name = doc.replace(/\.md$/, '').split('/').pop();
|
|
80
|
+
console.log(` refreshing ${name} from its own reference: ${ref}`);
|
|
81
|
+
await runDiscover(srcDir, 'docs', { reference: ref, noPrompt: true }, name);
|
|
82
|
+
}
|
|
73
83
|
console.log(' done — docs/\n');
|
|
74
84
|
console.log('Regenerating tests for changed journeys...');
|
|
75
85
|
const results = await runGenerate('docs', true, false);
|
package/dist/core/validate.js
CHANGED
|
@@ -20,6 +20,28 @@ import { execFileSync } from 'node:child_process';
|
|
|
20
20
|
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { tmpdir } from 'node:os';
|
|
23
|
+
/**
|
|
24
|
+
* Third-party network SDKs common enough to be worth gating everywhere:
|
|
25
|
+
* a backend test that imports one without mocking it is talking to a real
|
|
26
|
+
* service, which flakes offline and can fire real charges, messages or
|
|
27
|
+
* emails. Deliberately NOT exhaustive and deliberately NOT tied to any one
|
|
28
|
+
* industry — every project names its own in `externalModules`, and a
|
|
29
|
+
* shipping API, a CMS, a games backend or an ad network is as much a live
|
|
30
|
+
* dependency as a payment provider. Database clients are excluded on
|
|
31
|
+
* purpose: a backend integration test may legitimately use a real local DB.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_EXTERNAL_MODULES = [
|
|
34
|
+
// payment
|
|
35
|
+
'stripe', 'braintree', '@paypal/checkout-server-sdk', 'razorpay', 'square',
|
|
36
|
+
// messaging / email
|
|
37
|
+
'twilio', '@sendgrid/mail', '@sendgrid/client', 'nodemailer', 'postmark',
|
|
38
|
+
'mailgun.js', '@slack/web-api', 'resend',
|
|
39
|
+
// cloud
|
|
40
|
+
'aws-sdk', '@aws-sdk/client-ses', '@aws-sdk/client-sns', '@aws-sdk/client-s3',
|
|
41
|
+
'@google-cloud/storage', '@azure/storage-blob',
|
|
42
|
+
// identity / support / analytics
|
|
43
|
+
'auth0', 'firebase-admin', 'node-zendesk', 'intercom-client', 'analytics-node',
|
|
44
|
+
];
|
|
23
45
|
/**
|
|
24
46
|
* Semantic gate for a generated test file. Returns a human-readable
|
|
25
47
|
* reason string if the content violates a guardrail, or null if it is
|
|
@@ -70,14 +92,14 @@ export function checkTestGates(content, _ext, ctx) {
|
|
|
70
92
|
}
|
|
71
93
|
}
|
|
72
94
|
// F11 — hermetic seam (jest/backend): a test that imports a known
|
|
73
|
-
// EXTERNAL network service
|
|
95
|
+
// EXTERNAL network service must mock it, or the
|
|
74
96
|
// suite silently depends on a live third party — it flakes offline and can
|
|
75
97
|
// fire real charges/messages. DB clients are intentionally NOT listed:
|
|
76
98
|
// backend integration tests legitimately use a real (local) database.
|
|
77
99
|
if (ctx.framework === 'jest') {
|
|
78
100
|
const EXTERNAL = new Set([
|
|
79
|
-
|
|
80
|
-
|
|
101
|
+
...DEFAULT_EXTERNAL_MODULES,
|
|
102
|
+
...(ctx.externalModules || []),
|
|
81
103
|
]);
|
|
82
104
|
for (const mod of importedModules(content)) {
|
|
83
105
|
const top = topLevelModule(mod);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@activate-core/test-forge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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": {
|