@activate-core/test-forge 0.11.0 → 0.12.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.
@@ -18,22 +18,40 @@ const MAP_PATH = 'testforge.md';
18
18
  * Spawn a command, feed `input` on stdin, resolve with its stdout.
19
19
  * Async (unlike execFileSync) so many calls can run concurrently.
20
20
  */
21
- function runWithStdin(program, args, input) {
21
+ function runWithStdin(program, args, input, timeoutMs = 300_000) {
22
22
  return new Promise((resolve, reject) => {
23
23
  const child = spawn(program, args, {
24
24
  stdio: ['pipe', 'pipe', 'pipe'],
25
25
  });
26
26
  let out = '';
27
27
  let err = '';
28
+ let done = false;
29
+ const finish = (fn) => {
30
+ if (done)
31
+ return;
32
+ done = true;
33
+ clearTimeout(timer);
34
+ fn();
35
+ };
36
+ // A blocked AI CLI (interactive auth, TTY wait, rate-limit, hang) would
37
+ // otherwise never close — kill it and surface a clear, resumable error.
38
+ const timer = setTimeout(() => {
39
+ child.kill('SIGKILL');
40
+ finish(() => reject(new Error(`${program} timed out after ${timeoutMs}ms — the AI CLI did not respond. ` +
41
+ `Check it runs non-interactively (auth set up, no TTY prompt), ` +
42
+ `or raise aiTimeoutMs in testforge.config.json.`)));
43
+ }, timeoutMs);
28
44
  child.stdout.setEncoding('utf-8');
29
45
  child.stdout.on('data', (d) => (out += d));
30
46
  child.stderr.on('data', (d) => (err += d));
31
- child.on('error', reject);
47
+ child.on('error', (e) => finish(() => reject(e)));
32
48
  child.on('close', (code) => {
33
- if (code === 0)
34
- resolve(out);
35
- else
36
- reject(new Error(`${program} exited ${code}: ${err.slice(0, 500)}`));
49
+ finish(() => {
50
+ if (code === 0)
51
+ resolve(out);
52
+ else
53
+ reject(new Error(`${program} exited ${code}: ${err.slice(0, 500)}`));
54
+ });
37
55
  });
38
56
  child.stdin.end(input);
39
57
  });
@@ -106,15 +124,44 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
106
124
  `route file, its controller/handler, the service it calls, and the`,
107
125
  `models it touches. Confirm the REAL endpoint paths, HTTP methods,`,
108
126
  `request/response shapes, status codes, auth requirements, and function`,
109
- `signatures from the code. Base every assertion on what the code`,
110
- `actually does. Only fall back to a pending test (${pending}) when`,
111
- `the behavior genuinely cannot be determined from the source — do not`,
112
- `leave something pending that a quick read of the code would answer.`,
127
+ `signatures from the code — that is the MECHANICS: HOW to reach and`,
128
+ `drive the behaviour (paths, methods, payload/response shape,`,
129
+ `selectors, model/collection names). But the EXPECTED OUTCOME you`,
130
+ `assert on is the ORACLE, and its source of truth is the JOURNEY`,
131
+ `DOCUMENT below — NOT what the code currently does. The code tells you`,
132
+ `HOW; the document tells you WHAT is correct. If the code's real`,
133
+ `behaviour contradicts the document, assert the DOCUMENT's expectation`,
134
+ `and let the test FAIL — a real bug surfaced is the goal, never`,
135
+ `something to paper over. NEVER reshape an assertion to match observed`,
136
+ `code behaviour just to make it green; that rubber-stamps the bug. If`,
137
+ `the document does not state the expected outcome for a case, make it`,
138
+ `pending (${pending}) with a "// TODO:" note — do not invent the`,
139
+ `expectation from the code.`,
140
+ `REQUEST URLS: build every request path as the FULL mounted route the`,
141
+ `router actually registers — INCLUDING the version/area prefix (e.g.`,
142
+ `/v{VERSION}/<area>/<action>, reading the real version from its env var).`,
143
+ `Never post to a bare "/<action>": a missing prefix 404s and the test`,
144
+ `fails for a fake reason. Use the SAME prefix consistently for every`,
145
+ `call in the file.`,
146
+ `AUTH & SCOPING: a route may be declared auth:false yet STILL populate`,
147
+ `request.user when a Bearer token is sent (a pre-auth extension). So`,
148
+ `owner-scoped data ("my bookings/payouts") is derived from the TOKEN`,
149
+ `owner — NOT from a query param. Do NOT assume a "?ownerId=/?makerId="`,
150
+ `fallback overrides the token for an AUTHENTICATED call: the testkit`,
151
+ `seeds the owner fixtures under the authenticated identity, so expect`,
152
+ `the token owner's rows. A second, non-owner identity (for IDOR/scope`,
153
+ `tests) needs a distinct token; if unavailable, make the case pending.`,
113
154
  `Do NOT write or edit any file; output only the test code to stdout.`,
114
155
  `EVERY runnable test MUST contain at least one real assertion. If you`,
115
156
  `genuinely cannot assert an outcome, make the case pending (${pending})`,
116
157
  `— never write a test body with no assertion, which passes without`,
117
158
  `testing anything.`,
159
+ `ASSERTION DEPTH: assert the REAL end-state — persisted DB/state, the`,
160
+ `actual response body/status, or observable UI — not merely that "no`,
161
+ `error was thrown". CRITICAL: if you mock an external/API response, do`,
162
+ `NOT then "verify" a value your own mock hard-coded (asserting the mock`,
163
+ `returned what you told it to proves nothing). Read back the real effect`,
164
+ `(DB row, downstream call, UI change) the behaviour was supposed to have.`,
118
165
  ``,
119
166
  isApi
120
167
  ? [
@@ -126,26 +173,52 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
126
173
  `duplicate exists, correct fields) using the database/ORM the`,
127
174
  `project uses. Connect via the env var named in the map (e.g.`,
128
175
  `process.env.DB_URL) — never hardcode a connection string.`,
129
- `MOCKING: if the code calls an external service (e.g. an identity`,
130
- `or payment API), mock it AT THE SEAM THE CODE ACTUALLY USES —`,
131
- `read the source to see which client it imports (e.g. axios) and`,
132
- `how it is called, then jest.mock that exact module so the mock`,
133
- `truly intercepts. Do NOT mock a client the code doesn't use, and`,
134
- `do NOT drive the server out-of-process while expecting an`,
176
+ `MOCKING (MANDATORY — the test must be HERMETIC): the test must`,
177
+ `NEVER depend on a live external service. For EVERY external`,
178
+ `service the exercised code reaches (identity/IM, payment/Stripe,`,
179
+ `SMS, email, etc.), mock it AT THE SEAM THE CODE ACTUALLY USES —`,
180
+ `read the source to see which client it imports (e.g. axios, the`,
181
+ `stripe SDK) and how it is called, then jest.mock that EXACT module`,
182
+ `so the mock truly intercepts. A test that leaves any such call`,
183
+ `unmocked (so it would hit a real host like localhost/an IM/Stripe`,
184
+ `URL) is WRONG — it will fail whenever that service is down and is`,
185
+ `not deterministic. Do NOT mock a client the code doesn't use.`,
186
+ `HERMETIC BASE URLS: if the client's target is built from an env`,
187
+ `var (e.g. process.env.IM_API_URL), do NOT rely on the ambient`,
188
+ `value — set it yourself in beforeAll to a fixed test host AND`,
189
+ `mock that same client, so every run behaves identically`,
190
+ `regardless of how the environment is configured. Prefer mocking`,
191
+ `the client module directly (host-agnostic) over matching a URL.`,
192
+ `Do NOT drive the server out-of-process while expecting an`,
135
193
  `in-process mock to fire — they cannot both be true.`,
136
194
  ].join('\n')
137
195
  : [
138
196
  `Because this is a web journey, write Playwright tests that drive`,
139
- `the UI. FIRST find the REAL selectors: with your read-only tools,`,
140
- `locate and READ this page's actual React/Vue components (grep the`,
141
- `webapp source for the route, page name, and on-screen text, then`,
142
- `open those component files). Use the selectors that genuinely`,
143
- `exist there — data-testid/data-cy, input name/id, aria-role,`,
144
- `label text, or button text — via Playwright locators`,
145
- `(getByRole/getByLabel/getByTestId/getByText). Only leave a`,
146
- `"// TODO: confirm selector" when the element truly cannot be`,
147
- `found in the component source; do NOT default everything to a`,
148
- `TODO. Never invent a selector as if it were real.`,
197
+ `the UI. FIRST read this page's real components: grep the webapp`,
198
+ `source for the route, page name, and on-screen text, then open`,
199
+ `those component files and use only locators that GENUINELY exist.`,
200
+ `SELECTOR PREFERENCE — most apps have NO dedicated test hooks, so`,
201
+ `do NOT reach for a data-testid/data-cy unless you have SEEN that`,
202
+ `exact attribute in the component source. Prefer, in this order:`,
203
+ ` 1. getByRole(role, { name }) — real roles / accessible names`,
204
+ ` 2. getByLabel(...) or getByPlaceholder(...) — real <label>/placeholder`,
205
+ ` 3. getByText(...) — real on-screen text`,
206
+ ` 4. locator('[name="…"]') / by id — a real input name/id attribute`,
207
+ ` 5. getByTestId(...) / [data-cy=…] — ONLY if that hook truly exists`,
208
+ `The first four are present in almost any real DOM without special`,
209
+ `hooks, so a page with zero data-testids is still fully groundable —`,
210
+ `ground on role/label/text/name instead of inventing a test-id.`,
211
+ `NEVER invent a data-testid/data-cy: an unverified test-id is`,
212
+ `REJECTED by the selector-grounding gate and wastes the whole file.`,
213
+ `Only when an element cannot be identified by ANY real handle,`,
214
+ `defer that single case with test.fixme(...) and a`,
215
+ `"// TODO: confirm selector" note — do not default to TODO, never guess.`,
216
+ `NAVIGATION: page.goto(...) takes ONLY a concrete URL or path —`,
217
+ `never a description/comment, and never a route-file placeholder`,
218
+ `like "/s/[shortCode]" or "/user/:id". Fill the path with a real`,
219
+ `value (a seeded id/code, or the concrete route) using a backtick`,
220
+ "template, e.g. page.goto(`${base}/s/${code}`). A goto with prose",
221
+ `or an unfilled [param]/:param is rejected by the gate.`,
149
222
  `CRITICAL Playwright API: import { test, expect } from`,
150
223
  `'@playwright/test'. There is NO test.todo / it.* / describe.skip`,
151
224
  `in Playwright — using them throws at collection and breaks the`,
@@ -161,7 +234,11 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
161
234
  `different name: \`import { request as apiRequest } from`,
162
235
  `'@playwright/test'\` then \`const ctx = await`,
163
236
  `apiRequest.newContext({ baseURL, extraHTTPHeaders })\`. Never call`,
164
- `.newContext() on the fixture.`,
237
+ `.newContext() on the fixture, and there is NO \`.apiRequest\``,
238
+ `property on the \`playwright\` fixture — \`playwright.apiRequest`,
239
+ `.newContext(...)\` throws "Cannot read properties of undefined".`,
240
+ `The ONLY correct source of newContext is the imported request`,
241
+ `module (aliased as apiRequest above).`,
165
242
  ].join('\n'),
166
243
  ``,
167
244
  ...(testPath
@@ -234,11 +311,16 @@ export function generateWithAI(journey, testPath, focus) {
234
311
  // Allow read-only exploration so the model grounds the test in the
235
312
  // real source (routes/controllers/models); Write stays denied, so it
236
313
  // still can't create files — it reads, then prints the code.
237
- const { program, args } = withReadOnlyTools(splitCommand(loadConfig().aiCommand));
314
+ const config = loadConfig();
315
+ const { program, args } = withReadOnlyTools(splitCommand(config.aiCommand));
238
316
  const output = execFileSync(program, args, {
239
317
  input: prompt,
240
318
  encoding: 'utf-8',
241
319
  maxBuffer: 10 * 1024 * 1024,
320
+ // Same anti-hang guard as the async path: a blocked CLI throws here
321
+ // instead of freezing the run forever.
322
+ timeout: config.aiTimeoutMs ?? 300_000,
323
+ killSignal: 'SIGKILL',
242
324
  });
243
325
  return cleanOutput(output);
244
326
  }
@@ -250,8 +332,9 @@ export function generateWithAI(journey, testPath, focus) {
250
332
  export async function generateWithAIAsync(journey, testPath, focus, harnessPath, transport) {
251
333
  const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
252
334
  const prompt = buildPrompt(journey, map, testPath, focus, harnessPath, transport);
253
- const { program, args } = withReadOnlyTools(splitCommand(loadConfig().aiCommand));
254
- const stdout = await runWithStdin(program, args, prompt);
335
+ const config = loadConfig();
336
+ const { program, args } = withReadOnlyTools(splitCommand(config.aiCommand));
337
+ const stdout = await runWithStdin(program, args, prompt, config.aiTimeoutMs);
255
338
  return cleanOutput(stdout);
256
339
  }
257
340
  /** Does this line look like the start (or body) of real code? */
@@ -30,6 +30,8 @@ import { join } from 'node:path';
30
30
  const CONFIG_PATH = 'testforge.config.json';
31
31
  const DEFAULT_CONFIG = {
32
32
  aiCommand: 'claude -p',
33
+ aiTimeoutMs: 300_000,
34
+ aiConcurrency: 4,
33
35
  testOutput: {},
34
36
  framework: { api: 'jest', web: 'playwright' },
35
37
  seedOutput: 'generated-tests/seed',
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
14
14
  import { builtinModules } from 'node:module';
15
- import { dirname, join } from 'node:path';
15
+ import { dirname, extname, join } from 'node:path';
16
16
  /** Read a package.json's dependency names (all dependency fields). */
17
17
  function depsOf(pkgPath) {
18
18
  try {
@@ -94,6 +94,83 @@ export function installedModules(fromDir) {
94
94
  }
95
95
  return mods;
96
96
  }
97
+ /**
98
+ * The set of UI test-hook values (`data-testid` / `data-cy` / `data-test`)
99
+ * that genuinely exist in a package's component source — extracted by
100
+ * reading the real files, so it works on ANY project. Feeds the F10
101
+ * selector-grounding gate: a generated UI test may only assert on a hook
102
+ * that is actually in the components; anything else is an invented selector.
103
+ *
104
+ * Static string attributes only (`data-testid="pay-now"`). Dynamic ones
105
+ * (`data-testid={expr}`) can't be resolved statically and are skipped — the
106
+ * gate stays lenient (only the placeholder-marker check applies to those).
107
+ * Bounded walk so a huge monorepo can't stall generation.
108
+ */
109
+ export function projectSelectors(fromDir) {
110
+ const root = packageRoot(fromDir);
111
+ const found = new Set();
112
+ const SRC_EXT = new Set(['.jsx', '.tsx', '.vue', '.svelte', '.js', '.ts', '.html']);
113
+ const SKIP_DIR = new Set([
114
+ 'node_modules', '.git', 'dist', 'build', '.next', 'out',
115
+ 'coverage', '.turbo', '.cache', 'tests', 'test', '__tests__', '__mocks__',
116
+ ]);
117
+ const attrRe = /\bdata-(?:testid|cy|test)\s*=\s*(["'])([^"']+)\1/g;
118
+ let filesLeft = 4000;
119
+ const walk = (dir) => {
120
+ if (filesLeft <= 0)
121
+ return;
122
+ let entries;
123
+ try {
124
+ entries = readdirSync(dir, { withFileTypes: true });
125
+ }
126
+ catch {
127
+ return;
128
+ }
129
+ for (const e of entries) {
130
+ if (filesLeft <= 0)
131
+ return;
132
+ if (e.name.startsWith('.'))
133
+ continue;
134
+ const full = join(dir, e.name);
135
+ if (e.isDirectory()) {
136
+ if (!SKIP_DIR.has(e.name))
137
+ walk(full);
138
+ continue;
139
+ }
140
+ if (!SRC_EXT.has(extname(e.name)))
141
+ continue;
142
+ filesLeft--;
143
+ let content;
144
+ try {
145
+ content = readFileSync(full, 'utf-8');
146
+ }
147
+ catch {
148
+ continue;
149
+ }
150
+ if (content.length > 400_000)
151
+ continue; // skip bundles/minified
152
+ let m;
153
+ attrRe.lastIndex = 0;
154
+ while ((m = attrRe.exec(content)) !== null)
155
+ found.add(m[2]);
156
+ }
157
+ };
158
+ walk(root);
159
+ return found;
160
+ }
161
+ /** Nearest ancestor directory (incl. self) that has a package.json. */
162
+ function packageRoot(fromDir) {
163
+ let dir = fromDir;
164
+ for (let i = 0; i < 12; i++) {
165
+ if (existsSync(join(dir, 'package.json')))
166
+ return dir;
167
+ const parent = dirname(dir);
168
+ if (parent === dir)
169
+ break;
170
+ dir = parent;
171
+ }
172
+ return fromDir;
173
+ }
97
174
  /**
98
175
  * A concrete instruction for how a backend (Jest) suite should reach the
99
176
  * server, based on what the project actually depends on — so the AI does
@@ -24,20 +24,57 @@ const MAP_PATH = 'testforge.md';
24
24
  export const HARNESS_CONTRACT = [
25
25
  `The testkit module MUST export exactly this stable API (CommonJS`,
26
26
  `module.exports), implemented against the project's real data layer:`,
27
- ` - connect(): Promise<void> open the DB connection (env DB var`,
28
- ` from the map, e.g. process.env.DB_URL)`,
27
+ ` - connect(): Promise<void> open the DB connection(s). Some apps`,
28
+ ` span MULTIPLE databases (e.g. a main DB,`,
29
+ ` a separate chat/conversation DB, and an`,
30
+ ` identity/IM DB) — READ the real data`,
31
+ ` config to find how many and their env-var`,
32
+ ` names, and connect to EACH. Do NOT assume`,
33
+ ` a single process.env.DB_URL.`,
29
34
  ` - disconnect(): Promise<void> close it`,
30
- ` - reset(): Promise<void> remove all test data (only records`,
31
- ` with the "qa-"/"QA-TEST" prefix)`,
35
+ ` - reset(): Promise<void> remove ONLY test data (records with the`,
36
+ ` "qa-"/"QA-TEST" prefix or the well-known`,
37
+ ` TEST_IDS), cascading children by`,
38
+ ` projectId. MUST be gated — see SAFETY.`,
32
39
  ` - seed(spec?): Promise<object> idempotently upsert the test records`,
33
40
  ` the tests need (using the real models`,
34
41
  ` /collections + enums), returning the`,
35
- ` created ids/handles`,
36
- ` - TEST_IDS: object the stable well-known test ids/handles`,
42
+ ` created ids/handles. Seed EACH record`,
43
+ ` into the database it actually belongs to`,
44
+ ` (e.g. the identity/IM user in the IM DB,`,
45
+ ` a conversation in the chat DB) — not all`,
46
+ ` into one DB. Upsert with { $set: mutable,`,
47
+ ` $setOnInsert: { createdAt } } — never`,
48
+ ` replaceOne, never clobber creation`,
49
+ ` metadata or a pre-existing richer record.`,
50
+ ` Seeds are TRUSTED fixtures: do NOT let the`,
51
+ ` app's own schema quirks block them — if an`,
52
+ ` update-validator rejects a value the app`,
53
+ ` itself defaults (e.g. a required String`,
54
+ ` whose default is ''), write WITHOUT update`,
55
+ ` validators (runValidators:false) rather`,
56
+ ` than failing the whole seed.`,
57
+ ` - TEST_IDS: object the stable well-known test ids/handles.`,
58
+ ` EVERY id MUST be env-overridable: resolve`,
59
+ ` each as process.env.TEST_<NAME>_ID ||`,
60
+ ` '<stable default>' (e.g. TEST_PROJECT_ID,`,
61
+ ` TEST_WEARER_ID). This lets a caller point`,
62
+ ` the suite at data created BY HAND on`,
63
+ ` staging/prod (with SEED_MODE=existing)`,
64
+ ` without editing code. Resolve non-id`,
65
+ ` handles (phone, email, auth subject) the`,
66
+ ` same env-overridable way.`,
37
67
  ` - setup(): Promise<void> THE one-call entry point (idempotent,`,
38
68
  ` safe to call from every beforeAll AND`,
39
69
  ` from a Playwright globalSetup). It:`,
40
- ` 1. connect()s + seed()s;`,
70
+ ` 1. connect()s; then seed()s — UNLESS`,
71
+ ` process.env.SEED_MODE === 'existing',`,
72
+ ` in which case it SKIPS seed() and`,
73
+ ` reset() and runs against data already`,
74
+ ` in the DB (staging/prod, where the`,
75
+ ` fixtures were created by hand and`,
76
+ ` addressed via the env-overridable`,
77
+ ` TEST_IDS);`,
41
78
  ` 2. MINTS a fresh auth access token at`,
42
79
  ` runtime — read the project's REAL`,
43
80
  ` login/refresh auth (controllers/`,
@@ -64,11 +101,66 @@ export const HARNESS_CONTRACT = [
64
101
  ` fixture — \`const { request: apiRequest } = require('@playwright/`,
65
102
  ` test'); return apiRequest.newContext({ baseURL, extraHTTPHeaders })\`.`,
66
103
  ` The \`request\` FIXTURE has NO .newContext(); calling it throws.`,
67
- ` - login(page): Promise<void> drive/seed an authenticated UI session`,
104
+ ` - login(page): Promise<void> establish an authenticated UI session`,
105
+ ` the webapp actually accepts. Inject the`,
106
+ ` SAME real minted access token (a valid,`,
107
+ ` decodable JWT — the app gate typically`,
108
+ ` jwtDecode()s it and checks exp) into the`,
109
+ ` EXACT localStorage/cookie keys the app`,
110
+ ` reads (GREP the auth reducer/guard for the`,
111
+ ` key names). A fabricated/opaque token that`,
112
+ ` fails the app's validity check leaves the`,
113
+ ` user logged-out (redirect loop). The token`,
114
+ ` owner MUST be the identity that owns the`,
115
+ ` seeded fixtures (see IDENTITY BINDING), so`,
116
+ ` the UI shows the seeded data.`,
68
117
  `Use only env var NAMES for secrets/connection — never literal values.`,
69
118
  `The ONLY credentials a developer should have to provide are the DB`,
70
119
  `connection, a Stripe test key, and ONE seed/refresh token (or seeded`,
71
120
  `login) — everything else setup() derives at runtime.`,
121
+ ``,
122
+ `SAFETY — this testkit must NEVER be able to damage a real environment:`,
123
+ ` - reset() and ANY delete/drop MUST refuse unless BOTH hold: the opt-in`,
124
+ ` flag process.env.TESTFORGE_ALLOW_RESET === 'true' is set, AND the`,
125
+ ` connection is clearly NOT production (e.g. host is localhost or the`,
126
+ ` DB name contains "test"). Otherwise throw a clear Error naming the`,
127
+ ` target. Default is SAFE: with the flag unset, reset() throws and`,
128
+ ` deletes nothing.`,
129
+ ` - When process.env.SEED_MODE === 'existing', reset() and all seed()`,
130
+ ` WRITES are skipped entirely — the suite runs against data already`,
131
+ ` present (hand-created on staging/prod), addressed via TEST_IDS. Do`,
132
+ ` not mutate that data destructively.`,
133
+ ` - Never widen a delete beyond the "qa-"/"QA-TEST" prefix + TEST_IDS`,
134
+ ` scope; always cascade children by projectId, never a bare drop.`,
135
+ ``,
136
+ `IDENTITY BINDING — the #1 cause of false failures, get this right.`,
137
+ `Owner-scoped endpoints ("my bookings", "my payouts") derive the owner`,
138
+ `from the AUTHENTICATED TOKEN, not from a query param — and a route`,
139
+ `declared auth:false may STILL populate request.user via a pre-auth`,
140
+ `extension when a Bearer is sent. Therefore:`,
141
+ ` - setup() MUST resolve the authenticated identity's REAL id (decode`,
142
+ ` the minted token's subject/_id claim, or call the profile/whoami`,
143
+ ` endpoint) and seed the OWNER fixtures (the maker/wearer/customer`,
144
+ ` whose data the tests read) under THAT id — then set the TEST_IDS`,
145
+ ` owner ids to it.`,
146
+ ` - Do NOT hardcode an owner id the token does not own: the seeded rows`,
147
+ ` would never be returned to the authenticated caller, and every`,
148
+ ` owner-scoped test would fail for a fake reason.`,
149
+ ` - A scope/IDOR test needing a SECOND, non-owner identity needs a`,
150
+ ` distinct token (env). If only one identity is available, make those`,
151
+ ` cases pending — never assert a scope the token cannot actually prove.`,
152
+ ``,
153
+ `CONNECTION LIFECYCLE — setup() must be safe to call repeatedly across`,
154
+ `suites, or one suite's teardown breaks the next (MongoNotConnectedError).`,
155
+ ` - connect() and setup() MUST check the live connection state before`,
156
+ ` use (e.g. mongoose.connection.readyState !== 1) and REOPEN if closed.`,
157
+ ` Do NOT gate reconnection behind a memoized "already set up" promise —`,
158
+ ` a cached promise that resolved once will NOT reopen a since-closed`,
159
+ ` connection. Guard on the ACTUAL connection state, not a boolean flag.`,
160
+ ` - Generated tests must NOT call disconnect() on the shared connection`,
161
+ ` in an afterAll/afterEach. Leave teardown to a single global teardown`,
162
+ ` (Playwright globalTeardown / Jest globalTeardown) that runs ONCE after`,
163
+ ` the whole run — never per-file.`,
72
164
  ].join('\n');
73
165
  /** Where an area's shared testkit lives, and how tests import it. */
74
166
  export function harnessInfo(areaDir, framework, ext) {
@@ -96,7 +188,10 @@ function buildHarnessPrompt(harnessPath, framework) {
96
188
  `You are inside the repository with read-only tools (Read, Grep, Glob,`,
97
189
  `LS). READ the real DB config, the models/schemas, AND the real auth`,
98
190
  `flow (login / refresh-token / OTP controllers + middleware) so setup()`,
99
- `mints tokens the way the app actually does. Also GREP the sibling test`,
191
+ `mints tokens the way the app actually does. Determine whether the app`,
192
+ `uses MORE THAN ONE database (e.g. a main DB, a chat/conversation DB, an`,
193
+ `identity/IM DB); if so, connect to and seed EACH via its own env-driven`,
194
+ `connection — do not collapse them into one. Also GREP the sibling test`,
100
195
  `files next to this testkit for every process.env.<NAME> they read`,
101
196
  `(especially *TOKEN*, *_ID, base URLs) and make setup() POPULATE those`,
102
197
  `exact names — the tests must run without any hand-pasted token.`,
@@ -199,3 +294,54 @@ export function globalSetupContent() {
199
294
  ``,
200
295
  ].join('\n');
201
296
  }
297
+ /**
298
+ * A fixture/environment guide written next to the testkit. Documents the
299
+ * two run modes and — crucially — how to run against data created BY HAND
300
+ * on staging/prod (env-addressable TEST_IDS + SEED_MODE=existing), plus the
301
+ * safety gates. Deterministic and project-agnostic; the exact per-entity
302
+ * shape lives in the generated seed()/TEST_IDS.
303
+ */
304
+ export function fixturesDoc() {
305
+ return [
306
+ `# Test fixtures & environments`,
307
+ ``,
308
+ `Generated by testforge. The same tests run in two modes.`,
309
+ ``,
310
+ `## Modes`,
311
+ `- **auto** (default — local / CI): the testkit seeds its own fixtures`,
312
+ ` into a throwaway TEST database and resets them between runs. No manual`,
313
+ ` setup.`,
314
+ `- **existing** (staging / production): the testkit does NOT seed or`,
315
+ ` reset — it runs against data that already exists. Enable with:`,
316
+ ``,
317
+ ` SEED_MODE=existing`,
318
+ ``,
319
+ `## Running against data you created by hand (staging / prod)`,
320
+ `1. Create the fixture entities in the target database yourself (a`,
321
+ ` project, its price line(s), the wearer/maker users, etc.) — mirror`,
322
+ ` what the seed builders in \`helpers/testkit\` produce.`,
323
+ `2. Point the suite at them — every well-known id is env-overridable:`,
324
+ ``,
325
+ ` TEST_PROJECT_ID=<your project id>`,
326
+ ` TEST_WEARER_ID=<...> # and any other TEST_*_ID the suite reads`,
327
+ ``,
328
+ `3. Provide auth + connection env (DB url/name(s), a seed/refresh token`,
329
+ ` or seeded login, Stripe test key).`,
330
+ `4. Run with \`SEED_MODE=existing\` so nothing is seeded or deleted.`,
331
+ ``,
332
+ `## Safety`,
333
+ `- \`reset()\` and any delete are DISABLED unless BOTH hold:`,
334
+ ` \`TESTFORGE_ALLOW_RESET=true\` AND the target DB is clearly`,
335
+ ` non-production (localhost, or a DB name containing "test"). Otherwise`,
336
+ ` it throws.`,
337
+ `- In \`SEED_MODE=existing\`, seeding and reset are skipped entirely —`,
338
+ ` your data is never mutated destructively.`,
339
+ `- NEVER point auto mode at a production database.`,
340
+ ``,
341
+ `## The exact fixture shape`,
342
+ `The precise fields/enums each fixture needs are defined by \`seed()\``,
343
+ `and \`TEST_IDS\` in \`helpers/testkit\`. Read those to reproduce a`,
344
+ `matching entity by hand.`,
345
+ ``,
346
+ ].join('\n');
347
+ }
@@ -25,8 +25,8 @@ import { generateWithAI, generateWithAIAsync } from './ai-generator.js';
25
25
  import { generateSeed } from './seed-generator.js';
26
26
  import { planSuite } from './suite-planner.js';
27
27
  import { isSyntaxValid, checkTestGates, analyzeFile } from './validate.js';
28
- import { installedModules, transportHint } from './detect.js';
29
- import { harnessInfo, generateHarnessAsync, generateHarness, playwrightConfig, globalSetupContent, } from './harness.js';
28
+ import { installedModules, transportHint, projectSelectors } from './detect.js';
29
+ import { harnessInfo, generateHarnessAsync, generateHarness, playwrightConfig, globalSetupContent, fixturesDoc, } from './harness.js';
30
30
  import { hashContent, loadCache, saveCache } from './cache.js';
31
31
  import { findMarkdownFiles } from './find-files.js';
32
32
  import { loadConfig, testDirForType, resolveFramework } from './config.js';
@@ -175,6 +175,7 @@ export async function runHarness(only) {
175
175
  }
176
176
  mkdirSync(dirname(info.path), { recursive: true });
177
177
  writeFileSync(info.path, kit, 'utf-8');
178
+ writeFileSync(join(info.dir, 'FIXTURES.md'), fixturesDoc(), 'utf-8');
178
179
  if (framework === 'playwright') {
179
180
  const gsPath = join(info.dir, 'global-setup.js');
180
181
  writeFileSync(gsPath, globalSetupContent(), 'utf-8');
@@ -200,10 +201,47 @@ const GEN_MARKER = '@generated by testforge';
200
201
  * and tell the user to confirm the spec first.
201
202
  */
202
203
  function isStubDoc(markdown) {
203
- return (/^#.*\btodo\b/im.test(markdown) ||
204
- /not covered by the reference/i.test(markdown) ||
205
- /confirm (the )?expected behaviou?r/i.test(markdown) ||
206
- /todo:\s*not covered/i.test(markdown));
204
+ // The doc's FIRST heading is its premise. A confirmed journey opens with a
205
+ // real title (e.g. "# Journey: ..."); a genuine stub opens by declaring
206
+ // itself uncovered ("# TODO: not covered by the reference ...").
207
+ //
208
+ // We deliberately look ONLY at the opening heading. Partial "reference
209
+ // SILENT" annotations on individual SECTIONS of an otherwise-complete doc,
210
+ // and trailing "developer verification checklist" sections, are healthy —
211
+ // the previous whole-document scan wrongly skipped confirmed journeys that
212
+ // merely carried such notes.
213
+ const firstHeading = (markdown.match(/^#{1,6}\s+.+$/m) || [''])[0];
214
+ return /\btodo\b|not covered by the reference|reference[-\s]silent|unconfirmed/i.test(firstHeading);
215
+ }
216
+ /**
217
+ * True if a generated file is a testforge DEFERRED stub — a placeholder
218
+ * written when AI generation was unavailable. It is NOT real coverage, so
219
+ * per-file resume must RE-ATTEMPT it (unlike a real file, which is kept).
220
+ */
221
+ function isDeferredStub(content) {
222
+ return content.includes('DEFERRED stub');
223
+ }
224
+ /** True if the journey's output dir already holds any deferred stub. */
225
+ function hasDeferredStubs(testDir, ext) {
226
+ let files;
227
+ try {
228
+ files = readdirSync(testDir);
229
+ }
230
+ catch {
231
+ return false;
232
+ }
233
+ for (const f of files) {
234
+ if (!f.endsWith(ext))
235
+ continue;
236
+ try {
237
+ if (isDeferredStub(readFileSync(join(testDir, f), 'utf-8')))
238
+ return true;
239
+ }
240
+ catch {
241
+ /* ignore unreadable */
242
+ }
243
+ }
244
+ return false;
207
245
  }
208
246
  /** Slugs recorded in a journey's existing plan cache (the previous run). */
209
247
  function readPlanSlugs(journey) {
@@ -295,6 +333,10 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
295
333
  const seedExt = ext.endsWith('.js') ? '.seed.js' : '.seed.ts';
296
334
  const cache = loadCache(CACHE_PATH);
297
335
  const results = [];
336
+ // Real UI test-hooks per area, extracted once from the components — feeds
337
+ // the F10 selector-grounding gate so generated UI tests can only assert on
338
+ // selectors that genuinely exist (never an invented data-testid).
339
+ const selectorsByArea = new Map();
298
340
  // Shared harness per area — generated once, then every test in that
299
341
  // area imports it (so files stop inventing their own helper paths).
300
342
  const harnessByArea = new Map();
@@ -318,6 +360,7 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
318
360
  }
319
361
  mkdirSync(dirname(info.path), { recursive: true });
320
362
  writeFileSync(info.path, kit, 'utf-8');
363
+ writeFileSync(join(info.dir, 'FIXTURES.md'), fixturesDoc(), 'utf-8');
321
364
  // Playwright areas need a config + globalSetup so the specs can run
322
365
  // AND get their tokens minted once before the suite.
323
366
  if (framework === 'playwright') {
@@ -366,9 +409,20 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
366
409
  // AI can't fall back to an uninstalled convention (e.g. supertest).
367
410
  const allowedModules = installedModules(testDir);
368
411
  const transport = transportHint(testDir, framework);
369
- const testGate = (content) => checkTestGates(content, ext, { framework, allowedModules });
370
- // Skip if unchanged.
371
- if (!force && cache[file] === hash) {
412
+ // F10 — selector grounding (Playwright areas only): the real test-hooks
413
+ // in this area's components, so a generated UI test can't invent one.
414
+ let knownSelectors;
415
+ if (framework === 'playwright') {
416
+ knownSelectors = selectorsByArea.get(areaDir);
417
+ if (!knownSelectors) {
418
+ knownSelectors = projectSelectors(areaDir);
419
+ selectorsByArea.set(areaDir, knownSelectors);
420
+ }
421
+ }
422
+ const testGate = (content) => checkTestGates(content, ext, { framework, allowedModules, knownSelectors });
423
+ // Skip if unchanged — but NOT if the journey still has deferred stubs
424
+ // to retry (a resume pass fills those in without touching real files).
425
+ if (!force && cache[file] === hash && !hasDeferredStubs(testDir, ext)) {
372
426
  results.push({
373
427
  journeyName: journey.name,
374
428
  testDir,
@@ -421,9 +475,12 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
421
475
  warnings.push(`removed ${staleRemoved.length} stale file(s) from a previous plan: ${staleRemoved.join(', ')}`);
422
476
  }
423
477
  // Generate all focused files CONCURRENTLY (bounded pool). Each AI
424
- // call is independent, so a 15-file journey finishes in ~4 waves
425
- // instead of 15 serial calls.
426
- const generated = await pool(planned, GEN_CONCURRENCY, async (pf) => {
478
+ // call is independent, so a 15-file journey finishes in several waves
479
+ // instead of one-at-a-time. Concurrency is configurable: too many
480
+ // parallel agentic AI runs starve each other and time out, so a flaky
481
+ // environment can lower `aiConcurrency` (e.g. 2) for reliability.
482
+ const concurrency = Math.max(1, config.aiConcurrency ?? GEN_CONCURRENCY);
483
+ const generated = await pool(planned, concurrency, async (pf) => {
427
484
  // Flat (jest): prefix the file with the journey so many journeys can
428
485
  // share one dir without clashing. Folder (playwright): the journey
429
486
  // folder already namespaces it, so the slug alone is enough.
@@ -435,7 +492,10 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
435
492
  // (--force ignores this and regenerates.)
436
493
  if (!force && existsSync(filePath)) {
437
494
  try {
438
- if (isSyntaxValid(readFileSync(filePath, 'utf-8'), ext)) {
495
+ const existing = readFileSync(filePath, 'utf-8');
496
+ // Keep a real, parsing file — but a DEFERRED stub is not real
497
+ // coverage, so fall through and re-attempt it on this resume pass.
498
+ if (isSyntaxValid(existing, ext) && !isDeferredStub(existing)) {
439
499
  return { slug: pf.slug, path: filePath, status: 'ai' };
440
500
  }
441
501
  }
@@ -42,11 +42,22 @@ export function buildSeedPrompt(journeyDoc, seedPath) {
42
42
  `the seed code to stdout.`,
43
43
  ``,
44
44
  `Requirements for the seed you write:`,
45
- `- Connect using the env var named in the map (e.g.`,
46
- ` process.env.DB_URL). NEVER hardcode a connection string or any`,
47
- ` secret.`,
48
- `- Make it IDEMPOTENT: use upsert by a stable, well-known test id so`,
49
- ` running it repeatedly (fresh DB or already-seeded) is safe.`,
45
+ `- Connect using the env var(s) named in the map. If the app spans`,
46
+ ` MULTIPLE databases (e.g. main + chat/conversation + identity/IM),`,
47
+ ` connect to and seed EACH into the DB it belongs to — do not assume a`,
48
+ ` single process.env.DB_URL. NEVER hardcode a connection string or secret.`,
49
+ `- Make it IDEMPOTENT with an explicit field split: upsert by a stable`,
50
+ ` id using { $set: <mutable fields>, $setOnInsert: { createdAt, ...other`,
51
+ ` creation-only fields } } (updateOne/findOneAndUpdate, upsert:true). Do`,
52
+ ` NOT use replaceOne, and do NOT put createdAt/creation metadata in $set`,
53
+ ` — that clobbers it on every re-run. Seed shared/base entities (e.g. an`,
54
+ ` identity user) INSERT-ONLY ($setOnInsert only) so a developer's richer`,
55
+ ` local record is never overwritten.`,
56
+ `- Every well-known id MUST be env-overridable: read`,
57
+ ` process.env.TEST_<NAME>_ID (falling back to a stable default), using`,
58
+ ` the SAME names the testkit's TEST_IDS exposes. This lets the same`,
59
+ ` fixtures target data created BY HAND on staging/prod without code`,
60
+ ` edits (used with SEED_MODE=existing, where writes are skipped).`,
50
61
  `- Prefix test data clearly (e.g. names/ids starting with "qa-" or`,
51
62
  ` "QA-TEST") so it is obvious and easy to clean up.`,
52
63
  `- Insert only what THIS feature's tests need as a precondition,`,
@@ -32,12 +32,20 @@ export function checkTestGates(content, _ext, ctx) {
32
32
  return "jest area must not import '@playwright/test'";
33
33
  }
34
34
  // F4 — goto guard: `page.goto(...)` must take a real route/URL, not a
35
- // pasted description. Literal args containing whitespace are prose.
35
+ // pasted description and not an unfilled route-file placeholder. Literal
36
+ // args with whitespace are prose; `[param]`/`:param`/`{param}`/`<param>`
37
+ // are route templates that navigate to a garbage literal path at runtime.
38
+ // (Only quote-delimited literals are inspected — a backtick template like
39
+ // `/s/${code}` is the correct, resolved form and is intentionally exempt.)
36
40
  const gotoRe = /\.goto\(\s*(['"])([\s\S]*?)\1/g;
37
41
  let m;
38
42
  while ((m = gotoRe.exec(content)) !== null) {
39
- if (/\s/.test(m[2])) {
40
- return `goto() argument looks like prose, not a URL/path: "${m[2].slice(0, 40)}…"`;
43
+ const arg = m[2];
44
+ if (/\s/.test(arg)) {
45
+ return `goto() argument looks like prose, not a URL/path: "${arg.slice(0, 40)}…"`;
46
+ }
47
+ if (/\[[A-Za-z_]|\/:[A-Za-z_]|\{[A-Za-z_]|<[A-Za-z_]/.test(arg)) {
48
+ return `goto() has an unresolved route placeholder — fill it with a real value: "${arg.slice(0, 40)}"`;
41
49
  }
42
50
  }
43
51
  // F2 — no silent false-green: a file with RUNNABLE tests must assert
@@ -61,8 +69,139 @@ export function checkTestGates(content, _ext, ctx) {
61
69
  }
62
70
  }
63
71
  }
72
+ // F11 — hermetic seam (jest/backend): a test that imports a known
73
+ // EXTERNAL network service (payment/SMS/email/cloud) must mock it, or the
74
+ // suite silently depends on a live third party — it flakes offline and can
75
+ // fire real charges/messages. DB clients are intentionally NOT listed:
76
+ // backend integration tests legitimately use a real (local) database.
77
+ if (ctx.framework === 'jest') {
78
+ const EXTERNAL = new Set([
79
+ 'stripe', 'twilio', '@sendgrid/mail', '@sendgrid/client',
80
+ 'nodemailer', 'aws-sdk', '@aws-sdk/client-ses', '@aws-sdk/client-sns',
81
+ ]);
82
+ for (const mod of importedModules(content)) {
83
+ const top = topLevelModule(mod);
84
+ if (!EXTERNAL.has(top))
85
+ continue;
86
+ const mocked = content.includes(`jest.mock('${mod}'`) ||
87
+ content.includes(`jest.mock("${mod}"`) ||
88
+ content.includes(`jest.mock('${top}'`) ||
89
+ content.includes(`jest.mock("${top}"`);
90
+ if (!mocked) {
91
+ return `imports external service '${top}' without a jest.mock — the test would hit the live service; mock it at the seam`;
92
+ }
93
+ }
94
+ }
95
+ // F10 — selector grounding: a RUNNABLE (non-skipped) UI test must not lean
96
+ // on a guessed selector. Unchecked, the generator invents plausible
97
+ // data-testids that time out at runtime — the journey is never really
98
+ // exercised, and the red looks like a product failure instead of a test
99
+ // gap. A guessed selector must be grounded in a real component attribute,
100
+ // or the test must be explicitly deferred (test.fixme/skip). This is
101
+ // feature-agnostic — it guards every UI journey, not any one flow.
102
+ if (ctx.framework === 'playwright') {
103
+ const active = stripPendingBlocks(content);
104
+ // (a) The generator's own tell: a selector sitting next to a
105
+ // placeholder marker on the same line (e.g. `// TODO: real selector`).
106
+ const marker = String.raw `(?:TODO|FIXME|placeholder|real selector|guess(?:ed)?|unconfirmed|not\s+verified)`;
107
+ const sel = String.raw `(?:getByTestId\s*\(|\[\s*data-(?:testid|cy|test)\s*=)`;
108
+ const selNearMarker = new RegExp(`(?:${sel}[^\\n]*?\\b${marker}\\b)|(?:\\b${marker}\\b[^\\n]*?${sel})`, 'i');
109
+ if (selNearMarker.test(active)) {
110
+ return 'a runnable test uses a guessed/placeholder selector — ground it in a real component attribute, or defer the test (test.fixme/skip)';
111
+ }
112
+ // (b) When the project's real test-hooks are known, any test-id a
113
+ // runnable test uses that isn't one of them is an invented selector.
114
+ if (ctx.knownSelectors && ctx.knownSelectors.size > 0) {
115
+ for (const id of usedTestIds(active)) {
116
+ if (!ctx.knownSelectors.has(id)) {
117
+ return `references selector '${id}' not found in the project's components — ground it or defer the test`;
118
+ }
119
+ }
120
+ }
121
+ }
64
122
  return null;
65
123
  }
124
+ /**
125
+ * Remove pending test blocks (`it/test/describe.skip|fixme|todo(...)`) so the
126
+ * F9 selector check only ever inspects RUNNABLE code. A guessed selector
127
+ * inside a deferred test is fine — it's an honest gap, not a false red.
128
+ * String- and comment-aware paren matching so a `(` inside a body or string
129
+ * doesn't unbalance the scan.
130
+ */
131
+ function stripPendingBlocks(content) {
132
+ const startRe = /\b(?:it|test|describe)\.(?:skip|fixme|todo)\s*\(/g;
133
+ let out = '';
134
+ let last = 0;
135
+ let m;
136
+ while ((m = startRe.exec(content)) !== null) {
137
+ const openIdx = m.index + m[0].length - 1; // index of the '('
138
+ const end = matchParen(content, openIdx);
139
+ if (end === -1)
140
+ continue; // unbalanced — leave as-is, don't drop tail
141
+ out += content.slice(last, m.index);
142
+ last = end + 1;
143
+ startRe.lastIndex = last;
144
+ }
145
+ out += content.slice(last);
146
+ return out;
147
+ }
148
+ /**
149
+ * Given the index of an opening `(`, return the index of its matching `)`,
150
+ * skipping over quoted strings, template literals, and comments. Returns -1
151
+ * if no match is found.
152
+ */
153
+ function matchParen(s, open) {
154
+ let depth = 0;
155
+ for (let i = open; i < s.length; i++) {
156
+ const c = s[i];
157
+ // skip strings / templates
158
+ if (c === "'" || c === '"' || c === '`') {
159
+ const q = c;
160
+ i++;
161
+ while (i < s.length && s[i] !== q) {
162
+ if (s[i] === '\\')
163
+ i++;
164
+ i++;
165
+ }
166
+ continue;
167
+ }
168
+ // skip comments
169
+ if (c === '/' && s[i + 1] === '/') {
170
+ while (i < s.length && s[i] !== '\n')
171
+ i++;
172
+ continue;
173
+ }
174
+ if (c === '/' && s[i + 1] === '*') {
175
+ i += 2;
176
+ while (i < s.length && !(s[i] === '*' && s[i + 1] === '/'))
177
+ i++;
178
+ i++;
179
+ continue;
180
+ }
181
+ if (c === '(')
182
+ depth++;
183
+ else if (c === ')') {
184
+ depth--;
185
+ if (depth === 0)
186
+ return i;
187
+ }
188
+ }
189
+ return -1;
190
+ }
191
+ /** Test-hook values a file references: getByTestId('x'), [data-testid="x"], data-cy, data-test. */
192
+ function usedTestIds(content) {
193
+ const ids = new Set();
194
+ const patterns = [
195
+ /getByTestId\s*\(\s*(['"`])([^'"`]+)\1/g,
196
+ /\[\s*data-(?:testid|cy|test)\s*=\s*(['"])([^'"]+)\1\s*\]/g,
197
+ ];
198
+ for (const re of patterns) {
199
+ let m;
200
+ while ((m = re.exec(content)) !== null)
201
+ ids.add(m[2]);
202
+ }
203
+ return [...ids];
204
+ }
66
205
  /**
67
206
  * F8 — lightweight coverage stats for one generated file, so a manifest
68
207
  * can tell REAL coverage from deferred placeholders (a green run is never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@activate-core/test-forge",
3
- "version": "0.11.0",
3
+ "version": "0.12.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": {