@activate-core/test-forge 0.14.0 → 0.15.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 +91 -5
- package/dist/cli/index.js +6 -2
- package/dist/core/ai-generator.js +78 -10
- package/dist/core/cache.js +86 -0
- package/dist/core/config.js +1 -0
- package/dist/core/harness.js +10 -10
- package/dist/core/lint.js +105 -0
- package/dist/core/report.js +80 -3
- package/dist/core/run-generate.js +24 -1
- package/dist/core/update.js +39 -6
- package/dist/core/validate.js +25 -3
- package/package.json +1 -1
- package/reporters/jest-reporter.cjs +8 -1
- package/reporters/playwright-reporter.cjs +10 -1
package/README.md
CHANGED
|
@@ -35,13 +35,44 @@ When your code changes later:
|
|
|
35
35
|
testforge update # re-check and refresh only what changed
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
### Working on one journey
|
|
39
|
+
|
|
40
|
+
`update` is the whole-project command: if the code has moved it re-maps,
|
|
41
|
+
re-drafts **every** doc and regenerates. Most of the time you want one
|
|
42
|
+
journey, and both steps take `--journey` (or `--area`):
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# the spec for one journey changed — re-draft just that doc, then its tests
|
|
46
|
+
testforge discover --journey payment
|
|
47
|
+
testforge generate --journey payment
|
|
48
|
+
|
|
49
|
+
# adding a journey that the reference now describes
|
|
50
|
+
testforge discover --journey refund-flow
|
|
51
|
+
testforge generate --journey refund-flow
|
|
52
|
+
|
|
53
|
+
# everything in one area
|
|
54
|
+
testforge generate --area ops
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
A journey **is** its doc: `generate` reads `docs/` and treats every markdown
|
|
58
|
+
file there as one journey. So a doc you write by hand — for a feature the
|
|
59
|
+
reference does not cover yet, or one taken from a ticket — works exactly like a
|
|
60
|
+
drafted one. Write the doc, then `generate --journey <name>`.
|
|
61
|
+
|
|
62
|
+
If you edited a doc yourself, skip `discover` entirely and go straight to
|
|
63
|
+
`generate --journey <name>`.
|
|
64
|
+
|
|
65
|
+
Regeneration replaces: when a journey is regenerated, test files for checks that
|
|
66
|
+
no longer exist are deleted — but only files TestForge wrote. Your own tests are
|
|
67
|
+
never touched.
|
|
68
|
+
|
|
38
69
|
## Commands
|
|
39
70
|
|
|
40
71
|
| Command | What it does |
|
|
41
72
|
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
42
73
|
| `testforge setup` | One-time setup: runs init, map, and discover in order, with progress. |
|
|
43
74
|
| `testforge generate` | Generates a test file and a seed file for each journey doc. Only changed docs are regenerated. |
|
|
44
|
-
| `testforge update` |
|
|
75
|
+
| `testforge update` | Refreshes the map, docs and tests when the **source code** or the **reference** has changed. Skips everything if neither has (no AI calls). Broad by design — for one journey, see below. |
|
|
45
76
|
|
|
46
77
|
### Advanced (run a single step)
|
|
47
78
|
|
|
@@ -51,6 +82,41 @@ testforge update # re-check and refresh only what changed
|
|
|
51
82
|
| `testforge map` | Rebuild the project map (`testforge.md`) only. |
|
|
52
83
|
| `testforge discover` | Redraft the per-journey docs in `docs/` only. |
|
|
53
84
|
| `testforge env` | Rebuild `.env.testing.example` from the docs only. |
|
|
85
|
+
| `testforge run` | Run the generated tests (`--journey` / `--area` / `--env-file`). |
|
|
86
|
+
| `testforge report` | Publish results to the dashboard — `--results` after a run, `--inventory` to register every generated check. |
|
|
87
|
+
|
|
88
|
+
### Flags worth knowing
|
|
89
|
+
|
|
90
|
+
| Flag | On | What it does |
|
|
91
|
+
| -------------------------- | ---------------------- | ---------------------------------------------------------------------------- |
|
|
92
|
+
| `--journey <name>` | discover, generate, run | Work on one journey instead of all of them. |
|
|
93
|
+
| `--area <name>` | discover, generate, run | Work on one area (api, web, ops…). |
|
|
94
|
+
| `--force` | generate | Regenerate even when the doc has not changed. |
|
|
95
|
+
| `--refresh-docs` | update | Re-draft the docs even when nothing detectably changed — see below. |
|
|
96
|
+
| `--reference <file\|url>` | setup | Base the docs on a spec instead of the code map. |
|
|
97
|
+
| `--source map` | setup | Base the docs on `testforge.md`, no prompt. |
|
|
98
|
+
|
|
99
|
+
#### When the reference cannot be checked
|
|
100
|
+
|
|
101
|
+
`update` compares two things: a hash of your source code, and a hash of the
|
|
102
|
+
reference the docs came from. A reference it can read (a file, or a plain URL)
|
|
103
|
+
is compared directly, so a spec that moved while the code stood still is
|
|
104
|
+
detected — that case used to pass silently as "everything is up to date".
|
|
105
|
+
|
|
106
|
+
Some references cannot be read at all. A Notion or Confluence link answers a
|
|
107
|
+
plain fetch with the application, not the document: no page content, and a build
|
|
108
|
+
stamp that changes on every vendor deploy. Hashing that would be worse than not
|
|
109
|
+
checking — it would report "unchanged" while the spec moved, and "changed" every
|
|
110
|
+
time the vendor shipped. TestForge refuses it and tells you what it could not
|
|
111
|
+
check:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
No source changes detected.
|
|
115
|
+
|
|
116
|
+
Note: the reference could not be checked — app.notion.com serves an app
|
|
117
|
+
shell, not the document — its content needs the API.
|
|
118
|
+
If the specification has changed, run `testforge update --refresh-docs`.
|
|
119
|
+
```
|
|
54
120
|
|
|
55
121
|
## How it works
|
|
56
122
|
|
|
@@ -70,8 +136,18 @@ becomes a Jest test (HTTP + database checks); `Type: web` becomes a
|
|
|
70
136
|
Playwright test. Other areas use the framework set in config. It also
|
|
71
137
|
generates a seed file that provisions the test data each test needs.
|
|
72
138
|
|
|
73
|
-
**
|
|
74
|
-
|
|
139
|
+
**generate** hands each file it writes to the project's own ESLint with
|
|
140
|
+
`--fix` before moving on. Formatting differences are corrected in place — CI
|
|
141
|
+
runs lint before it runs a test, and a file that is correct but indented
|
|
142
|
+
differently still fails the build. Anything the linter cannot fix (an unused
|
|
143
|
+
declaration, say) is reported as a warning against that journey, because a
|
|
144
|
+
declaration nothing checks is worth a person's attention. If the project has no
|
|
145
|
+
ESLint, the step is skipped.
|
|
146
|
+
|
|
147
|
+
**update** hashes your source code AND the reference the docs came from,
|
|
148
|
+
and compares both to the last run. Neither changed means no work and no AI
|
|
149
|
+
calls; either one changed triggers a refresh, and it says which. A reference it
|
|
150
|
+
cannot read is reported as exactly that, never as "up to date".
|
|
75
151
|
|
|
76
152
|
## Source of truth
|
|
77
153
|
|
|
@@ -108,7 +184,7 @@ website, or a Notion page. testforge then:
|
|
|
108
184
|
|
|
109
185
|
The reference becomes the source of truth — expected behavior from a human
|
|
110
186
|
or client, not just what the code happens to do. The choice is remembered
|
|
111
|
-
in
|
|
187
|
+
in `test-artifacts/source.json`, so a later `testforge update` refreshes the
|
|
112
188
|
docs from the same source without asking again. Your review of
|
|
113
189
|
`testforge.diff.md` stays as an audit of what differed and why.
|
|
114
190
|
|
|
@@ -204,7 +280,8 @@ The test runner produces its own pass/fail report.
|
|
|
204
280
|
"ops": "playwright"
|
|
205
281
|
},
|
|
206
282
|
"seedOutput": "packages/api/test/helpers/seed",
|
|
207
|
-
"testExtension": ".test.ts"
|
|
283
|
+
"testExtension": ".test.ts",
|
|
284
|
+
"externalModules": ["shipengine", "@customerio/cdp-analytics"]
|
|
208
285
|
}
|
|
209
286
|
```
|
|
210
287
|
|
|
@@ -221,6 +298,15 @@ The test runner produces its own pass/fail report.
|
|
|
221
298
|
- **framework** — which test framework each area uses.
|
|
222
299
|
- **seedOutput** — where seed files go.
|
|
223
300
|
- **testExtension** — `.test.ts` or `.test.js`.
|
|
301
|
+
- **externalModules** — the third-party network services this project
|
|
302
|
+
talks to, named as a test would import them. A generated backend test
|
|
303
|
+
that imports one without mocking it is rejected before it is written:
|
|
304
|
+
left alone it would reach the live service, so it breaks offline and
|
|
305
|
+
can fire real charges, messages or shipments. testforge already knows
|
|
306
|
+
the common SDKs (payment, SMS, email, cloud, identity) and this list is
|
|
307
|
+
added to them, never instead of them — so name only what is particular
|
|
308
|
+
to your product. Every domain has something: a shipping carrier, a CMS,
|
|
309
|
+
a ticketing or streaming backend, an ad network.
|
|
224
310
|
|
|
225
311
|
## Notes
|
|
226
312
|
|
package/dist/cli/index.js
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* testforge generate Generate tests from the journey docs.
|
|
14
14
|
* testforge generate --basic Use the built-in basic generator only.
|
|
15
15
|
* testforge generate --force Regenerate everything, ignoring the cache.
|
|
16
|
+
* testforge update Refresh only what changed (code or reference).
|
|
17
|
+
* testforge update --refresh-docs Re-derive the docs even with no detected
|
|
18
|
+
* change — for a reference we cannot read
|
|
19
|
+
* (an auth-gated page can move unseen).
|
|
16
20
|
*/
|
|
17
21
|
import { VERSION } from '../core/index.js';
|
|
18
22
|
import { runGenerate } from '../core/run-generate.js';
|
|
@@ -65,7 +69,7 @@ function printUsage() {
|
|
|
65
69
|
console.log('Usage:');
|
|
66
70
|
console.log(' testforge setup Run init + map + discover in order');
|
|
67
71
|
console.log(' testforge setup --reference <file|url> Base the docs on a reference');
|
|
68
|
-
console.log(' testforge setup --source map Base the docs on testforge.md');
|
|
72
|
+
console.log(' testforge setup --source map Base the docs on testforge.md', ' testforge update --refresh-docs Re-derive docs from the reference');
|
|
69
73
|
console.log(' testforge generate Generate tests from the journey docs');
|
|
70
74
|
console.log(' testforge generate --basic Use the basic generator only');
|
|
71
75
|
console.log(' testforge generate --force Regenerate everything');
|
|
@@ -83,7 +87,7 @@ function printUsage() {
|
|
|
83
87
|
}
|
|
84
88
|
async function main() {
|
|
85
89
|
if (command === 'update') {
|
|
86
|
-
await runUpdate('.');
|
|
90
|
+
await runUpdate('.', { refreshDocs: process.argv.includes('--refresh-docs') });
|
|
87
91
|
}
|
|
88
92
|
else if (command === 'setup') {
|
|
89
93
|
await runSetup('.', sourceOptions());
|
|
@@ -129,20 +129,88 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
|
|
|
129
129
|
` test with a one-line reason naming exactly what was missing. Never`,
|
|
130
130
|
` let a missing precondition reach an assertion and report as a bug.`,
|
|
131
131
|
` Guard such tests at the top: check the precondition, skip if unmet.`,
|
|
132
|
+
` A CREDENTIAL THAT EXISTS IS NOT A CREDENTIAL THAT WORKS. A supplied`,
|
|
133
|
+
` token expires; treat "present" and "usable" as different checks.`,
|
|
134
|
+
` Where the harness accepts a pre-minted token, verify it is still in`,
|
|
135
|
+
` date (a JWT carries its own exp) before adopting it, and mint a`,
|
|
136
|
+
` fresh one when it is not. If no working credential can be obtained,`,
|
|
137
|
+
` skip and say so — a dead credential answers every authenticated`,
|
|
138
|
+
` request with 401/403, and a suite that asserts on those reports an`,
|
|
139
|
+
` entire product as broken when nothing is wrong with it.`,
|
|
132
140
|
`(3) NO FLAKY TESTS. Every test must pass or fail for the same reason on`,
|
|
133
141
|
` every run, in any order. Do not depend on execution order, on data`,
|
|
134
142
|
` another test creates or mutates, on wall-clock timing, or on`,
|
|
135
143
|
` ambient totals that other tests change (assert on the rows YOU`,
|
|
136
144
|
` seeded, by id — never on a global count). Set up what you assert`,
|
|
137
145
|
` on, and assert only on that. Wait for a specific condition, never a`,
|
|
138
|
-
` fixed sleep
|
|
146
|
+
` fixed sleep. Prefer a retrying assertion to a one-shot read: a value`,
|
|
147
|
+
` the page writes after hydration, or re-renders on its way somewhere`,
|
|
148
|
+
` else, is not there at the instant you first look. And a read that`,
|
|
149
|
+
` runs in the page (evaluate) dies when a redirect lands mid-poll —`,
|
|
150
|
+
` tolerate that error and let the poll retry, rather than reporting`,
|
|
151
|
+
` a destroyed context as a product failure.`,
|
|
139
152
|
`(4) NEVER ASSERT AN UNFIXED VALUE. Do not assert exact user-facing copy,`,
|
|
140
153
|
` incidental ordering, generated ids, timestamps, or counts the`,
|
|
141
154
|
` document does not fix. Assert the behaviour the document specifies,`,
|
|
142
155
|
` at the loosest form that still proves it.`,
|
|
143
|
-
`(5)
|
|
156
|
+
`(5) PROVE YOUR NEGATIVE INPUT IS ACTUALLY NEGATIVE. A security test that`,
|
|
157
|
+
` feeds a "bad" credential, signature, id or payload is only as good as`,
|
|
158
|
+
` the badness of that input. Construct it so it MUST differ from the`,
|
|
159
|
+
` valid one at the level the product checks — mutate decoded bytes, not`,
|
|
160
|
+
` an encoded character, since encodings carry spare bits that make`,
|
|
161
|
+
` edits invisible. Where you can, assert the control too: the valid`,
|
|
162
|
+
` input is accepted and the invalid one is not. An input that only`,
|
|
163
|
+
` looks tampered turns correct behaviour into a reported breach, which`,
|
|
164
|
+
` is the most damaging false alarm there is.`,
|
|
165
|
+
`(6) A STUB MUST NEVER INTERCEPT THE APPLICATION ITSELF. When you fake a`,
|
|
166
|
+
` third party, match it by HOST (or an exact URL), never by a substring`,
|
|
167
|
+
` of the URL: a pattern like "**evil.example**" also matches your own`,
|
|
168
|
+
` page's "?next=https%3A%2F%2Fevil.example", so the app is served the`,
|
|
169
|
+
` stub, nothing renders, and the test fails on a screen that never`,
|
|
170
|
+
` loaded. Compare hostnames case-insensitively. After installing`,
|
|
171
|
+
` stubs, satisfy yourself the page under test still loads.`,
|
|
172
|
+
`(7) BIND A FIXTURE TO THE IDENTITY THE SCREEN IS SCOPED BY. Before`,
|
|
173
|
+
` seeding for a role-scoped page, find the exact key the server scopes`,
|
|
174
|
+
` on — a user _id, a phone number, an account id — and which sign-in`,
|
|
175
|
+
` the page uses. Seed against THAT identity. Do not reuse whatever the`,
|
|
176
|
+
` harness minted first: the primary token is usually a different role,`,
|
|
177
|
+
` and an auth/IdP subject is not the same value as the database id`,
|
|
178
|
+
` (stripping a "sms|" prefix yields something id-SHAPED that belongs`,
|
|
179
|
+
` to nobody). Fixtures hung off the wrong identity make every figure`,
|
|
180
|
+
` read zero and every list read empty, which looks exactly like the`,
|
|
181
|
+
` product losing data.`,
|
|
182
|
+
`(8) SEED ONLY STATES THE PRODUCT CAN REACH. Build every fixture the way`,
|
|
183
|
+
` the code would: follow the transition that produces it and copy the`,
|
|
184
|
+
` fields that transition writes. A hand-assembled record combining`,
|
|
185
|
+
` fields no code path puts together is not a test case — the product`,
|
|
186
|
+
` owes it no behaviour, so whatever it does with it proves nothing.`,
|
|
187
|
+
` Before seeding a state, find the write that produces it; if there`,
|
|
188
|
+
` is none, the scenario is imaginary — drop it.`,
|
|
189
|
+
`(9) A TIMEOUT IS NOT A BUG REPORT. If an action legitimately waits on`,
|
|
144
190
|
` something external, verify availability first and skip when absent.`,
|
|
145
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.`,
|
|
146
214
|
`When rules conflict, prefer reporting LESS: a pending test costs a line`,
|
|
147
215
|
`in the report; a false failure costs the team a release.`,
|
|
148
216
|
``,
|
|
@@ -174,8 +242,8 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
|
|
|
174
242
|
`call in the file.`,
|
|
175
243
|
`AUTH & SCOPING: a route may be declared auth:false yet STILL populate`,
|
|
176
244
|
`request.user when a Bearer token is sent (a pre-auth extension). So`,
|
|
177
|
-
`owner-scoped data ("my
|
|
178
|
-
`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="`,
|
|
179
247
|
`fallback overrides the token for an AUTHENTICATED call: the testkit`,
|
|
180
248
|
`seeds the owner fixtures under the authenticated identity, so expect`,
|
|
181
249
|
`the token owner's rows. A second, non-owner identity (for IDOR/scope`,
|
|
@@ -204,13 +272,13 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
|
|
|
204
272
|
`process.env.DB_URL) — never hardcode a connection string.`,
|
|
205
273
|
`MOCKING (MANDATORY — the test must be HERMETIC): the test must`,
|
|
206
274
|
`NEVER depend on a live external service. For EVERY external`,
|
|
207
|
-
`service the exercised code reaches
|
|
208
|
-
`
|
|
209
|
-
`read the source to see which client it imports (
|
|
210
|
-
`
|
|
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`,
|
|
211
279
|
`so the mock truly intercepts. A test that leaves any such call`,
|
|
212
|
-
`unmocked (so it would
|
|
213
|
-
`
|
|
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`,
|
|
214
282
|
`not deterministic. Do NOT mock a client the code doesn't use.`,
|
|
215
283
|
`HERMETIC BASE URLS: if the client's target is built from an env`,
|
|
216
284
|
`var (e.g. process.env.IM_API_URL), do NOT rely on the ambient`,
|
package/dist/core/cache.js
CHANGED
|
@@ -105,3 +105,89 @@ export function hashProjectSource(rootDir) {
|
|
|
105
105
|
walk(rootDir);
|
|
106
106
|
return hashContent(parts.join('\n---\n'));
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Is this response the document, or the application that would render it?
|
|
110
|
+
*
|
|
111
|
+
* Notion (and Confluence, and Google Docs) answer a plain fetch with 200 and a
|
|
112
|
+
* single-page-app shell: megabytes of script, none of the document's text, and
|
|
113
|
+
* a build stamp that changes every time they deploy. Hashing that is worse than
|
|
114
|
+
* not checking at all — it reports "unchanged" while the specification moves,
|
|
115
|
+
* and "changed" every time the vendor ships. Refuse it and say why.
|
|
116
|
+
*/
|
|
117
|
+
function looksLikeAppShell(url, body) {
|
|
118
|
+
let host = '';
|
|
119
|
+
try {
|
|
120
|
+
host = new URL(url).hostname.toLowerCase();
|
|
121
|
+
}
|
|
122
|
+
catch { /* keep '' */ }
|
|
123
|
+
const HOSTED_EDITORS = ['notion.so', 'notion.com', 'notion.site', 'atlassian.net', 'docs.google.com', 'sharepoint.com'];
|
|
124
|
+
if (HOSTED_EDITORS.some((h) => host === h || host.endsWith('.' + h))) {
|
|
125
|
+
return `${host} serves an app shell, not the document — its content needs the API`;
|
|
126
|
+
}
|
|
127
|
+
// Generic net for anything else that ships a shell: almost all markup and
|
|
128
|
+
// script, almost no readable text.
|
|
129
|
+
const text = body.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
130
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
131
|
+
.replace(/<[^>]+>/g, ' ')
|
|
132
|
+
.replace(/\s+/g, ' ')
|
|
133
|
+
.trim();
|
|
134
|
+
if (body.length > 50_000 && text.length / body.length < 0.02) {
|
|
135
|
+
return 'the page returned an app shell rather than readable content';
|
|
136
|
+
}
|
|
137
|
+
if (/<title>[^<]*(log ?in|sign ?in|authenticate)/i.test(body)) {
|
|
138
|
+
return 'the page requires sign-in';
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Hash the reference the journey docs are derived from.
|
|
144
|
+
*
|
|
145
|
+
* The source hash covers the CODE, which answers "has the implementation
|
|
146
|
+
* moved?". It says nothing about the specification. A reference that changes
|
|
147
|
+
* while the code stands still is the case that matters most — the docs, and so
|
|
148
|
+
* every assertion, are now out of date — and it used to pass silently as
|
|
149
|
+
* "everything is up to date".
|
|
150
|
+
*
|
|
151
|
+
* A local path is read directly. An http(s) reference is fetched. Anything we
|
|
152
|
+
* cannot read (a Notion page behind auth, no network) returns `unreadable` with
|
|
153
|
+
* the reason, so the caller can say so plainly instead of implying it checked.
|
|
154
|
+
*/
|
|
155
|
+
export async function hashReference(reference) {
|
|
156
|
+
const ref = (reference || '').trim();
|
|
157
|
+
if (!ref)
|
|
158
|
+
return { unreadable: 'no reference recorded' };
|
|
159
|
+
if (/^https?:\/\//i.test(ref)) {
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch(ref, { redirect: 'follow' });
|
|
162
|
+
if (!res.ok)
|
|
163
|
+
return { unreadable: `fetch returned ${res.status}` };
|
|
164
|
+
const body = await res.text();
|
|
165
|
+
const shell = looksLikeAppShell(ref, body);
|
|
166
|
+
if (shell)
|
|
167
|
+
return { unreadable: shell };
|
|
168
|
+
return { hash: hashContent(body) };
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
return { unreadable: e.message.slice(0, 80) };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const st = statSync(ref);
|
|
176
|
+
if (st.isDirectory()) {
|
|
177
|
+
const parts = [];
|
|
178
|
+
for (const entry of readdirSync(ref).sort()) {
|
|
179
|
+
const full = join(ref, entry);
|
|
180
|
+
try {
|
|
181
|
+
if (statSync(full).isFile())
|
|
182
|
+
parts.push(entry + ':' + hashContent(readFileSync(full, 'utf-8')));
|
|
183
|
+
}
|
|
184
|
+
catch { /* unreadable entry — skip */ }
|
|
185
|
+
}
|
|
186
|
+
return { hash: hashContent(parts.join('\n')) };
|
|
187
|
+
}
|
|
188
|
+
return { hash: hashContent(readFileSync(ref, 'utf-8')) };
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
return { unreadable: e.message.slice(0, 80) };
|
|
192
|
+
}
|
|
193
|
+
}
|
package/dist/core/config.js
CHANGED
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/report.js
CHANGED
|
@@ -99,6 +99,70 @@ export async function publishReport(opts) {
|
|
|
99
99
|
});
|
|
100
100
|
return { status: res.status, text: await res.text() };
|
|
101
101
|
}
|
|
102
|
+
const PENDING_CALL = /^[ \t]*(?:it|test)\.(?:todo|fixme)\(\s*(['"`])([\s\S]*?)\1/;
|
|
103
|
+
/** Read the pending checks out of a test file, each with the reason recorded
|
|
104
|
+
* beside it: the contiguous `//` comment block immediately above the call. */
|
|
105
|
+
export function scanPending(file) {
|
|
106
|
+
let lines;
|
|
107
|
+
try {
|
|
108
|
+
lines = fs.readFileSync(file, 'utf8').split('\n');
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
const out = [];
|
|
114
|
+
for (let i = 0; i < lines.length; i++) {
|
|
115
|
+
const m = PENDING_CALL.exec(lines[i]);
|
|
116
|
+
if (!m)
|
|
117
|
+
continue;
|
|
118
|
+
const parts = [];
|
|
119
|
+
for (let j = i - 1; j >= 0 && j > i - 8; j--) {
|
|
120
|
+
const t = lines[j].trim();
|
|
121
|
+
if (!t.startsWith('//')) {
|
|
122
|
+
if (t)
|
|
123
|
+
break;
|
|
124
|
+
else
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
// Section banners ("// ----- Pending — ... -----") head a whole block and
|
|
128
|
+
// say nothing about THIS check; strip the rule and drop what is left if
|
|
129
|
+
// it is only decoration.
|
|
130
|
+
const body = t.replace(/^\/+\s?/, '').replace(/-{3,}/g, ' ').replace(/\s+/g, ' ').trim();
|
|
131
|
+
if (!body)
|
|
132
|
+
continue;
|
|
133
|
+
parts.unshift(body);
|
|
134
|
+
}
|
|
135
|
+
let reason = parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
136
|
+
// A generic block header is not a reason for one check.
|
|
137
|
+
if (/^pending\s*[—-]/i.test(reason) && reason.length < 90)
|
|
138
|
+
reason = '';
|
|
139
|
+
if (reason.length < 15)
|
|
140
|
+
reason = '';
|
|
141
|
+
out.push({ title: m[2].replace(/\s+/g, ' ').trim().slice(0, 300), ...(reason ? { reason: reason.slice(0, 400) } : {}) });
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
/** Attach pending-case reasons to every suite whose file we can locate. */
|
|
146
|
+
function attachPending(dir, ext, suites) {
|
|
147
|
+
const files = [];
|
|
148
|
+
try {
|
|
149
|
+
walk(dir, ext, files);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const byName = new Map();
|
|
155
|
+
for (const f of files)
|
|
156
|
+
byName.set(suiteName(f), f);
|
|
157
|
+
for (const s of suites) {
|
|
158
|
+
const f = byName.get(s.name);
|
|
159
|
+
if (!f || s.pending)
|
|
160
|
+
continue;
|
|
161
|
+
const pending = scanPending(f);
|
|
162
|
+
if (pending.length)
|
|
163
|
+
s.pending = pending;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
102
166
|
function walk(dir, ext, out) {
|
|
103
167
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
104
168
|
const full = path.join(dir, e.name);
|
|
@@ -143,8 +207,15 @@ for (const f of ['jest.config.js','jest.config.cjs','jest.config.json']) {
|
|
|
143
207
|
if (fs.existsSync(path.join(__dirname, f))) { base = require('./' + f); break; }
|
|
144
208
|
}
|
|
145
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;
|
|
146
216
|
module.exports = {
|
|
147
|
-
...
|
|
217
|
+
...rest,
|
|
218
|
+
reporters: ['default'],
|
|
148
219
|
testEnvironment: 'node', // plain node env for a count-only run
|
|
149
220
|
setupFilesAfterEnv: [...(base.setupFilesAfterEnv || []), path.resolve(__dirname, '.tf-collect-patch.cjs')],
|
|
150
221
|
};
|
|
@@ -212,7 +283,10 @@ function jestCount(dir, ext, category, out) {
|
|
|
212
283
|
*/
|
|
213
284
|
function playwrightList(pkgDir, category, out) {
|
|
214
285
|
try {
|
|
215
|
-
|
|
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', {
|
|
216
290
|
cwd: pkgDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 180000,
|
|
217
291
|
});
|
|
218
292
|
const counts = {};
|
|
@@ -247,8 +321,10 @@ export function scanInventory(cwd, testOutput, ext, framework = {}) {
|
|
|
247
321
|
continue;
|
|
248
322
|
if (framework[category] === 'playwright') {
|
|
249
323
|
// package root (holds playwright.config) is the parent of the tests dir
|
|
250
|
-
if (playwrightList(path.dirname(dir), category, suites))
|
|
324
|
+
if (playwrightList(path.dirname(dir), category, suites)) {
|
|
325
|
+
attachPending(dir, ext, suites);
|
|
251
326
|
continue;
|
|
327
|
+
}
|
|
252
328
|
console.warn(` ! playwright --list failed for "${category}" — using static estimate`);
|
|
253
329
|
jestStatic(dir, ext, category, suites);
|
|
254
330
|
}
|
|
@@ -256,6 +332,7 @@ export function scanInventory(cwd, testOutput, ext, framework = {}) {
|
|
|
256
332
|
// jest (and ops/lambda, also jest-based) — accurate collect-only counter
|
|
257
333
|
jestCount(dir, ext, category, suites);
|
|
258
334
|
}
|
|
335
|
+
attachPending(dir, ext, suites);
|
|
259
336
|
}
|
|
260
337
|
return suites;
|
|
261
338
|
}
|
|
@@ -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;
|
package/dist/core/update.js
CHANGED
|
@@ -12,11 +12,13 @@ import { existsSync } from 'node:fs';
|
|
|
12
12
|
import { runMap } from './map.js';
|
|
13
13
|
import { runDiscover } from './discover.js';
|
|
14
14
|
import { runGenerate } from './run-generate.js';
|
|
15
|
-
import { hashProjectSource, loadCache, saveCache } from './cache.js';
|
|
15
|
+
import { hashProjectSource, hashReference, loadCache, saveCache } from './cache.js';
|
|
16
|
+
import { loadSourceChoice } from './source-of-truth.js';
|
|
16
17
|
import { runEnvTemplate } from './env-template.js';
|
|
17
18
|
import { CACHE_PATH } from './paths.js';
|
|
18
19
|
const SOURCE_KEY = '__source__';
|
|
19
|
-
|
|
20
|
+
const REFERENCE_KEY = '__reference__';
|
|
21
|
+
export async function runUpdate(srcDir, opts = {}) {
|
|
20
22
|
if (!existsSync('testforge.md') || !existsSync('testforge.config.json')) {
|
|
21
23
|
console.log("This project isn't set up yet.");
|
|
22
24
|
console.log('Run `testforge setup` first.');
|
|
@@ -26,12 +28,41 @@ export async function runUpdate(srcDir) {
|
|
|
26
28
|
const cache = loadCache(CACHE_PATH);
|
|
27
29
|
const oldHash = cache[SOURCE_KEY];
|
|
28
30
|
const newHash = hashProjectSource(srcDir);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
const codeChanged = oldHash !== newHash;
|
|
32
|
+
// The docs are the source of truth for every assertion, so a reference that
|
|
33
|
+
// moved matters at least as much as code that moved. Checking only the code
|
|
34
|
+
// meant a changed spec was reported as "everything is up to date" — the tests
|
|
35
|
+
// then kept asserting a specification nobody uses any more.
|
|
36
|
+
const src = loadSourceChoice() || { mode: 'map' };
|
|
37
|
+
const refState = src.mode === 'reference' && src.reference
|
|
38
|
+
? await hashReference(src.reference)
|
|
39
|
+
: { unreadable: 'docs are derived from the code map, not a reference' };
|
|
40
|
+
const refChanged = Boolean(refState.hash) && cache[REFERENCE_KEY] !== undefined && cache[REFERENCE_KEY] !== refState.hash;
|
|
41
|
+
const refFirstSeen = Boolean(refState.hash) && cache[REFERENCE_KEY] === undefined;
|
|
42
|
+
if (!codeChanged && !refChanged && !opts.refreshDocs) {
|
|
43
|
+
console.log(`No source changes detected.${refState.hash ? ' The reference is unchanged too.' : ''}`);
|
|
44
|
+
if (refState.unreadable && src.mode === 'reference') {
|
|
45
|
+
// Say what was NOT checked. Claiming "up to date" while the specification
|
|
46
|
+
// could have moved unseen is the one thing this command must not do.
|
|
47
|
+
console.log(`\n Note: the reference could not be checked — ${refState.unreadable}.`);
|
|
48
|
+
console.log(` ${src.reference}`);
|
|
49
|
+
console.log(' If the specification has changed, run `testforge update --refresh-docs`.');
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
console.log('(Nothing regenerated — no AI calls made.)');
|
|
53
|
+
}
|
|
54
|
+
if (refFirstSeen) {
|
|
55
|
+
cache[REFERENCE_KEY] = refState.hash;
|
|
56
|
+
saveCache(CACHE_PATH, cache);
|
|
57
|
+
}
|
|
32
58
|
return;
|
|
33
59
|
}
|
|
34
|
-
|
|
60
|
+
const why = [
|
|
61
|
+
codeChanged ? 'source code changed' : null,
|
|
62
|
+
refChanged ? 'the reference changed' : null,
|
|
63
|
+
opts.refreshDocs && !codeChanged && !refChanged ? 'refresh requested' : null,
|
|
64
|
+
].filter(Boolean).join(' and ');
|
|
65
|
+
console.log(`Updating the project — ${why}.\n`);
|
|
35
66
|
console.log('Re-mapping the project...');
|
|
36
67
|
runMap(srcDir);
|
|
37
68
|
console.log(' done — testforge.md\n');
|
|
@@ -46,6 +77,8 @@ export async function runUpdate(srcDir) {
|
|
|
46
77
|
const skipped = results.filter((r) => r.status === 'skipped').length;
|
|
47
78
|
console.log(` done — ${made} updated, ${skipped} unchanged.\n`);
|
|
48
79
|
cache[SOURCE_KEY] = newHash;
|
|
80
|
+
if (refState.hash)
|
|
81
|
+
cache[REFERENCE_KEY] = refState.hash;
|
|
49
82
|
saveCache(CACHE_PATH, cache);
|
|
50
83
|
console.log('Refreshing env template...');
|
|
51
84
|
runEnvTemplate('docs', '.env.testing.example', srcDir);
|
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.15.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": {
|
|
@@ -27,6 +27,13 @@ module.exports = class TestForgeJestReporter {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
const durationSec = Math.round((results.startTime ? Date.now() - results.startTime : 0) / 1000);
|
|
30
|
-
|
|
30
|
+
const list = Object.values(suites);
|
|
31
|
+
// Same reasoning as the Playwright reporter: no suites means nothing ran,
|
|
32
|
+
// and a published 0/0/0 run reads as a clean result.
|
|
33
|
+
if (!list.length) {
|
|
34
|
+
console.log('[testforge] nothing ran — no run published');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
await publish(this.category, list, durationSec);
|
|
31
38
|
}
|
|
32
39
|
};
|
|
@@ -26,6 +26,15 @@ module.exports = class TestForgePlaywrightReporter {
|
|
|
26
26
|
}
|
|
27
27
|
async onEnd(result) {
|
|
28
28
|
const durationSec = Math.round(((result && result.duration) || (Date.now() - this.start)) / 1000);
|
|
29
|
-
|
|
29
|
+
const suites = Object.values(this.suites);
|
|
30
|
+
// A run that collected no suites did not test anything: global setup failed,
|
|
31
|
+
// or the filter matched nothing. Publishing it records a 0/0/0 result that
|
|
32
|
+
// reads as "the suite ran and found nothing wrong" — the most misleading
|
|
33
|
+
// thing this reporter could say. Say nothing instead.
|
|
34
|
+
if (!suites.length) {
|
|
35
|
+
console.log('[testforge] nothing ran — no run published');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
await publish(this.category, suites, durationSec);
|
|
30
39
|
}
|
|
31
40
|
};
|