@activate-core/test-forge 0.13.0 → 0.13.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.
@@ -83,21 +83,9 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
83
83
  : 'TypeScript';
84
84
  return [
85
85
  `You are a senior QA engineer. Generate a complete ${framework}`,
86
- `for the user journey described below.`,
86
+ `for the user journey described below. (The specific file to write is`,
87
+ `stated at the very end, after the journey — read all the context first.)`,
87
88
  ``,
88
- ...(focus
89
- ? [
90
- `SCOPE — this is ONE file in a larger suite for this journey.`,
91
- `Cover ONLY this concern in this file:`,
92
- ` ${focus.title}`,
93
- ` ${focus.covers}`,
94
- `Do NOT test the journey's other concerns here — separate files`,
95
- `cover those. Go DEEP on this one concern (happy path + its own`,
96
- `edge/negative/security cases) rather than broad-but-shallow.`,
97
- `Use this exact describe() title: "${focus.title}".`,
98
- ``,
99
- ]
100
- : []),
101
89
  `Think like an experienced tester whose goal is to break the`,
102
90
  `application. Cover, where relevant: happy path, missing/empty`,
103
91
  `required fields, invalid formats and boundary values (very long,`,
@@ -241,17 +229,6 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
241
229
  `module (aliased as apiRequest above).`,
242
230
  ].join('\n'),
243
231
  ``,
244
- ...(testPath
245
- ? [
246
- `This test file will be saved at EXACTLY this path:`,
247
- ` ${testPath}`,
248
- `Compute every require()/import path (to app source, helpers, the`,
249
- `seed file, etc.) RELATIVE TO THAT EXACT location — count the`,
250
- `directory depth from that path, do not assume a conventional`,
251
- `depth. Getting the relative depth wrong breaks the whole file.`,
252
- ``,
253
- ]
254
- : []),
255
232
  ...(transport
256
233
  ? [transport, `Do NOT import any library that is not already a dependency of this project.`, ``]
257
234
  : []),
@@ -298,6 +275,36 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
298
275
  `Data section. Use ALL of it. Respect any "# TODO:" notes.`,
299
276
  ``,
300
277
  journey.rawMarkdown,
278
+ // ---- PER-FILE TASK (kept LAST so the big context above stays a stable,
279
+ // cache-reusable prefix across every file in this journey, and so the
280
+ // concrete task is the most recent thing the model reads) ----
281
+ ``,
282
+ ...(focus
283
+ ? [
284
+ `--- THIS FILE (the one to write now) ---`,
285
+ `This is ONE file in a larger suite for this journey.`,
286
+ `Cover ONLY this concern here:`,
287
+ ` ${focus.title}`,
288
+ ` ${focus.covers}`,
289
+ `Do NOT test the journey's other concerns here — separate files`,
290
+ `cover those. Go DEEP on this one concern (happy path + its own`,
291
+ `edge/negative/security cases) rather than broad-but-shallow.`,
292
+ `Use this exact describe() title: "${focus.title}".`,
293
+ ]
294
+ : []),
295
+ ...(testPath
296
+ ? [
297
+ ``,
298
+ `Save this file at EXACTLY: ${testPath}`,
299
+ `Compute every require()/import path (app source, helpers, the seed`,
300
+ `file, etc.) RELATIVE TO THAT EXACT location — count the directory`,
301
+ `depth from that path; do not assume a conventional depth. Getting`,
302
+ `the relative depth wrong breaks the whole file.`,
303
+ ]
304
+ : []),
305
+ ``,
306
+ `Now output ONLY the raw ${isJs ? 'JavaScript' : 'TypeScript'} test code`,
307
+ `for THIS file — the first character must be the first line of code.`,
301
308
  ].join('\n');
302
309
  }
