@activate-core/test-forge 0.13.1 → 0.14.1
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 +72 -4
- package/dist/cli/index.js +6 -2
- package/dist/core/ai-generator.js +87 -0
- package/dist/core/cache.js +86 -0
- package/dist/core/doc-guidance.js +6 -0
- package/dist/core/reference.js +10 -0
- package/dist/core/report.js +68 -1
- package/dist/core/update.js +39 -6
- package/package.json +1 -1
- package/reporters/_shared.cjs +19 -3
- 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,10 @@ 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
|
-
**update** hashes your source code
|
|
74
|
-
|
|
139
|
+
**update** hashes your source code AND the reference the docs came from,
|
|
140
|
+
and compares both to the last run. Neither changed means no work and no AI
|
|
141
|
+
calls; either one changed triggers a refresh, and it says which. A reference it
|
|
142
|
+
cannot read is reported as exactly that, never as "up to date".
|
|
75
143
|
|
|
76
144
|
## Source of truth
|
|
77
145
|
|
|
@@ -108,7 +176,7 @@ website, or a Notion page. testforge then:
|
|
|
108
176
|
|
|
109
177
|
The reference becomes the source of truth — expected behavior from a human
|
|
110
178
|
or client, not just what the code happens to do. The choice is remembered
|
|
111
|
-
in
|
|
179
|
+
in `test-artifacts/source.json`, so a later `testforge update` refreshes the
|
|
112
180
|
docs from the same source without asking again. Your review of
|
|
113
181
|
`testforge.diff.md` stays as an audit of what differed and why.
|
|
114
182
|
|
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());
|
|
@@ -105,6 +105,93 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
|
|
|
105
105
|
`guard is missing and produce a failing test that falsely brands the`,
|
|
106
106
|
`product as vulnerable.`,
|
|
107
107
|
``,
|
|
108
|
+
`PRIME DIRECTIVE — A FAILING TEST MUST MEAN A REAL PRODUCT DEFECT.`,
|
|
109
|
+
`Teams hold releases on what these tests report. A test that fails for`,
|
|
110
|
+
`any reason other than the product being wrong is worse than no test at`,
|
|
111
|
+
`all: it burns the team's trust, and the next real defect gets waved`,
|
|
112
|
+
`through as "probably the tool again". So a test may FAIL only when the`,
|
|
113
|
+
`product provably contradicts an expectation the journey document`,
|
|
114
|
+
`states EXPLICITLY. Apply these rules without exception:`,
|
|
115
|
+
`(1) EXPLICIT-ORACLE ONLY. Before writing an assertion, find the exact`,
|
|
116
|
+
` line in the journey document that fixes the expected outcome, and`,
|
|
117
|
+
` cite it in a short comment (e.g. "// oracle: §4 total = fee +`,
|
|
118
|
+
` shipping"). If the expectation is merely implied, inferred, or`,
|
|
119
|
+
` "good practice" — including a rule that sounds obviously right —`,
|
|
120
|
+
` it is NOT an oracle: write it ${pending} with a "// TODO:" note.`,
|
|
121
|
+
` An assertion you cannot trace to a stated requirement is a false`,
|
|
122
|
+
` positive waiting to happen. Anything under a "Suggested (not a`,
|
|
123
|
+
` requirement…)" heading is explicitly NOT an oracle — never assert`,
|
|
124
|
+
` it; make it ${pending} if you cover it at all.`,
|
|
125
|
+
`(2) INFRASTRUCTURE IS NEVER A PRODUCT DEFECT. If a precondition cannot`,
|
|
126
|
+
` be established — a dependency is unreachable, a required env var or`,
|
|
127
|
+
` credential is absent, seed/fixture data is missing, a second`,
|
|
128
|
+
` identity is unavailable, an external provider is down — SKIP the`,
|
|
129
|
+
` test with a one-line reason naming exactly what was missing. Never`,
|
|
130
|
+
` let a missing precondition reach an assertion and report as a bug.`,
|
|
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.`,
|
|
140
|
+
`(3) NO FLAKY TESTS. Every test must pass or fail for the same reason on`,
|
|
141
|
+
` every run, in any order. Do not depend on execution order, on data`,
|
|
142
|
+
` another test creates or mutates, on wall-clock timing, or on`,
|
|
143
|
+
` ambient totals that other tests change (assert on the rows YOU`,
|
|
144
|
+
` seeded, by id — never on a global count). Set up what you assert`,
|
|
145
|
+
` on, and assert only on that. Wait for a specific condition, never a`,
|
|
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.`,
|
|
152
|
+
`(4) NEVER ASSERT AN UNFIXED VALUE. Do not assert exact user-facing copy,`,
|
|
153
|
+
` incidental ordering, generated ids, timestamps, or counts the`,
|
|
154
|
+
` document does not fix. Assert the behaviour the document specifies,`,
|
|
155
|
+
` at the loosest form that still proves it.`,
|
|
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`,
|
|
190
|
+
` something external, verify availability first and skip when absent.`,
|
|
191
|
+
` A test that times out tells the team nothing about the product.`,
|
|
192
|
+
`When rules conflict, prefer reporting LESS: a pending test costs a line`,
|
|
193
|
+
`in the report; a false failure costs the team a release.`,
|
|
194
|
+
``,
|
|
108
195
|
`GROUND EVERYTHING IN THE REAL SOURCE. You are running inside the`,
|
|
109
196
|
`project's repository and have read-only tools (Read, Grep, Glob, LS).`,
|
|
110
197
|
`BEFORE writing tests, look up the actual code for this journey — use`,
|
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
|
+
}
|
|
@@ -20,6 +20,12 @@ export const DEEP_DOC_GUIDANCE = [
|
|
|
20
20
|
` vague mentions;`,
|
|
21
21
|
`- concrete acceptance criteria per case (exactly what must hold).`,
|
|
22
22
|
`Aim for the depth of a spec a new engineer could implement from.`,
|
|
23
|
+
`Every acceptance criterion you write becomes an ORACLE — tests are`,
|
|
24
|
+
`generated from it, and a product that disagrees is reported to the team`,
|
|
25
|
+
`as a defect. So state a criterion ONLY where the source actually fixes`,
|
|
26
|
+
`the behaviour. Anything you merely believe SHOULD hold goes under a`,
|
|
27
|
+
`"Suggested (not a requirement — do not assert)" heading, never into the`,
|
|
28
|
+
`acceptance criteria: an invented requirement becomes a false bug report.`,
|
|
23
29
|
`Organise the body with numbered "## N. <Section>" headings, ONE`,
|
|
24
30
|
`concern per section, because each section later becomes its own`,
|
|
25
31
|
`focused test file — so the richer and better-separated the sections,`,
|
package/dist/core/reference.js
CHANGED
|
@@ -147,6 +147,16 @@ export function buildReferenceDiscoverPrompt(srcDir, docsDir, reference, journey
|
|
|
147
147
|
`Rules:`,
|
|
148
148
|
`- Base behavior on the reference and technical detail on real code;`,
|
|
149
149
|
` do not invent either with false confidence.`,
|
|
150
|
+
`- EVERY requirement you write becomes an ORACLE: tests will be`,
|
|
151
|
+
` generated from it, and a product that disagrees will be reported to`,
|
|
152
|
+
` the team as a defect. So write a requirement ONLY when the reference`,
|
|
153
|
+
` actually states it. Do NOT add hardening, validation, limits, or`,
|
|
154
|
+
` guards that the reference does not ask for, however sensible they`,
|
|
155
|
+
` seem — an invented requirement becomes a false bug report and costs`,
|
|
156
|
+
` the team a release.`,
|
|
157
|
+
`- When you believe something SHOULD hold but the reference does not say`,
|
|
158
|
+
` so, put it under a "Suggested (not a requirement — do not assert)"`,
|
|
159
|
+
` heading, never in Expected / Negative Cases / acceptance criteria.`,
|
|
150
160
|
`- Where the reference is silent on a needed detail, add a "# TODO:"`,
|
|
151
161
|
` line describing what the developer must verify.`,
|
|
152
162
|
`- Never include secrets — refer to env var names only.`,
|
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);
|
|
@@ -247,8 +311,10 @@ export function scanInventory(cwd, testOutput, ext, framework = {}) {
|
|
|
247
311
|
continue;
|
|
248
312
|
if (framework[category] === 'playwright') {
|
|
249
313
|
// package root (holds playwright.config) is the parent of the tests dir
|
|
250
|
-
if (playwrightList(path.dirname(dir), category, suites))
|
|
314
|
+
if (playwrightList(path.dirname(dir), category, suites)) {
|
|
315
|
+
attachPending(dir, ext, suites);
|
|
251
316
|
continue;
|
|
317
|
+
}
|
|
252
318
|
console.warn(` ! playwright --list failed for "${category}" — using static estimate`);
|
|
253
319
|
jestStatic(dir, ext, category, suites);
|
|
254
320
|
}
|
|
@@ -256,6 +322,7 @@ export function scanInventory(cwd, testOutput, ext, framework = {}) {
|
|
|
256
322
|
// jest (and ops/lambda, also jest-based) — accurate collect-only counter
|
|
257
323
|
jestCount(dir, ext, category, suites);
|
|
258
324
|
}
|
|
325
|
+
attachPending(dir, ext, suites);
|
|
259
326
|
}
|
|
260
327
|
return suites;
|
|
261
328
|
}
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@activate-core/test-forge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
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": {
|
package/reporters/_shared.cjs
CHANGED
|
@@ -117,11 +117,27 @@ function resolveEnv() {
|
|
|
117
117
|
|
|
118
118
|
function sig(msg) {
|
|
119
119
|
const m = strip(msg || '').replace(/\n/g, ' ');
|
|
120
|
-
|
|
120
|
+
// Order matters, and each pattern has to be precise. A failing assertion
|
|
121
|
+
// carries plenty of incidental words — Playwright prints "Timeout: 5000ms"
|
|
122
|
+
// inside every expect() failure, and a status-code assertion legitimately
|
|
123
|
+
// mentions 403 — so loose patterns used to swallow most failures into
|
|
124
|
+
// "Timeout" / "IM auth" and made the whole triage useless.
|
|
125
|
+
|
|
126
|
+
// 1) A REAL timeout: the runner itself gave up, or the socket did.
|
|
127
|
+
if (/test timeout of \d+ ?ms exceeded|exceeded timeout of \d+ ?ms|timed out after \d+|ETIMEDOUT|ESOCKETTIMEDOUT|ECONNABORTED/i.test(m))
|
|
128
|
+
return 'Timeout';
|
|
129
|
+
|
|
130
|
+
// 2) An assertion that ran and disagreed — the common, useful case.
|
|
131
|
+
if (/expect\(.*\)|toBe\b|toEqual\b|toHaveCount\b|Received:|Expected:|expected the result to/i.test(m))
|
|
132
|
+
return 'Assertion mismatch';
|
|
133
|
+
|
|
134
|
+
// 3) Auth against the identity service — needs auth wording, not a bare 403.
|
|
135
|
+
if (/whoami|invalid_grant|refresh token|Unauthorized|Forbidden|invalid token|jwt (expired|malformed)/i.test(m))
|
|
136
|
+
return 'IM auth (403)';
|
|
137
|
+
|
|
121
138
|
if (/\$gt|\$exists|injection/i.test(m)) return 'NoSQL injection accepted';
|
|
122
|
-
if (/timeout|timed out|ETIMEDOUT/i.test(m)) return 'Timeout';
|
|
123
139
|
if (/\b500\b|Internal Server/i.test(m)) return 'Server 500';
|
|
124
|
-
if (/
|
|
140
|
+
if (/timeout|timed out/i.test(m)) return 'Timeout'; // looser fallback, last
|
|
125
141
|
return 'Uncategorised';
|
|
126
142
|
}
|
|
127
143
|
|
|
@@ -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
|
};
|