@activate-core/test-forge 0.10.2 → 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,36 +18,58 @@ 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
  });
40
58
  }
41
59
  /** Build the prompt we send to the AI for one journey. */
42
- export function buildPrompt(journey, map, testPath, focus, harnessPath) {
60
+ export function buildPrompt(journey, map, testPath, focus, harnessPath, transport) {
43
61
  const config = loadConfig();
44
62
  // Opinionated: testforge only generates Jest (api/backend) or Playwright
45
63
  // (web/UI) tests — any other framework (e.g. a project's Cypress setup)
46
64
  // is clamped to the right one for the journey. Single source of truth in
47
65
  // resolveFramework, shared with the output-layout decision.
48
66
  const isApi = resolveFramework(config, journey.area, journey.type) === 'jest';
67
+ // Do NOT assume an HTTP client here — the transport is detected from the
68
+ // project's real dependencies and passed in (Hapi/Fastify inject,
69
+ // supertest only if installed, or native http). Hardcoding supertest
70
+ // was what produced ~half the API suites that never loaded.
49
71
  const framework = isApi
50
- ? 'Jest (with supertest for HTTP) for an API/backend test'
72
+ ? 'Jest for an API/backend test'
51
73
  : 'Playwright for a web/UI test';
52
74
  // Pending-test syntax differs by runner: Jest has test.todo/it.skip;
53
75
  // Playwright has NEITHER (test.todo throws) — it uses test.fixme/skip.
@@ -102,11 +124,44 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath) {
102
124
  `route file, its controller/handler, the service it calls, and the`,
103
125
  `models it touches. Confirm the REAL endpoint paths, HTTP methods,`,
104
126
  `request/response shapes, status codes, auth requirements, and function`,
105
- `signatures from the code. Base every assertion on what the code`,
106
- `actually does. Only fall back to a pending test (${pending}) when`,
107
- `the behavior genuinely cannot be determined from the source — do not`,
108
- `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.`,
109
154
  `Do NOT write or edit any file; output only the test code to stdout.`,
155
+ `EVERY runnable test MUST contain at least one real assertion. If you`,
156
+ `genuinely cannot assert an outcome, make the case pending (${pending})`,
157
+ `— never write a test body with no assertion, which passes without`,
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.`,
110
165
  ``,
111
166
  isApi
112
167
  ? [
@@ -118,19 +173,52 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath) {
118
173
  `duplicate exists, correct fields) using the database/ORM the`,
119
174
  `project uses. Connect via the env var named in the map (e.g.`,
120
175
  `process.env.DB_URL) — never hardcode a connection string.`,
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`,
193
+ `in-process mock to fire — they cannot both be true.`,
121
194
  ].join('\n')
122
195
  : [
123
196
  `Because this is a web journey, write Playwright tests that drive`,
124
- `the UI. FIRST find the REAL selectors: with your read-only tools,`,
125
- `locate and READ this page's actual React/Vue components (grep the`,
126
- `webapp source for the route, page name, and on-screen text, then`,
127
- `open those component files). Use the selectors that genuinely`,
128
- `exist there — data-testid/data-cy, input name/id, aria-role,`,
129
- `label text, or button text — via Playwright locators`,
130
- `(getByRole/getByLabel/getByTestId/getByText). Only leave a`,
131
- `"// TODO: confirm selector" when the element truly cannot be`,
132
- `found in the component source; do NOT default everything to a`,
133
- `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.`,
134
222
  `CRITICAL Playwright API: import { test, expect } from`,
135
223
  `'@playwright/test'. There is NO test.todo / it.* / describe.skip`,
136
224
  `in Playwright — using them throws at collection and breaks the`,
@@ -146,7 +234,11 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath) {
146
234
  `different name: \`import { request as apiRequest } from`,
147
235
  `'@playwright/test'\` then \`const ctx = await`,
148
236
  `apiRequest.newContext({ baseURL, extraHTTPHeaders })\`. Never call`,
149
- `.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).`,
150
242
  ].join('\n'),
151
243
  ``,
152
244
  ...(testPath
@@ -160,6 +252,9 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath) {
160
252
  ``,
161
253
  ]
162
254
  : []),