303
310
  /**
@@ -285,10 +285,10 @@ export function playwrightConfig(testDirRelToPackage, globalSetupRelToPackage, c
285
285
  ` testMatch: /.*\\.(test|spec)\\.[jt]s$/,`,
286
286
  ` globalSetup: require.resolve('${globalSetupRelToPackage}'),`,
287
287
  ` timeout: 60000,`,
288
- ` // Console line output + Allure results (see ALLURE.md to view the report).`,
289
- ` // Last reporter auto-publishes the run to the TestForge dashboard (needs`,
290
- ` // TESTFORGE_KEY in env + project/dashboardUrl in testforge.config.json).`,
291
- ` reporter: [['line'], ['allure-playwright'], [require.resolve('@activate-core/test-forge/reporters/playwright-reporter.cjs'), { category: ${JSON.stringify(category)} }]],`,
288
+ ` // Console line output. The last reporter auto-publishes the run to the`,
289
+ ` // TestForge dashboard (needs TESTFORGE_KEY in env + project/dashboardUrl`,
290
+ ` // in testforge.config.json).`,
291
+ ` reporter: [['line'], [require.resolve('@activate-core/test-forge/reporters/playwright-reporter.cjs'), { category: ${JSON.stringify(category)} }]],`,
292
292
  ` use: {`,
293
293
  ` baseURL: process.env.BASE_URL || 'http://localhost:3000',`,
294
294
  ` trace: 'on-first-retry',`,
@@ -300,8 +300,8 @@ export function playwrightConfig(testDirRelToPackage, globalSetupRelToPackage, c
300
300
  }
301
301
  /**
302
302
  * Jest config for a backend (jest) area — self-contained so `npm test` / a bare
303
- * `npx jest` runs with no shell setup, and emits Allure results.
304
- * - testEnvironment 'allure-jest/node': captures each test into allure-results/
303
+ * `npx jest` runs with no shell setup.
304
+ * - testEnvironment 'node': the default Node test environment
305
305
  * - setupFiles: loads .env.testing + NODE_ENV + the reset opt-in before any
306
306
  * test file imports the app
307
307
  * - serial (maxWorkers 1): suites share one test DB and reset between tests
@@ -309,9 +309,9 @@ export function playwrightConfig(testDirRelToPackage, globalSetupRelToPackage, c
309
309
  export function jestConfig(testDirRelToPackage, setupRelToPackage, category = 'api') {
310
310
  return [
311
311
  `// @generated by testforge`,
312
- `// Install once: npm i -D jest allure-jest allure-commandline (see ALLURE.md)`,
312
+ `// Install once: npm i -D jest`,
313
313
  `module.exports = {`,
314
- ` testEnvironment: 'allure-jest/node',`,
314
+ ` testEnvironment: 'node',`,
315
315
  ` setupFiles: ['<rootDir>/${setupRelToPackage}'],`,
316
316
  ` testMatch: ['<rootDir>/${testDirRelToPackage}/**/*.test.js'],`,
317
317
  ` // Serial: the suites share ONE test DB and reset it between tests.`,
@@ -350,49 +350,6 @@ export function jestSetupContent() {
350
350
  ``,
351
351
  ].join('\n');
352
352
  }
