@activate-core/test-forge 0.13.0 → 0.14.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.
@@ -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,`,
@@ -117,6 +105,47 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
117
105
  `guard is missing and produce a failing test that falsely brands the`,
118
106
  `product as vulnerable.`,
119
107
  ``,
108
+ `PRIME DIRECTIVE — A FAILING TEST MUST MEAN A REAL PRODUCT DEFECT.`,
109
+ `Teams hold releases on what these tests report. A test that fails for`,
110
+ `any reason other than the product being wrong is worse than no test at`,
111
+ `all: it burns the team's trust, and the next real defect gets waved`,
112
+ `through as "probably the tool again". So a test may FAIL only when the`,
113
+ `product provably contradicts an expectation the journey document`,
114
+ `states EXPLICITLY. Apply these rules without exception:`,
115
+ `(1) EXPLICIT-ORACLE ONLY. Before writing an assertion, find the exact`,
116
+ ` line in the journey document that fixes the expected outcome, and`,
117
+ ` cite it in a short comment (e.g. "// oracle: §4 total = fee +`,
118
+ ` shipping"). If the expectation is merely implied, inferred, or`,
119
+ ` "good practice" — including a rule that sounds obviously right —`,
120
+ ` it is NOT an oracle: write it ${pending} with a "// TODO:" note.`,
121
+ ` An assertion you cannot trace to a stated requirement is a false`,
122
+ ` positive waiting to happen. Anything under a "Suggested (not a`,
123
+ ` requirement…)" heading is explicitly NOT an oracle — never assert`,
124
+ ` it; make it ${pending} if you cover it at all.`,
125
+ `(2) INFRASTRUCTURE IS NEVER A PRODUCT DEFECT. If a precondition cannot`,
126
+ ` be established — a dependency is unreachable, a required env var or`,
127
+ ` credential is absent, seed/fixture data is missing, a second`,
128
+ ` identity is unavailable, an external provider is down — SKIP the`,
129
+ ` test with a one-line reason naming exactly what was missing. Never`,
130
+ ` let a missing precondition reach an assertion and report as a bug.`,
131
+ ` Guard such tests at the top: check the precondition, skip if unmet.`,
132
+ `(3) NO FLAKY TESTS. Every test must pass or fail for the same reason on`,
133
+ ` every run, in any order. Do not depend on execution order, on data`,
134
+ ` another test creates or mutates, on wall-clock timing, or on`,
135
+ ` ambient totals that other tests change (assert on the rows YOU`,
136
+ ` seeded, by id — never on a global count). Set up what you assert`,
137
+ ` on, and assert only on that. Wait for a specific condition, never a`,
138
+ ` fixed sleep.`,
139
+ `(4) NEVER ASSERT AN UNFIXED VALUE. Do not assert exact user-facing copy,`,
140
+ ` incidental ordering, generated ids, timestamps, or counts the`,
141
+ ` document does not fix. Assert the behaviour the document specifies,`,
142
+ ` at the loosest form that still proves it.`,
143
+ `(5) A TIMEOUT IS NOT A BUG REPORT. If an action legitimately waits on`,
144
+ ` something external, verify availability first and skip when absent.`,
145
+ ` A test that times out tells the team nothing about the product.`,
146
+ `When rules conflict, prefer reporting LESS: a pending test costs a line`,
147
+ `in the report; a false failure costs the team a release.`,
148
+ ``,
120
149
  `GROUND EVERYTHING IN THE REAL SOURCE. You are running inside the`,
121
150
  `project's repository and have read-only tools (Read, Grep, Glob, LS).`,
122
151
  `BEFORE writing tests, look up the actual code for this journey — use`,
@@ -241,17 +270,6 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
241
270
  `module (aliased as apiRequest above).`,
242
271
  ].join('\n'),
243
272
  ``,
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
273
  ...(transport
256
274
  ? [transport, `Do NOT import any library that is not already a dependency of this project.`, ``]
257
275
  : []),
@@ -298,6 +316,36 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
298
316
  `Data section. Use ALL of it. Respect any "# TODO:" notes.`,
299
317
  ``,
300
318
  journey.rawMarkdown,
319
+ // ---- PER-FILE TASK (kept LAST so the big context above stays a stable,
320
+ // cache-reusable prefix across every file in this journey, and so the
321
+ // concrete task is the most recent thing the model reads) ----
322
+ ``,
323
+ ...(focus
324
+ ? [
325
+ `--- THIS FILE (the one to write now) ---`,
326
+ `This is ONE file in a larger suite for this journey.`,
327
+ `Cover ONLY this concern here:`,
328
+ ` ${focus.title}`,
329
+ ` ${focus.covers}`,
330
+ `Do NOT test the journey's other concerns here — separate files`,
331
+ `cover those. Go DEEP on this one concern (happy path + its own`,
332
+ `edge/negative/security cases) rather than broad-but-shallow.`,
333
+ `Use this exact describe() title: "${focus.title}".`,
334
+ ]
335
+ : []),
336
+ ...(testPath
337
+ ? [
338
+ ``,
339
+ `Save this file at EXACTLY: ${testPath}`,
340
+ `Compute every require()/import path (app source, helpers, the seed`,
341
+ `file, etc.) RELATIVE TO THAT EXACT location — count the directory`,
342
+ `depth from that path; do not assume a conventional depth. Getting`,
343
+ `the relative depth wrong breaks the whole file.`,
344
+ ]
345
+ : []),
346
+ ``,
347
+ `Now output ONLY the raw ${isJs ? 'JavaScript' : 'TypeScript'} test code`,
348
+ `for THIS file — the first character must be the first line of code.`,
301
349
  ].join('\n');
