@activate-core/test-forge 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -19,7 +19,7 @@
19
19
  * (an auth-gated page can move unseen).
20
20
  */
21
21
  import { VERSION } from '../core/index.js';
22
- import { runGenerate } from '../core/generate/run-generate.js';
22
+ import { runGenerate, runHarness } from '../core/generate/run-generate.js';
23
23
  import { runTests } from '../core/run/run-tests.js';
24
24
  import { runDiscover } from '../core/docs/discover.js';
25
25
  import { runMap } from '../core/project/map.js';
@@ -79,7 +79,7 @@ function printUsage() {
79
79
  console.log(' testforge generate --force Regenerate everything');
80
80
  console.log(' testforge generate --no-wait Stop when the AI hits its usage limit (default: wait for the reset and resume)');
81
81
  console.log(' testforge generate --no-verify Skip running each journey once after generating (default: run and repair)');
82
- console.log(' testforge generate --journey <name> Generate just ONE journey');
82
+ console.log(' testforge generate --journey <name> Generate just ONE journey', ' testforge generate --harness Regenerate only the shared harness (testkit), no test files');
83
83
  console.log(' testforge generate --area <area> Generate a whole area (e.g. api)');
84
84
  console.log(' testforge discover Draft journey docs (asks: map or reference)');
85
85
  console.log(' testforge discover --journey <name> Draft just ONE journey doc');
@@ -144,6 +144,13 @@ async function main() {
144
144
  });
145
145
  process.exitCode = code;
146
146
  }