353
- /** Allure failure categories — buckets that show up in the report's "Categories"
354
- * tab. Copied into allure-results/ so the report groups failures meaningfully. */
355
- export function allureCategories() {
356
- return JSON.stringify([
357
- { name: 'Product defect', matchedStatuses: ['failed'], messageRegex: '.*expect.*|.*assert.*' },
358
- { name: 'Test / harness issue', matchedStatuses: ['broken'] },
359
- { name: 'Flaky', matchedStatuses: ['failed', 'broken'], flaky: true },
360
- ], null, 2);
361
- }
362
- /** ALLURE.md — how to install the reporter deps and view the report. */
363
- export function allureDoc() {
364
- return [
365
- `# Allure reporting (generated by testforge)`,
366
- ``,
367
- `testforge wires the suites to emit **Allure** results. To view the report:`,
368
- ``,
369
- `## 1. Install once (per package)`,
370
- '```bash',
371
- `# web (Playwright): npm i -D allure-playwright allure-commandline`,
372
- `# api (Jest): npm i -D allure-jest allure-commandline`,
373
- '```',
374
- ``,
375
- `## 2. Run the tests`,
376
- `Running the suite writes raw results into \`allure-results/\`.`,
377
- ``,
378
- `## 3. Generate + open the report`,
379
- '```bash',
380
- `npx allure generate allure-results --clean -o allure-report`,
381
- `npx allure open allure-report`,
382
- '```',
383
- `Or add scripts to package.json:`,
384
- '```json',
385
- `"report": "allure generate allure-results --clean -o allure-report && allure open allure-report"`,
386
- '```',
387
- ``,
388
- `## Categories`,
389
- `\`allure-categories.json\` groups failures (Product defect / Test-harness / Flaky).`,
390
- `Copy it to \`allure-results/categories.json\` before generating to activate it.`,
391
- ``,
392
- `> \`allure-results/\`, \`allure-report/\` are build output — add them to .gitignore.`,
393
- ``,
394
- ].join('\n');
395
- }
396
353
  /**
397
354
  * Playwright globalSetup file: runs the testkit's one-call setup() once
398
355
  * before the suite (connect + seed + mint/populate tokens). Sits next to
@@ -145,7 +145,7 @@ for (const f of ['jest.config.js','jest.config.cjs','jest.config.json']) {
145
145
  if (!Object.keys(base).length && fs.existsSync(path.join(__dirname,'package.json'))) base = require('./package.json').jest || {};
146
146
  module.exports = {
147
147
  ...base,
148
- testEnvironment: 'node', // avoid allure-jest side effects during a count-only run
148
+ testEnvironment: 'node', // plain node env for a count-only run
149
149
  setupFilesAfterEnv: [...(base.setupFilesAfterEnv || []), path.resolve(__dirname, '.tf-collect-patch.cjs')],
150
150
  };
151
151
  `;
@@ -27,7 +27,7 @@ import { generateSeed } from './seed-generator.js';
27
27
  import { planSuite } from './suite-planner.js';
28
28
  import { isSyntaxValid, checkTestGates, analyzeFile } from './validate.js';
29
29
  import { installedModules, transportHint, projectSelectors } from './detect.js';
30
- import { harnessInfo, generateHarnessAsync, generateHarness, playwrightConfig, globalSetupContent, fixturesDoc, jestConfig, jestSetupContent, allureCategories, allureDoc, } from './harness.js';
30
+ import { harnessInfo, generateHarnessAsync, generateHarness, playwrightConfig, globalSetupContent, fixturesDoc, jestConfig, jestSetupContent, } from './harness.js';
31
31
  import { hashContent, loadCache, saveCache } from './cache.js';
32
32
  import { findMarkdownFiles } from './find-files.js';
33
33
  import { loadConfig, testDirForType, resolveFramework } from './config.js';
@@ -356,7 +356,7 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
356
356
  writeFileSync(info.path, kit, 'utf-8');
357
357
  writeFileSync(join(info.dir, 'FIXTURES.md'), fixturesDoc(), 'utf-8');
358
358
  // Config + globalSetup (playwright) / config + setup (jest) so the specs run
359
- // AND get their tokens minted once, plus Allure reporting scaffolding.
359
+ // AND get their tokens minted once, plus the runner config scaffolding.
360
360
  writeHarnessConfigs(framework, areaDir, info.dir, area);
361
361
  harnessByArea.set(area, info.path);
362
362
  return info.path;
@@ -376,16 +376,15 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
376
376
  // --journey <x>: generate just the one matching journey.
377
377
  if (only && !matchesJourney(file, journey.id, only))
378
378
  continue;
379
- // Layout depends on the framework's test runner:
380
- // - Playwright (web/ops) recurses, so each journey gets its own
381
- // FOLDER of focused specs (testDir/<journey>/<slug>).
382
- // - Jest is often pointed at a flat glob (e.g. the-seam's
383
- // `**/service/*.test.js`, which does NOT recurse), so api/lambda
384
- // files stay FLAT in the test dir, journey-prefixed to stay unique
385
- // (testDir/<journey>-<slug>). Otherwise they'd silently not run.
379
+ // EVERY area gets its own per-journey FOLDER of focused files
380
+ // (testDir/<journey>/<slug>), for both Playwright and Jest. The folder
381
+ // namespaces the files, so the on-disk name is just the clean <slug>
382
+ // (no journey prefix) — which is also what the dashboard shows as the
383
+ // suite name. testforge's generated jest.config uses a recursive
384
+ // `tests/**/*.test.js` glob, so foldered jest files are still discovered.
386
385
  const areaDir = testDirForType(config, journey.area);
387
386
  const framework = resolveFramework(config, journey.area, journey.type);
388
- const flat = framework === 'jest';
387
+ const flat = false; // foldered layout for all runners (clean, categorised)
389
388
  const testDir = flat ? areaDir : join(areaDir, journey.id);
390
389
  const seedPath = join(seedDir, `${journey.id}${seedExt}`);
391
390
  // Dependency-aware guardrails (F1): what this area may import, and how
@@ -631,23 +630,15 @@ function writeCoverageManifest(results) {
631
630
  }
632
631
  }
633
632
  /**
634
- * Write the per-area runner config + Allure reporting scaffolding.
635
- * - playwright: global-setup.js + playwright.config.js (allure-playwright reporter)
636
- * - jest: helpers/jest.setup.js + jest.config.js (allure-jest env)
637
- * - both: ALLURE.md + allure-categories.json at the package root
633
+ * Write the per-area runner config.
634
+ * - playwright: global-setup.js + playwright.config.js
635
+ * - jest: helpers/jest.setup.js + jest.config.js
638
636
  * Config files are written only if absent, so a project's hand-tuned config is
639
637
  * never clobbered. `harnessDir` is the area's helpers dir (where setup lives).
640
638
  */
641
639
  function writeHarnessConfigs(framework, areaDir, harnessDir, area) {
642
640
  const pkgRoot = dirname(areaDir);
643
641
  const rel = areaDir.slice(pkgRoot.length + 1) || '.';
644
- // Allure docs + categories (both frameworks); never overwrite.
645
- const allureMd = join(pkgRoot, 'ALLURE.md');
646
- if (!existsSync(allureMd))
647
- writeFileSync(allureMd, allureDoc(), 'utf-8');
648
- const cats = join(pkgRoot, 'allure-categories.json');
649
- if (!existsSync(cats))
650
- writeFileSync(cats, allureCategories(), 'utf-8');
651
642
  if (framework === 'playwright') {
652
643
  const gsPath = join(harnessDir, 'global-setup.js');
653
644
  writeFileSync(gsPath, globalSetupContent(), 'utf-8');
@@ -44,7 +44,13 @@ function buildPlanPrompt(journey, map) {
44
44
  `Output ONLY a JSON array (no prose, no markdown fences) of objects:`,
45
45
  ` [{ "slug": "<short-kebab-name>", "title": "<describe title>",`,
46
46
  ` "covers": "<one or two sentences: exactly what this file tests>" }]`,
47
- `slug: lowercase, words joined by "-", no path and no file extension.`,
47
+ `slug: lowercase kebab-case, no path, no extension — and SHORT: name the`,
48
+ ` one concern in 2–4 words (4 max), the way a human names a file. Do NOT`,
49
+ ` restate the journey name and do NOT pile on qualifiers.`,
50
+ ` Good: "amount-validation", "3ds-challenge", "zero-amount", "rate-limit".`,
51
+ ` Bad: "charge-amount-sanity-negative-nonnumeric-oversized",`,
52
+ ` "otp-session-otp-send-rate-limit".`,
53
+ `title: human-readable and specific, e.g. "Payment — Amount validation".`,
48
54
  `The very first character of your response must be "[".`,
49
55
  ``,
50
56
  `--- PROJECT MAP (data layer) ---`,
@@ -71,14 +77,16 @@ function extractJsonArray(text) {
71
77
  return [];
72
78
  }
73
79
  }
74
- /** Normalise a raw slug into a safe kebab-case file stem. */
80
+ /** Normalise a raw slug into a safe, SHORT kebab-case file stem. */
75
81
  function safeSlug(raw) {
76
- return String(raw ?? '')
82
+ const kebab = String(raw ?? '')
77
83
  .trim()
78
84
  .toLowerCase()
79
85
  .replace(/\.(spec|test)\.[jt]sx?$/, '') // drop any extension the AI added
80
86
  .replace(/[^a-z0-9]+/g, '-')
81
87
  .replace(/^-|-$/g, '');
88
+ // Hard cap: keep names short & human — at most 4 words, whatever the AI returns.
89
+ return kebab.split('-').filter(Boolean).slice(0, 4).join('-');
82
90
  }
83
91
  /**
84
92
  * Plan the suite for one journey: returns the list of focused files to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@activate-core/test-forge",
3
- "version": "0.13.0",
3
+ "version": "0.13.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": {
@@ -42,6 +42,50 @@ function loadConfig(root) {
42
42
 
43
43
  const strip = (s) => (s || '').replace(/\x1b\[[0-9;]*m/g, '');
44
44
 
45
+ // jest's raw matcher line (`expect(received).toBeGreaterThanOrEqual(expected)`) reads
46
+ // backwards to a human. Translate the matcher into a plain, direction-correct sentence
47
+ // so an engineer sees intent, not jest internals.
48
+ const MATCHER = {
49
+ toBe: 'equal', toEqual: 'equal', toStrictEqual: 'equal',
50
+ toBeGreaterThanOrEqual: 'be at least', toBeGreaterThan: 'be greater than',
51
+ toBeLessThanOrEqual: 'be at most', toBeLessThan: 'be less than',
52
+ toContain: 'contain', toContainEqual: 'contain', toMatch: 'match',
53
+ toHaveLength: 'have length', toHaveProperty: 'have property', toBeCloseTo: 'be about',
54
+ };
55
+ function humanize(body) {
56
+ const m = body.match(/\.(\w+)\((?:expected|n|value|substring|sample|element)?\)/) || body.match(/expect\([^)]*\)\.(\w+)/);
57
+ const phrase = m && MATCHER[m[1]];
58
+ if (!phrase) return null;
59
+ const exp = (body.match(/Expected:?\s*(.+)/) || [])[1];
60
+ const rec = (body.match(/Received:?\s*(.+)/) || [])[1];
61
+ const ce = (exp || '').replace(/^[><=!~\s]+/, '').trim();
62
+ const cr = (rec || '').trim();
63
+ if (!ce || !cr) return null;
64
+ return `The check expected the result to ${phrase} ${ce}, but it was ${cr}.`;
65
+ }
66
+
67
+ // A readable failure detail for an engineer: a plain-language lead (what the check
68
+ // wanted vs what it got) + the Expected/Received values + any custom message. Drops
69
+ // jest's cryptic matcher line and the noisy stack frames / internal paths. Capped so
70
+ // it stays a summary, not a full dump.
71
+ function cleanError(raw) {
72
+ const s = strip(raw || '');
73
+ if (!s) return '';
74
+ const kept = s.split('\n').filter((l) => {
75
+ const t = l.trim();
76
+ if (!t) return true; // keep blank lines for readability (collapsed below)
77
+ if (/^at\s/.test(t)) return false; // stack frame
78
+ if (/node_modules|node:internal|\/dist\//.test(t)) return false; // library/internal noise
79
+ return true;
80
+ });
81
+ const body = kept.join('\n').replace(/\n{3,}/g, '\n\n').trim();
82
+ const lead = humanize(body);
83
+ if (!lead) return body.slice(0, 700);
84
+ // The plain lead replaces jest's `expect(received)…` matcher line — drop it, keep the rest.
85
+ const rest = body.split('\n').filter((l) => !/^(Error:\s*)?expect\(/.test(l.trim())).join('\n').replace(/\n{3,}/g, '\n\n').trim();
86
+ return (lead + (rest ? '\n\n' + rest : '')).slice(0, 700);
87
+ }
88
+
45
89
  function sh(cmd, cwd) {
46
90
  try { return (execSync(cmd, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()) || undefined; }
47
91
  catch { return undefined; }
@@ -106,4 +150,4 @@ async function publish(category, suites, durationSec) {
106
150
  }
107
151
  }
108
152
 
109
- module.exports = { strip, sig, publish, gitInfo, resolveEnv };
153
+ module.exports = { strip, cleanError, sig, publish, gitInfo, resolveEnv };
@@ -1,7 +1,7 @@
1
1
  // TestForge Jest reporter — auto-publishes the run to the dashboard on completion.
2
2
  // Add to jest.config.js:
3
3
  // reporters: ['default', ['<abs>/reporters/jest-reporter.cjs', { category: 'api' }]]
4
- const { strip, sig, publish } = require('./_shared.cjs');
4
+ const { cleanError, sig, publish } = require('./_shared.cjs');
5
5
 
6
6
  module.exports = class TestForgeJestReporter {
7
7
  constructor(_globalConfig, options) {
@@ -15,12 +15,14 @@ module.exports = class TestForgeJestReporter {
15
15
  for (const a of (f.testResults || f.assertionResults || [])) {
16
16
  const status = a.status === 'passed' ? 'passed' : a.status === 'failed' ? 'failed' : 'skipped';
17
17
  const raw = (a.failureMessages || [])[0];
18
- const err = strip(raw || '').split('\n').find((l) => l && l.trim()) || '';
19
18
  su.tests.push({
20
19
  name: a.fullName || a.title,
21
20
  status,
22
- error: status === 'failed' ? err.slice(0, 200) : undefined,
21
+ error: status === 'failed' ? cleanError(raw) : undefined,
23
22
  sig: status === 'failed' ? sig(raw) : undefined,
23
+ note: status === 'skipped'
24
+ ? (a.status === 'todo' ? 'Marked to-do — this scenario isn’t implemented yet' : 'Skipped — not run in this execution')
25
+ : undefined,
24
26
  });
25
27
  }
26
28
  }
@@ -1,7 +1,7 @@
1
1
  // TestForge Playwright reporter — auto-publishes the run to the dashboard on completion.
2
2
  // Add to playwright.config.js reporter array:
3
3
  // ['<abs>/reporters/playwright-reporter.cjs', { category: 'web' }]
4
- const { strip, sig, publish } = require('./_shared.cjs');
4
+ const { cleanError, sig, publish } = require('./_shared.cjs');
5
5
 
6
6
  module.exports = class TestForgePlaywrightReporter {
7
7
  constructor(options) {
@@ -15,12 +15,13 @@ module.exports = class TestForgePlaywrightReporter {
15
15
  const su = this.suites[name] || (this.suites[name] = { name, category: this.category, tests: [] });
16
16
  const status = result.status === 'passed' ? 'passed' : result.status === 'skipped' ? 'skipped' : 'failed';
17
17
  const raw = result.error && result.error.message;
18
- const err = strip(raw || '').split('\n').find((l) => l && l.trim()) || '';
18
+ const skipAnno = (test.annotations || []).find((x) => x.type === 'skip' || x.type === 'fixme');
19
19
  su.tests.push({
20
20
  name: test.title,
21
21
  status,
22
- error: status === 'failed' ? err.slice(0, 200) : undefined,
22
+ error: status === 'failed' ? cleanError(raw) : undefined,
23
23
  sig: status === 'failed' ? sig(raw) : undefined,
24
+ note: status === 'skipped' ? ((skipAnno && skipAnno.description) || 'Skipped — not run in this execution') : undefined,
24
25
  });
25
26
  }
26
27
  async onEnd(result) {