302
350
  }
303
351
  /**
@@ -20,6 +20,12 @@ export const DEEP_DOC_GUIDANCE = [
20
20
  ` vague mentions;`,
21
21
  `- concrete acceptance criteria per case (exactly what must hold).`,
22
22
  `Aim for the depth of a spec a new engineer could implement from.`,
23
+ `Every acceptance criterion you write becomes an ORACLE — tests are`,
24
+ `generated from it, and a product that disagrees is reported to the team`,
25
+ `as a defect. So state a criterion ONLY where the source actually fixes`,
26
+ `the behaviour. Anything you merely believe SHOULD hold goes under a`,
27
+ `"Suggested (not a requirement — do not assert)" heading, never into the`,
28
+ `acceptance criteria: an invented requirement becomes a false bug report.`,
23
29
  `Organise the body with numbered "## N. <Section>" headings, ONE`,
24
30
  `concern per section, because each section later becomes its own`,
25
31
  `focused test file — so the richer and better-separated the sections,`,
@@ -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
@@ -147,6 +147,16 @@ export function buildReferenceDiscoverPrompt(srcDir, docsDir, reference, journey
147
147
  `Rules:`,
148
148
  `- Base behavior on the reference and technical detail on real code;`,
149
149
  ` do not invent either with false confidence.`,
150
+ `- EVERY requirement you write becomes an ORACLE: tests will be`,
151
+ ` generated from it, and a product that disagrees will be reported to`,
152
+ ` the team as a defect. So write a requirement ONLY when the reference`,
153
+ ` actually states it. Do NOT add hardening, validation, limits, or`,
154
+ ` guards that the reference does not ask for, however sensible they`,
155
+ ` seem — an invented requirement becomes a false bug report and costs`,
156
+ ` the team a release.`,
157
+ `- When you believe something SHOULD hold but the reference does not say`,
158
+ ` so, put it under a "Suggested (not a requirement — do not assert)"`,
159
+ ` heading, never in Expected / Negative Cases / acceptance criteria.`,
150
160
  `- Where the reference is silent on a needed detail, add a "# TODO:"`,
151
161
  ` line describing what the developer must verify.`,
152
162
  `- Never include secrets — refer to env var names only.`,
@@ -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.14.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": {
@@ -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; }
@@ -73,11 +117,27 @@ function resolveEnv() {
73
117
 
74
118
  function sig(msg) {
75
119
  const m = strip(msg || '').replace(/\n/g, ' ');
76
- if (/403|whoami|invalid_grant|refresh token/i.test(m)) return 'IM auth (403)';
120
+ // Order matters, and each pattern has to be precise. A failing assertion
121
+ // carries plenty of incidental words — Playwright prints "Timeout: 5000ms"
122
+ // inside every expect() failure, and a status-code assertion legitimately
123
+ // mentions 403 — so loose patterns used to swallow most failures into
124
+ // "Timeout" / "IM auth" and made the whole triage useless.
125
+
126
+ // 1) A REAL timeout: the runner itself gave up, or the socket did.
127
+ if (/test timeout of \d+ ?ms exceeded|exceeded timeout of \d+ ?ms|timed out after \d+|ETIMEDOUT|ESOCKETTIMEDOUT|ECONNABORTED/i.test(m))
128
+ return 'Timeout';
129
+
130
+ // 2) An assertion that ran and disagreed — the common, useful case.
131
+ if (/expect\(.*\)|toBe\b|toEqual\b|toHaveCount\b|Received:|Expected:|expected the result to/i.test(m))
132
+ return 'Assertion mismatch';
133
+
134
+ // 3) Auth against the identity service — needs auth wording, not a bare 403.
135
+ if (/whoami|invalid_grant|refresh token|Unauthorized|Forbidden|invalid token|jwt (expired|malformed)/i.test(m))
136
+ return 'IM auth (403)';
137
+
77
138
  if (/\$gt|\$exists|injection/i.test(m)) return 'NoSQL injection accepted';
78
- if (/timeout|timed out|ETIMEDOUT/i.test(m)) return 'Timeout';
79
139
  if (/\b500\b|Internal Server/i.test(m)) return 'Server 500';
80
- if (/expect|toBe|toEqual|Received|Expected/i.test(m)) return 'Assertion mismatch';
140
+ if (/timeout|timed out/i.test(m)) return 'Timeout'; // looser fallback, last
81
141
  return 'Uncategorised';
82
142
  }
83
143
 
@@ -106,4 +166,4 @@ async function publish(category, suites, durationSec) {
106
166
  }
107
167
  }
108
168
 
109
- module.exports = { strip, sig, publish, gitInfo, resolveEnv };
169
+ 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) {