147
+ else if (command === 'generate' && process.argv.includes('--harness')) {
148
+ // Regenerate only the shared harness (testkit), leaving every test file
149
+ // as it is — e.g. after a TestForge upgrade changes what a harness does.
150
+ console.log('Regenerating the shared test harness...\n');
151
+ const written = await runHarness(flagValue('--area'));
152
+ console.log(written.length ? `Written:\n ${written.join('\n ')}` : 'No harness written.');
153
+ }
147
154
  else if (command === 'generate') {
148
155
  const useAI = !useBasic;
149
156
  console.log(useAI
@@ -41,6 +41,9 @@ export function buildReviewPrompt(docPath, srcDir, reference) {
41
41
  `- Code branches: read the code this journey runs, client and server.`,
42
42
  ` Is every validation, error response and conditional branch an`,
43
43
  ` [observed] criterion or a Question? Add the missing ones.`,
44
+ `- [observed]: open the exact code each [observed] criterion rests on and`,
45
+ ` confirm it states what that code DOES (follow the real state shape and`,
46
+ ` call path, not a similarly named field). Fix any that misread it.`,
44
47
  `- Tiers: does every [spec] criterion quote a real source sentence?`,
45
48
  ` Re-tag any that does not. Does any criterion contradict the code`,
46
49
  ` with nothing to test (a rule the screen has no way to break)? Turn`,
@@ -297,6 +297,46 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
297
297
  ` list, and treat a template placeholder (DUMMY, replace_me, an empty`,
298
298
  ` value, a bracketed name) as absent rather than as a credential.`,
299
299
  ` Say which actor was missing in the skip reason.`,
300
+ `(13) A [baseline] CHECK PROVES ROBUSTNESS, NOT A VALUE. "Opens without a`,
301
+ ` crash" is proved by the page loading with no error and no 5xx — not by`,
302
+ ` an exact URL, trailing slash, heading text or element the document`,
303
+ ` does not name. Never add an extra condition the criterion does not`,
304
+ ` state (e.g. "double-click opens exactly one pop-up" when the document`,
305
+ ` only says the button opens it), and never assert data in a place the`,
306
+ ` page does not render it — check where the app shows it first.`,
307
+ `(14) SCOPE EVERY LOCATOR TO THE JOURNEY'S OWN UI. Global components`,
308
+ ` (a login pop-up, a header, a cookie bar) share ids and labels with`,
309
+ ` the page under test — "#password" can match the login modal's field.`,
310
+ ` Locate inside the journey's form/container, never page-wide by a`,
311
+ ` bare id or label that another always-mounted component also uses.`,
312
+ `(15) NOTHING YOU ARE NOT TESTING MAY BLOCK YOU. Apps open their own`,
313
+ ` pop-ups for a signed-in or new user (promotions, cookie consent,`,
314
+ ` onboarding tips, notification prompts). Use the harness's login /`,
315
+ ` storage helpers that pre-suppress them; if one still opens, close it`,
316
+ ` before you continue. A click intercepted by an overlay the test is`,
317
+ ` not about is your bug, not the product's.`,
318
+ `(16) "INSIDE" MEANS THE MIDDLE. To click inside a panel, click its centre`,
319
+ ` or a non-interactive child — never a corner offset like (5,5): with a`,
320
+ ` border-radius that point is outside the element and lands on the`,
321
+ ` overlay behind it.`,
322
+ `(17) LET THE APP SETTLE BEFORE YOU CHANGE THE SESSION. After a login,`,
323
+ ` sign-out or navigation the app sends its own requests and may`,
324
+ ` redirect by itself. Wait for the network to go idle before rotating`,
325
+ ` a session, logging in elsewhere or navigating away, and assert the`,
326
+ ` URL the APP chooses — never force a navigation that can cut off the`,
327
+ ` app's own redirect.`,
328
+ `(18) ONE SOURCE FOR SEEDED IDS. Read every seeded id and handle from the`,
329
+ ` harness (TEST_IDS / the values setup() publishes). If a test needs a`,
330
+ ` private account, override EVERY id that belongs to it, never a`,
331
+ ` subset — a leftover shared id makes you compare against another`,
332
+ ` customer's row.`,
333
+ `(19) VERIFICATION CODES THE TEST CANNOT READ (SMS/email OTP, magic`,
334
+ ` links, OAuth). Drive the real UI up to the code step, then use the`,
335
+ ` harness's bypassVerification() so the final verify/register request`,
336
+ ` is answered with a REAL account and a REAL token; everything after it`,
337
+ ` (logged-in state, redirect, payment) is then tested for real. Put`,
338
+ ` "(verification bypassed)" in that test's title. Wrong-code and`,
339
+ ` expired-code tests do NOT bypass: the real server must refuse them.`,
300
340
  `When rules conflict, prefer reporting LESS: a pending test costs a line`,
301
341
  `in the report; a false failure costs the team a release.`,
302
342
  ``,
@@ -105,6 +105,8 @@ export const HARNESS_CONTRACT = [
105
105
  ` process.env work with NO hand-`,
106
106
  ` pasted tokens.`,
107
107
  ` - token(role?): string the current access token accessor`,
108
+ ` - REQUIRED_PACKAGES, resetRateLimits(), bypassVerification(): see`,
109
+ ` RUN ENVIRONMENT below.`,
108
110
  `Plus, for Playwright suites, ALSO export:`,
109
111
  ` - apiContext(request): a helper that returns an authed API request`,
110
112
  ` context (Bearer = the minted token — NEVER hardcode a secret).`,
@@ -180,6 +182,54 @@ export const HARNESS_CONTRACT = [
180
182
  ` distinct token (env). If only one identity is available, make those`,
181
183
  ` cases pending — never assert a scope the token cannot actually prove.`,
182
184
  ``,
185
+ `RUN ENVIRONMENT — the suite must run on a fresh machine given only the`,
186
+ `env file, in WHATEVER stack this project uses. Decide everything below`,
187
+ `from what you read in THIS project; nothing here names a stack.`,
188
+ ` - PACKAGES: use Node built-ins, the test runner, and what the project`,
189
+ ` already has installed. If reaching the project's data layer needs a`,
190
+ ` client the project lacks (a driver for its database), require it`,
191
+ ` lazily, throw "testkit: missing package <name> — run: npm i -D <name>"`,
192
+ ` when absent, and list it in REQUIRED_PACKAGES (an array literal) so`,
193
+ ` the runner can check before the suite starts. Never add a package`,
194
+ ` for anything else.`,
195
+ ` - NO OTHER RUNTIMES: never shell out to another language runtime, a`,
196
+ ` framework CLI or a container tool — the test machine need not have`,
197
+ ` them.`,
198
+ ` - CREDENTIALS COME FROM THE ENV FILE: every role the suite logs in as`,
199
+ ` (customer, staff, admin, second customer, …) is an account whose`,
200
+ ` login identifier and password are read from the env file`,
201
+ ` (TEST_<ROLE>_EMAIL / TEST_<ROLE>_PASSWORD, or the identifier the app`,
202
+ ` logs in with). The testkit NEVER hashes or invents a password. The`,
203
+ ` seed may fill in the profile data such an account needs (addresses,`,
204
+ ` vehicles, settings) but never its credential.`,
205
+ ` - EXTRA ACCOUNT STATES (unverified, banned, deleted, another role, an`,
206
+ ` expired credential): create them the way the app itself would —`,
207
+ ` through its API where one exists, otherwise through the data layer`,
208
+ ` by COPYING the stored credential of an env-provided account onto the`,
209
+ ` new record, then setting the state. Never compute a credential.`,
210
+ ` - SEED THE DATA TESTS NEED: codes, links, other customers' records —`,
211
+ ` seeded and exposed through TEST_IDS and the env setup() publishes,`,
212
+ ` so in auto mode no test waits on hand-made data. DB assertions (rows`,
213
+ ` created, state persisted) go through the same data-layer client.`,
214
+ ` - LOG IN ONCE: mint each role's token once per run (globalSetup) and`,
215
+ ` reuse it; login(page) injects it. If the backend rate-limits and`,
216
+ ` its limiter state lives in a store the testkit can reach in local`,
217
+ ` mode, export resetRateLimits() that clears ONLY the limiter entries,`,
218
+ ` gated exactly like reset(), and call it from setup().`,
219
+ ` - SUPPRESS THE APP'S OWN POP-UPS: find, in the client, any dialog that`,
220
+ ` opens by itself for a seeded role (promotions, consent, onboarding,`,
221
+ ` permission prompts) and have login(page) and seeded browser storage`,
222
+ ` pre-set the flags that suppress it.`,
223
+ ` - VERIFICATION BYPASS (Playwright): when the app verifies by a code the`,
224
+ ` test cannot read (a code sent by message or email, a magic link, a`,
225
+ ` third-party sign-in), export bypassVerification(page, { url, role }):`,
226
+ ` route the request at \`url\` (the final verify/register call) and`,
227
+ ` fulfil it with the app's REAL success response shape (read the`,
228
+ ` server handler and the client that consumes it) carrying a REAL token`,
229
+ ` for an env-provided account of that role, obtained through the real`,
230
+ ` login. Only the code check is skipped; everything after it runs`,
231
+ ` against the real backend.`,
232
+ ``,
183
233
  `CONNECTION LIFECYCLE — setup() must be safe to call repeatedly across`,
184
234
  `suites, or one suite's teardown breaks the next (MongoNotConnectedError).`,
185
235
  ` - connect() and setup() MUST check the live connection state before`,
@@ -27,7 +27,7 @@ import { aiFailureNote, aiUnavailableReason, confirmAiAlive, lastAiFailureWasPro
27
27
  import { generateTestCases } from './generator.js';
28
28
  import { writeDeferredStub } from './playwright-writer.js';
29
29
  import { generateWithAI, generateWithAIAsync, repairWithAIAsync, } from './ai-generator.js';
30
- import { verifyFiles, browserPreflight } from './verify.js';
30
+ import { verifyFiles, browserPreflight, missingPackages } from './verify.js';
31
31
  import { packageRoot } from '../run/run-tests.js';
32
32
  import { groundJourney } from '../docs/grounding.js';
33
33
  import { generateSeed } from './seed-generator.js';
@@ -594,7 +594,14 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
594
594
  // is reported and left UNCACHED, so it is retried rather than trusted.
595
595
  let verified;
596
596
  const targets = testFiles.filter((t) => t.status === 'ai').map((t) => t.path);
597
- if (useAI && verifyOn && targets.length) {
597
+ const missing = harnessPath ? missingPackages(harnessPath, packageRoot(testDir)) : [];
598
+ if (useAI && verifyOn && targets.length && missing.length) {
599
+ // The suite cannot even start; running it would only feed repair a
600
+ // failure no test edit can fix.
601
+ verified = false;
602
+ warnings.push(`verify: not run — install the packages the harness needs: npm i -D ${missing.join(' ')}`);
603
+ }
604
+ else if (useAI && verifyOn && targets.length) {
598
605
  for (let attempt = 0; attempt <= verifyAttempts; attempt += 1) {
599
606
  const outcome = verifyFiles(config, journey.area, targets);
600
607
  if (outcome.infra) {
@@ -13,8 +13,9 @@
13
13
  * anything we could infer.
14
14
  */
15
15
  import { spawnSync } from 'node:child_process';
16
- import { existsSync } from 'node:fs';
17
- import { relative } from 'node:path';
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { createRequire } from 'node:module';
18
+ import { join, relative } from 'node:path';
18
19
  import { loadEnvFile, testDirForType, resolveFramework, } from '../project/config.js';
19
20
  import { runnerCommand, packageRoot } from '../run/run-tests.js';
20
21
  /** How much runner output to carry into a repair prompt. */
@@ -54,6 +55,10 @@ const INFRA_FAILURES = [
54
55
  /Executable doesn't exist at|browserType\.launch:|Host system is missing dependencies|does not support (chromium|firefox|webkit) on/i,
55
56
  'the test browser cannot start — install the one this Playwright version needs (`npx playwright install chromium`), or pin a Playwright version your OS supports',
56
57
  ],
58
+ [
59
+ /missing package|package is not installed|Cannot find module '(?![./])/i,
60
+ 'a package the tests or their harness need is not installed in the project — install it as a dev dependency (the error names it)',
61
+ ],
57
62
  [
58
63
  /net::ERR_CONNECTION_REFUSED|ECONNREFUSED/i,
59
64
  'the app (or a service it needs) is not reachable — start it and check BASE_URL / the DB settings in the env file',
@@ -138,3 +143,30 @@ export function verifyFiles(config, area, files) {
138
143
  }
139
144
  return { ran: true, ok: true, output };
140
145
  }
146
+ /**
147
+ * Packages the generated harness declares it needs (REQUIRED_PACKAGES) that
148
+ * the project cannot resolve. Read from the source, never executed.
149
+ */
150
+ export function missingPackages(harnessFile, root) {
151
+ let src = '';
152
+ try {
153
+ src = readFileSync(harnessFile, 'utf-8');
154
+ }
155
+ catch {
156
+ return [];
157
+ }
158
+ const list = src.match(/REQUIRED_PACKAGES\s*=\s*\[([^\]]*)\]/);
159
+ if (!list)
160
+ return [];
161
+ const names = [...list[1].matchAll(/['"]([^'"]+)['"]/g)].map((m) => m[1]);
162
+ const req = createRequire(join(root, 'package.json'));
163
+ return names.filter((n) => {
164
+ try {
165
+ req.resolve(n);
166
+ return false;
167
+ }
168
+ catch {
169
+ return true;
170
+ }
171
+ });
172
+ }
@@ -15,6 +15,7 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, } from 'node:fs';
15
15
  import { join } from 'node:path';
16
16
  import { findMarkdownFiles } from '../find-files.js';
17
17
  import { STATE_DIR, LEGACY_STATE_DIR } from '../paths.js';
18
+ import { loadConfig } from './config.js';
18
19
  /** Pull env var names from a chunk of text. */
19
20
  function extractEnvVars(text) {
20
21
  const vars = new Set();
@@ -25,6 +26,10 @@ function extractEnvVars(text) {
25
26
  for (const m of text.matchAll(/process\.env\[\s*['"]([A-Z0-9_]+)['"]\s*\]/gi)) {
26
27
  vars.add(m[1]);
27
28
  }
29
+ // A generated suite often reads env through a small helper: env('TEST_X', default).
30
+ for (const m of text.matchAll(/\benv\(\s*['"]([A-Z][A-Z0-9_]+)['"]/g)) {
31
+ vars.add(m[1]);
32
+ }
28
33
  // backtick-wrapped ALL_CAPS names in "env var" lines
29
34
  for (const line of text.split('\n')) {
30
35
  if (/env var|secret|credential/i.test(line)) {
@@ -189,6 +194,15 @@ export function runEnvTemplate(docsDir, outPath, srcDir = '.', testDirs = []) {
189
194
  return;
190
195
  }
191
196
  const files = findMarkdownFiles(docsDir);
197
+ // Every caller gets the generated suite scanned, not only the one that
198
+ // remembered to pass its dirs: the tests' own TEST_* variables are exactly
199
+ // what a developer cannot guess.
200
+ const config = loadConfig();
201
+ if (!testDirs.length) {
202
+ testDirs = [...Object.values(config.testOutput ?? {}), config.seedOutput ?? '']
203
+ .filter((d) => !!d && existsSync(d));
204
+ }
205
+ const envFile = config.envFile ?? '.env.testing';
192
206
  // area -> var -> set of journeys
193
207
  const byArea = new Map();
194
208
  for (const file of files) {
@@ -254,9 +268,9 @@ export function runEnvTemplate(docsDir, outPath, srcDir = '.', testDirs = []) {
254
268
  '# .env.testing.example',
255
269
  '#',
256
270
  '# Test credentials the generated tests/seeds need, grouped by area.',
257
- '# Copy this to .env.testing and fill in real values.',
271
+ `# Copy this to ${envFile} and fill in real values.`,
258
272
  '# Only variables for the features you actually run are required.',
259
- '# Keep .env.testing OUT of version control.',
273
+ `# Keep ${envFile} OUT of version control.`,
260
274
  '',
261
275
  `# ${'='.repeat(40)}`,
262
276
  '# TESTFORGE DASHBOARD (auto-publish each run)',
@@ -296,5 +310,5 @@ export function runEnvTemplate(docsDir, outPath, srcDir = '.', testDirs = []) {
296
310
  }
297
311
  writeFileSync(outPath, lines.join('\n'), 'utf-8');
298
312
  console.log(`Env template written to ${outPath}`);
299
- console.log('Copy it to .env.testing and fill in real values.');
313
+ console.log(`Copy it to ${envFile} and fill in real values.`);
300
314
  }
@@ -37,8 +37,13 @@ export function packageRoot(dir) {
37
37
  export function runnerCommand(config, area, framework, root, rel, journey, targets) {
38
38
  const explicit = config.testCommand?.[area];
39
39
  const paths = (targets ?? []).map((t) => relative(root, t) || t);
40
- if (explicit)
41
- return paths.length ? `${explicit} ${paths.join(' ')}` : explicit;
40
+ if (explicit) {
41
+ if (paths.length)
42
+ return `${explicit} ${paths.join(' ')}`;
43
+ // A custom command still honours --journey: narrow it to that journey's
44
+ // tests, or `run --journey x` silently runs the whole suite.
45
+ return journey ? `${explicit} ${framework === 'playwright' ? `${rel}/${journey}` : `--testPathPattern "${journey}"`}` : explicit;
46
+ }
42
47
  if (framework === 'playwright') {
43
48
  const target = paths.length
44
49
  ? paths.join(' ')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@activate-core/test-forge",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
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": {