255
+ ...(transport
256
+ ? [transport, `Do NOT import any library that is not already a dependency of this project.`, ``]
257
+ : []),
163
258
  ...(harnessPath
164
259
  ? [
165
260
  `SHARED HARNESS — a testkit already exists at:`,
@@ -216,11 +311,16 @@ export function generateWithAI(journey, testPath, focus) {
216
311
  // Allow read-only exploration so the model grounds the test in the
217
312
  // real source (routes/controllers/models); Write stays denied, so it
218
313
  // still can't create files — it reads, then prints the code.
219
- const { program, args } = withReadOnlyTools(splitCommand(loadConfig().aiCommand));
314
+ const config = loadConfig();
315
+ const { program, args } = withReadOnlyTools(splitCommand(config.aiCommand));
220
316
  const output = execFileSync(program, args, {
221
317
  input: prompt,
222
318
  encoding: 'utf-8',
223
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',
224
324
  });
225
325
  return cleanOutput(output);
226
326
  }
@@ -229,11 +329,12 @@ export function generateWithAI(journey, testPath, focus) {
229
329
  * journey's suite can be generated concurrently (each spawns its own
230
330
  * `claude` process). Same prompt, same read-only exploration.
231
331
  */
232
- export async function generateWithAIAsync(journey, testPath, focus, harnessPath) {
332
+ export async function generateWithAIAsync(journey, testPath, focus, harnessPath, transport) {
233
333
  const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
234
- const prompt = buildPrompt(journey, map, testPath, focus, harnessPath);
235
- const { program, args } = withReadOnlyTools(splitCommand(loadConfig().aiCommand));
236
- const stdout = await runWithStdin(program, args, prompt);
334
+ const prompt = buildPrompt(journey, map, testPath, focus, harnessPath, transport);
335
+ const config = loadConfig();
336
+ const { program, args } = withReadOnlyTools(splitCommand(config.aiCommand));
337
+ const stdout = await runWithStdin(program, args, prompt, config.aiTimeoutMs);
237
338
  return cleanOutput(stdout);
238
339
  }
239
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',
@@ -0,0 +1,210 @@
1
+ /**
2
+ * testforge — project capability detection (dependency-aware, no guessing).
3
+ *
4
+ * Generated tests must fit the project they land in: they may only import
5
+ * libraries the project actually has, and backend suites must drive the
6
+ * server the way the project's stack supports (Hapi/Fastify inject,
7
+ * supertest, or a live HTTP call) — never by assuming a convention.
8
+ *
9
+ * Everything here is read from the real files (package.json + node_modules)
10
+ * walking up from the test location, so it works on ANY project, including
11
+ * a greenfield one with no existing tests.
12
+ */
13
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
14
+ import { builtinModules } from 'node:module';
15
+ import { dirname, extname, join } from 'node:path';
16
+ /** Read a package.json's dependency names (all dependency fields). */
17
+ function depsOf(pkgPath) {
18
+ try {
19
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
20
+ const fields = [
21
+ 'dependencies',
22
+ 'devDependencies',
23
+ 'peerDependencies',
24
+ 'optionalDependencies',
25
+ ];
26
+ const names = [];
27
+ for (const f of fields) {
28
+ const obj = pkg[f];
29
+ if (obj)
30
+ names.push(...Object.keys(obj));
31
+ }
32
+ return names;
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ }
38
+ /** Top-level entries of a node_modules dir, incl. scoped (@scope/pkg). */
39
+ function nodeModulesNames(nmDir) {
40
+ if (!existsSync(nmDir))
41
+ return [];
42
+ const out = [];
43
+ let entries;
44
+ try {
45
+ entries = readdirSync(nmDir);
46
+ }
47
+ catch {
48
+ return [];
49
+ }
50
+ for (const e of entries) {
51
+ if (e.startsWith('.'))
52
+ continue;
53
+ if (e.startsWith('@')) {
54
+ try {
55
+ for (const sub of readdirSync(join(nmDir, e))) {
56
+ if (!sub.startsWith('.'))
57
+ out.push(`${e}/${sub}`);
58
+ }
59
+ }
60
+ catch {
61
+ /* ignore */
62
+ }
63
+ }
64
+ else {
65
+ out.push(e);
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ /**
71
+ * The set of module specifiers a test at `fromDir` may import: every
72
+ * dependency declared in any package.json from `fromDir` up to the repo
73
+ * root, every package physically present in node_modules along the way,
74
+ * and all Node builtins. Union is intentional — being permissive here
75
+ * only risks NOT rejecting a real dep; the goal is to catch clearly
76
+ * absent libraries (e.g. supertest) without false-rejecting hoisted ones.
77
+ */
78
+ export function installedModules(fromDir) {
79
+ const mods = new Set();
80
+ for (const b of builtinModules) {
81
+ mods.add(b);
82
+ mods.add(`node:${b}`);
83
+ }
84
+ let dir = fromDir;
85
+ for (let i = 0; i < 12; i++) {
86
+ const pkg = join(dir, 'package.json');
87
+ if (existsSync(pkg))
88
+ depsOf(pkg).forEach((d) => mods.add(d));
89
+ nodeModulesNames(join(dir, 'node_modules')).forEach((d) => mods.add(d));
90
+ const parent = dirname(dir);
91
+ if (parent === dir)
92
+ break;
93
+ dir = parent;
94
+ }
95
+ return mods;
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
+ }
174
+ /**
175
+ * A concrete instruction for how a backend (Jest) suite should reach the
176
+ * server, based on what the project actually depends on — so the AI does
177
+ * not fall back to an uninstalled default (the supertest failure).
178
+ */
179
+ export function transportHint(fromDir, framework) {
180
+ if (framework === 'playwright') {
181
+ return `This is a Playwright area: use the browser + the testkit's apiContext(request) for API calls. Do NOT use supertest.`;
182
+ }
183
+ const mods = installedModules(fromDir);
184
+ const has = (m) => mods.has(m);
185
+ if (has('@hapi/hapi') || has('hapi')) {
186
+ return [
187
+ `TRANSPORT: this is a Hapi backend. Drive the server IN-PROCESS with`,
188
+ `\`server.inject({ method, url, payload, headers })\` — the same way`,
189
+ `the project's own tests do. Do NOT use supertest (it is not a`,
190
+ `dependency and would run a separate process the in-process mocks`,
191
+ `can't intercept). Require the app's server/init from the real source.`,
192
+ ].join('\n');
193
+ }
194
+ if (has('fastify')) {
195
+ return [
196
+ `TRANSPORT: this is a Fastify backend. Drive it IN-PROCESS with`,
197
+ `\`app.inject({ method, url, payload, headers })\`. Do NOT use supertest.`,
198
+ ].join('\n');
199
+ }
200
+ if (has('supertest')) {
201
+ return `TRANSPORT: use supertest (it is installed) against the app's exported Express/Koa/HTTP handler.`;
202
+ }
203
+ // Express/Koa/etc. without supertest, or unknown: don't invent a dep.
204
+ return [
205
+ `TRANSPORT: do NOT import any HTTP-testing library that is not already`,
206
+ `a dependency of this project. Prefer the framework's own in-process`,
207
+ `injection if it has one; otherwise use Node's built-in http/fetch`,
208
+ `against process.env.BASE_URL. Never add a new dependency.`,
209
+ ].join('\n');
210
+ }