bmad-method-test-architecture-enterprise 3.1.0 → 3.3.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.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/cli/automate.js +7 -0
  3. package/cli/evaluate.js +20 -0
  4. package/cli/examples/gitlab-ci.yaml +1 -1
  5. package/cli/examples/pr-test-review.yml +3 -3
  6. package/cli/lib/agent-adapters.js +46 -0
  7. package/cli/lib/automate-inventory.js +258 -25
  8. package/cli/lib/automate-prompt.js +34 -2
  9. package/cli/lib/automate-result.js +74 -2
  10. package/cli/lib/evaluate/configuration-experiment.js +3401 -0
  11. package/cli/lib/evaluate/experiments.js +1299 -0
  12. package/cli/lib/evaluate/retain-skill-artifacts.js +78 -0
  13. package/cli/lib/evaluate/test-design-corpus/clean/docs/epics/epic-9-last-sync-indicator.md +47 -0
  14. package/cli/lib/evaluate/test-design-corpus/ground-truth.json +222 -0
  15. package/cli/lib/evaluate/test-design-corpus/native-identity.json +4 -0
  16. package/cli/lib/evaluate/test-design-corpus/seeded/docs/epics/epic-7-offline-order-capture.md +75 -0
  17. package/cli/lib/evaluate/test-design-replay.js +452 -0
  18. package/cli/lib/run-agent.js +18 -2
  19. package/cli/lib/test-design-staged-config.js +60 -0
  20. package/cli/lib/test-design-staging.js +229 -0
  21. package/cli/lib/unit-runners.js +219 -0
  22. package/cli/lib/vitest-attempts-reporter.mjs +30 -0
  23. package/cli/test-design-runner.js +245 -11
  24. package/cli/unit-check.js +595 -0
  25. package/package.json +13 -3
  26. package/skills/bmad-tea-automate/red/checklist.md +1 -1
  27. package/skills/bmad-tea-automate/resources/run-and-heal.md +3 -1
  28. package/skills/bmad-tea-evaluate/SKILL.md +11 -0
  29. package/skills/bmad-tea-evaluate/references/configuration-experiments.md +37 -0
  30. package/skills/bmad-tea-evaluate/references/evaluator.md +2 -2
  31. package/skills/bmad-tea-evaluate/references/gaps.md +1 -1
  32. package/skills/bmad-tea-test-review/steps-c/step-03f-aggregate-scores.md +3 -3
  33. package/skills/bmod-tea/bmod.toml +1 -1
  34. package/skills/bmod-tea/help/help.md +1 -1
  35. package/skills/bmod-tea/knowledge/auth-session.md +25 -20
  36. package/skills/bmod-tea/knowledge/test-healing-patterns.md +2 -0
@@ -0,0 +1,229 @@
1
+ /** Native Test Design staging, shared with the checkout harness. */
2
+ 'use strict';
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const { digest } = require('./evaluate/digest');
7
+ const { TEA_CONFIG_RELATIVE_PATH, TEA_KNOWLEDGE_PROMPT_LINE, stageTeaKnowledge, teaConfigToml } = require('./test-design-staged-config');
8
+ const PROJECT_ROOT = path.resolve(__dirname, '../..');
9
+ const FIXTURE_ROOT = path.join(__dirname, 'evaluate/test-design-corpus');
10
+ const GROUND_TRUTH = path.join(FIXTURE_ROOT, 'ground-truth.json');
11
+ const SKILL_ROOT = path.join(PROJECT_ROOT, 'skills/bmad-tea-test-design');
12
+ async function readText(file) {
13
+ try {
14
+ return { present: true, text: fs.readFileSync(file, 'utf8') };
15
+ } catch (error) {
16
+ if (error.code === 'ENOENT') return { present: false };
17
+ throw error;
18
+ }
19
+ }
20
+ async function writeText(file, text) {
21
+ fs.writeFileSync(file, text);
22
+ }
23
+ const GROUND_TRUTH_ONLY_TOKENS = [
24
+ 'materialRisks',
25
+ 'unsupportedRisks',
26
+ 'severityRank',
27
+ 'acceptableCoverage',
28
+ 'groundingQuote',
29
+ 'exclusionQuote',
30
+ 'maxRisks',
31
+ // The two keys that carry the matcher vocabulary itself. Neither string occurs in
32
+ // the staged skill tree or in either epic, so naming them costs nothing and closes
33
+ // the one channel through which the matchers could reach the agent.
34
+ 'anyOf',
35
+ 'exampleDescription',
36
+ ];
37
+ function projectRootOf(set) {
38
+ return set.projectRoot;
39
+ }
40
+
41
+ function designArtifactPaths(set) {
42
+ const design = path.posix.join(projectRootOf(set), 'test-artifacts', 'test-design', `test-design-epic-${set.epicNum}.md`);
43
+ return { design };
44
+ }
45
+
46
+ function filesUnder(root) {
47
+ const found = [];
48
+ const walk = (directory, prefix) => {
49
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
50
+ const absolute = path.join(directory, entry.name);
51
+ const relative = prefix ? path.join(prefix, entry.name) : entry.name;
52
+ if (entry.isDirectory()) walk(absolute, relative);
53
+ else if (entry.isFile()) found.push(relative);
54
+ }
55
+ };
56
+ if (fs.existsSync(root)) walk(root, '');
57
+ return found;
58
+ }
59
+
60
+ async function digestTree(root, relativePaths, io = {}) {
61
+ const parts = [];
62
+ for (const relative of relativePaths) {
63
+ const read = await (io.readText ?? readText)(path.join(root, relative));
64
+ // `fs.readFileSync` threw here and the behaviour is kept, named. Absence has
65
+ // to be loud: the alternative is decoding a null into the digest, so a file
66
+ // the walk found and the read did not would produce a digest that compares
67
+ // equal to nothing and reads as a mutation nobody can locate.
68
+ if (!read.present) {
69
+ throw new Error(`${relative} was listed under ${root} and could not be read, so the corpus digest cannot be taken`);
70
+ }
71
+ parts.push(`${relative}\0${read.text}`);
72
+ }
73
+ return digest(parts.join('\u0001'));
74
+ }
75
+
76
+ function configToml() {
77
+ return teaConfigToml({
78
+ header: ['# Written by the native Test Design fixture runner for one staged fixture set.'],
79
+ core: [
80
+ ['user_name', 'tea-eval-harness'],
81
+ ['project_name', 'field-service-platform'],
82
+ ['communication_language', 'English'],
83
+ ['document_output_language', 'English'],
84
+ ['output_folder', 'docs'],
85
+ ],
86
+ tea: [['test_artifacts', 'test-artifacts']],
87
+ });
88
+ }
89
+
90
+ async function corpusMutations(workspace, io = {}) {
91
+ const changed = (await digestTree(workspace.projectDir, workspace.corpusFiles, io)) === workspace.corpusDigest ? 0 : 1;
92
+ const added = filesUnder(workspace.projectDir).filter(
93
+ (relative) =>
94
+ !relative.startsWith(`test-artifacts${path.sep}`) &&
95
+ !relative.startsWith(`_bmad${path.sep}`) &&
96
+ !workspace.corpusFiles.includes(relative),
97
+ );
98
+ return changed + added.length;
99
+ }
100
+
101
+ async function stageWorkspace(set, io = {}) {
102
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tea-test-design-eval-'));
103
+ try {
104
+ return await stageIntoWorkspace(dir, set, io);
105
+ } catch (error) {
106
+ fs.rmSync(dir, { recursive: true, force: true });
107
+ throw error;
108
+ }
109
+ }
110
+
111
+ async function stageIntoWorkspace(dir, set, io = {}) {
112
+ const projectDir = path.join(dir, projectRootOf(set));
113
+ const setRoot = path.join(FIXTURE_ROOT, set.root);
114
+
115
+ for (const relative of filesUnder(setRoot)) {
116
+ const target = path.join(projectDir, relative);
117
+ fs.mkdirSync(path.dirname(target), { recursive: true });
118
+ fs.copyFileSync(path.join(setRoot, relative), target);
119
+ }
120
+ // The workflow writes its document and its progress checkpoint here. Neither set
121
+ // brings anything into this directory, so a run cannot inherit a checkpoint.
122
+ fs.mkdirSync(path.join(projectDir, 'test-artifacts'), { recursive: true });
123
+ fs.mkdirSync(path.join(projectDir, '_bmad'), { recursive: true });
124
+ await (io.writeText ?? writeText)(path.join(projectDir, TEA_CONFIG_RELATIVE_PATH), configToml());
125
+
126
+ for (const relative of filesUnder(SKILL_ROOT)) {
127
+ const target = path.join(dir, 'skill', relative);
128
+ fs.mkdirSync(path.dirname(target), { recursive: true });
129
+ fs.copyFileSync(path.join(SKILL_ROOT, relative), target);
130
+ }
131
+ stageTeaKnowledge(dir);
132
+
133
+ // The corpus files the run must leave alone. test-artifacts and _bmad are excluded
134
+ // because the run legitimately writes into the first and the harness wrote the second.
135
+ const corpusFiles = filesUnder(projectDir).filter(
136
+ (relative) => !relative.startsWith(`test-artifacts${path.sep}`) && !relative.startsWith(`_bmad${path.sep}`),
137
+ );
138
+ return { dir, projectDir, corpusFiles, corpusDigest: await digestTree(projectDir, corpusFiles, io) };
139
+ }
140
+
141
+ async function assertGroundTruthAbsent(dir, io = {}) {
142
+ const problems = [];
143
+ // Both existence checks are gone: each read answers absence itself. A staged
144
+ // file that vanished between the walk and the read used to contribute an empty
145
+ // string, which is the same silence one file at a time, and is reported now.
146
+ const groundTruthBytes = (await (io.readText ?? readText)(io.groundTruth ?? GROUND_TRUTH)).text;
147
+ for (const relative of filesUnder(dir)) {
148
+ if (path.basename(relative) === 'ground-truth.json') {
149
+ problems.push(`staged workspace contains ${relative}`);
150
+ continue;
151
+ }
152
+ const staged = await (io.readText ?? readText)(path.join(dir, relative));
153
+ if (!staged.present) {
154
+ problems.push(`staged file ${relative} disappeared between the walk and the read, so it was not checked`);
155
+ continue;
156
+ }
157
+ const text = staged.text;
158
+ if (groundTruthBytes && text === groundTruthBytes) {
159
+ problems.push(`staged file ${relative} carries the ground truth verbatim`);
160
+ continue;
161
+ }
162
+ for (const token of GROUND_TRUTH_ONLY_TOKENS) {
163
+ if (text.includes(token)) problems.push(`staged file ${relative} carries the ground-truth-only key "${token}"`);
164
+ }
165
+ }
166
+ return problems;
167
+ }
168
+
169
+ function buildPrompt(set, { designLevel = 'full' } = {}) {
170
+ const root = projectRootOf(set);
171
+ return [
172
+ `You are running the TEA workflow \`bmad-tea-test-design\` against the project in \`${root}/\`.`,
173
+ '',
174
+ 'The workflow is in `skill/`. Read `skill/instructions.md` first, then execute every step file it',
175
+ 'names in order, in full, without skipping or reordering. The step files are under `skill/steps-c/`.',
176
+ '',
177
+ '----- run configuration -----',
178
+ 'Take mode C (Create). Resolve the workflow placeholders to these values:',
179
+ '',
180
+ `- \`{project-root}\`: \`${root}\``,
181
+ `- TEA config (\`[core]\` and \`[modules.tea]\`): \`${root}/_bmad/config.toml\``,
182
+ `- \`{test_artifacts}\`: \`${root}/test-artifacts\``,
183
+ '- `{skill-root}`: `skill`',
184
+ TEA_KNOWLEDGE_PROMPT_LINE,
185
+ '- `mode`: `epic-level`',
186
+ '- `run_scope`: `epic`',
187
+ `- \`epic_num\`: \`${set.epicNum}\``,
188
+ `- \`run_key\`: \`epic-${set.epicNum}\``,
189
+ `- \`design_level\`: \`${designLevel}\``,
190
+ '- `tea_execution_mode`: `sequential`',
191
+ '- `tea_capability_probe`: `false`',
192
+ '',
193
+ `The epic is the one document under \`${root}/docs/epics/\`. It is the only requirements source for this`,
194
+ 'run; there is no PRD, no architecture document and no sprint status file, and none is missing.',
195
+ '',
196
+ '----- what to produce -----',
197
+ 'Write the one deliverable epic-level mode declares:',
198
+ '',
199
+ `- \`${root}/test-artifacts/test-design/test-design-epic-${set.epicNum}.md\`, from \`skill/test-design-template.md\`, carrying`,
200
+ ' the risk assessment and the test coverage plan the template lays out, with each risk row stating its',
201
+ ' category, probability, impact and score, and each coverage row naming its test level and the risk it',
202
+ ' covers.',
203
+ '',
204
+ `Do not add, edit, or delete any file under \`${root}/docs/\`. This workflow does not change its inputs,`,
205
+ 'and it does not generate tests.',
206
+ '',
207
+ 'Do not stop to ask a question. Every value the workflow would otherwise ask for is stated above.',
208
+ '',
209
+ 'When you are done, print one line naming the file you wrote. Nothing else you print is read.',
210
+ ].join('\n');
211
+ }
212
+
213
+ function caseIndex(sets) {
214
+ return sets.map((set) => ({ id: set.id, prompt: buildPrompt(set) }));
215
+ }
216
+ module.exports = {
217
+ TEA_CONFIG_RELATIVE_PATH,
218
+ corpusMutations,
219
+ projectRootOf,
220
+ designArtifactPaths,
221
+ filesUnder,
222
+ digestTree,
223
+ configToml,
224
+ stageWorkspace,
225
+ stageIntoWorkspace,
226
+ assertGroundTruthAbsent,
227
+ buildPrompt,
228
+ caseIndex,
229
+ };
@@ -0,0 +1,219 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { stripVTControlCharacters } = require('node:util');
6
+
7
+ // Runner identity comes from the project's own declarations, read before the
8
+ // agent runs. A report can claim any shape; it is accepted only for a runner
9
+ // the project declares.
10
+ const CONFIG_FILES = {
11
+ playwright: [
12
+ 'playwright.config.ts',
13
+ 'playwright.config.mts',
14
+ 'playwright.config.cts',
15
+ 'playwright.config.js',
16
+ 'playwright.config.mjs',
17
+ 'playwright.config.cjs',
18
+ ],
19
+ vitest: [
20
+ 'vitest.config.ts',
21
+ 'vitest.config.mts',
22
+ 'vitest.config.cts',
23
+ 'vitest.config.js',
24
+ 'vitest.config.mjs',
25
+ 'vitest.config.cjs',
26
+ 'vite.config.ts',
27
+ 'vite.config.mts',
28
+ 'vite.config.cts',
29
+ 'vite.config.js',
30
+ 'vite.config.mjs',
31
+ 'vite.config.cjs',
32
+ ],
33
+ jest: [
34
+ 'jest.config.ts',
35
+ 'jest.config.mts',
36
+ 'jest.config.cts',
37
+ 'jest.config.js',
38
+ 'jest.config.mjs',
39
+ 'jest.config.cjs',
40
+ 'jest.config.json',
41
+ ],
42
+ };
43
+ const PACKAGES = { playwright: '@playwright/test', vitest: 'vitest', jest: 'jest' };
44
+ const UNIT_RUNNERS = ['vitest', 'jest'];
45
+ const UNIT_CHECK_KIND = 'tea-unit-check';
46
+ const UNIT_STATUSES = { passed: 'passed', failed: 'failed', skipped: 'skipped', pending: 'skipped', todo: 'skipped', disabled: 'skipped' };
47
+
48
+ function readPackage(directory) {
49
+ const pkgPath = path.join(directory, 'package.json');
50
+ if (!fs.existsSync(pkgPath)) return {};
51
+ let pkg;
52
+ try {
53
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
54
+ } catch (error) {
55
+ throw new Error(`${pkgPath} is invalid JSON: ${error.message}`);
56
+ }
57
+ if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) throw new Error(`${pkgPath} must contain an object`);
58
+ return pkg;
59
+ }
60
+
61
+ // The directories above a workspace package up to its workspace or repository
62
+ // root, nearest first. A project outside any workspace or repository has none,
63
+ // so detection and the disposable copy never reach into unrelated parents.
64
+ const isWorkspaceRoot = (directory) =>
65
+ fs.existsSync(path.join(directory, 'pnpm-workspace.yaml')) || Object.hasOwn(readPackage(directory), 'workspaces');
66
+
67
+ function workspaceAncestors(projectRoot) {
68
+ const root = fs.realpathSync(projectRoot);
69
+ const boundary = (directory) => fs.existsSync(path.join(directory, '.git')) || isWorkspaceRoot(directory);
70
+ if (boundary(root)) return [];
71
+ const ancestors = [];
72
+ for (let directory = path.dirname(root); ; directory = path.dirname(directory)) {
73
+ ancestors.push(directory);
74
+ if (boundary(directory)) return ancestors;
75
+ if (path.dirname(directory) === directory) return [];
76
+ }
77
+ }
78
+
79
+ function detectRunners(projectRoot) {
80
+ const root = fs.realpathSync(projectRoot);
81
+ const pkg = readPackage(root);
82
+ const declared = { ...pkg.dependencies, ...pkg.devDependencies };
83
+ // A workspace package may rely on a runner its workspace root declares.
84
+ const hoisted = Object.assign(
85
+ {},
86
+ ...workspaceAncestors(root).map((directory) => {
87
+ const ancestor = readPackage(directory);
88
+ return { ...ancestor.dependencies, ...ancestor.devDependencies };
89
+ }),
90
+ );
91
+ const runners = [];
92
+ for (const runner of Object.keys(PACKAGES)) {
93
+ // Vite's own config names Vitest only when the project declares Vitest.
94
+ const candidates = CONFIG_FILES[runner].filter(
95
+ (file) => !(runner === 'vitest' && file.startsWith('vite.') && !Object.hasOwn(declared, 'vitest')),
96
+ );
97
+ let configFile = candidates.find((file) => fs.existsSync(path.join(root, file))) ?? null;
98
+ if (!configFile && runner === 'jest' && Object.hasOwn(pkg, 'jest')) configFile = 'package.json';
99
+ if (configFile || Object.hasOwn(declared, PACKAGES[runner]) || (runner !== 'playwright' && Object.hasOwn(hoisted, PACKAGES[runner])))
100
+ runners.push({ runner, configFile });
101
+ }
102
+ return runners;
103
+ }
104
+
105
+ function unitReportRunner(report) {
106
+ if (!report || typeof report !== 'object' || Array.isArray(report) || !Array.isArray(report.testResults)) return null;
107
+ // Jest's aggregated result always carries these; Vitest's JSON reporter never does.
108
+ return Object.hasOwn(report, 'numRuntimeErrorTestSuites') || Object.hasOwn(report, 'wasInterrupted') ? 'jest' : 'vitest';
109
+ }
110
+
111
+ const plain = (value) => stripVTControlCharacters(String(value ?? ''));
112
+
113
+ // One failure message: an assertion is the intended red reason; a timeout is a
114
+ // runner error like Playwright's timedOut; anything else is a wrong-reason failure.
115
+ function failureKind(message, detail) {
116
+ const text = plain(message);
117
+ if (/\bTest timed out in \d+ ?ms\b|\bExceeded timeout of \d+ ?ms\b/.test(text)) return 'timeout';
118
+ if (detail?.matcherResult || /^(?:\w*AssertionError)\b/.test(text) || /(?:^|\n)(?:Error: )?expect\(/.test(text)) return 'assertion';
119
+ return 'error';
120
+ }
121
+
122
+ // One failed attempt can carry several messages (a hook error after an assertion).
123
+ // It counts as an assertion failure only when every message is one.
124
+ function attemptFailure(messages, details = []) {
125
+ const kinds = messages.map((message, index) => failureKind(message, details[index]));
126
+ if (kinds.includes('timeout')) return 'timeout';
127
+ return kinds.length > 0 && kinds.every((kind) => kind === 'assertion') ? 'assertion' : 'error';
128
+ }
129
+
130
+ function normalizeUnitReport(report) {
131
+ const runner = unitReportRunner(report);
132
+ if (!runner) throw new Error('execution report is not a Vitest or Jest JSON report');
133
+ const label = runner === 'jest' ? 'Jest' : 'Vitest';
134
+ const files = [];
135
+ let errors = 0;
136
+ let failedTests = 0;
137
+ for (const result of report.testResults) {
138
+ if (!result || typeof result !== 'object' || typeof result.name !== 'string' || !path.isAbsolute(result.name))
139
+ throw new Error(`native ${label} result lacks an absolute test file path`);
140
+ if (!Array.isArray(result.assertionResults)) throw new Error(`native ${label} result lacks assertion results: ${result.name}`);
141
+ const message = plain(result.message).trim();
142
+ // Vitest leaves message empty unless the file itself failed (load, hook or
143
+ // collection error). Jest concatenates test failures there, so a Jest file
144
+ // error is a failed file with no test results or its suite-failure banner.
145
+ const fileError =
146
+ runner === 'vitest'
147
+ ? message || null
148
+ : (result.status === 'failed' && result.assertionResults.length === 0) || /● Test suite failed to run/.test(message)
149
+ ? message || 'test suite failed to run'
150
+ : null;
151
+ if (fileError) errors++;
152
+ const tests = [];
153
+ for (const assertion of result.assertionResults) {
154
+ if (
155
+ !assertion ||
156
+ typeof assertion.title !== 'string' ||
157
+ !Array.isArray(assertion.ancestorTitles) ||
158
+ assertion.ancestorTitles.some((title) => typeof title !== 'string')
159
+ )
160
+ throw new Error(`native ${label} result has a malformed test identity: ${result.name}`);
161
+ const status = UNIT_STATUSES[assertion.status];
162
+ if (!status) throw new Error(`execution report has unsupported attempt status: ${assertion.status}`);
163
+ const messages = Array.isArray(assertion.failureMessages) ? assertion.failureMessages.map(plain) : [];
164
+ const details = Array.isArray(assertion.failureDetails) ? assertion.failureDetails : [];
165
+ const attempts = [];
166
+ if (runner === 'jest') {
167
+ // Jest records retry reasons only with logErrorsBeforeRetry; invocations always counts the attempts.
168
+ const reasons = assertion.retryReasons ?? [];
169
+ if (!Array.isArray(reasons)) throw new Error(`native Jest result has malformed retry reasons: ${result.name}`);
170
+ const invocations = assertion.invocations ?? 1;
171
+ if (!Number.isSafeInteger(invocations) || invocations < 1)
172
+ throw new Error(`native Jest result has malformed invocations: ${result.name}`);
173
+ for (let index = 0; index < Math.max(invocations - 1, reasons.length); index++)
174
+ attempts.push({ status: 'failed', failure: reasons[index] === undefined ? 'error' : failureKind(plain(reasons[index])) });
175
+ attempts.push({ status, failure: status === 'failed' ? attemptFailure(messages, details) : null });
176
+ } else if (status === 'passed') {
177
+ // Vitest keeps a retried test's earlier failures in failureMessages of its final pass.
178
+ for (const message of messages) attempts.push({ status: 'failed', failure: failureKind(message) });
179
+ attempts.push({ status, failure: null });
180
+ } else if (status === 'failed') {
181
+ // tea-unit-check runs Vitest with --retry=0 and reports any retry or repeat
182
+ // it still observes as a run error, so a failed test is one attempt and its
183
+ // messages (soft assertions, hook errors) belong to it.
184
+ attempts.push({ status, failure: attemptFailure(messages) });
185
+ } else attempts.push({ status, failure: null });
186
+ if (status === 'failed') failedTests++;
187
+ tests.push({
188
+ titlePath: [...assertion.ancestorTitles, assertion.title],
189
+ line: assertion.location?.line,
190
+ column: assertion.location?.column,
191
+ message: messages.join('\n') || null,
192
+ attempts,
193
+ });
194
+ }
195
+ files.push({ name: result.name, error: fileError, tests });
196
+ }
197
+ if (runner === 'jest') {
198
+ const runtime = Number.isSafeInteger(report.numRuntimeErrorTestSuites) ? report.numRuntimeErrorTestSuites : 0;
199
+ errors = Math.max(errors, runtime);
200
+ if (report.wasInterrupted === true) errors++;
201
+ }
202
+ // A failed run with no failed test or file names an error outside every
203
+ // test: an unhandled rejection, a coverage threshold, a global setup failure.
204
+ if (report.success === false && failedTests === 0 && errors === 0) errors++;
205
+ return { runner, files, errors };
206
+ }
207
+
208
+ module.exports = {
209
+ CONFIG_FILES,
210
+ UNIT_RUNNERS,
211
+ UNIT_CHECK_KIND,
212
+ detectRunners,
213
+ workspaceAncestors,
214
+ isWorkspaceRoot,
215
+ unitReportRunner,
216
+ normalizeUnitReport,
217
+ failureKind,
218
+ attemptFailure,
219
+ };
@@ -0,0 +1,30 @@
1
+ // A Vitest reporter tea-unit-check adds beside the JSON reporter. Vitest's JSON
2
+ // keeps one result per test even when a retry or repeat ran it several times,
3
+ // so this records each test's actual retry and repeat counts. It imports
4
+ // nothing, so it works with whichever Vitest the project installs.
5
+ import fs from 'node:fs';
6
+
7
+ export default class AttemptsReporter {
8
+ constructor() {
9
+ this.tests = [];
10
+ }
11
+
12
+ // The reporter API (Vitest 3 or newer) gives a test that ran its diagnostic; a
13
+ // skipped test never ran, so it has none and made no attempt. A test that ran
14
+ // without one records null, which tea-unit-check treats as unknown attempts.
15
+ onTestCaseResult(testCase) {
16
+ const diagnostic = typeof testCase.diagnostic === 'function' ? testCase.diagnostic() : undefined;
17
+ const skipped = testCase.result?.()?.state === 'skipped';
18
+ this.tests.push({
19
+ moduleId: testCase.module?.moduleId ?? null,
20
+ fullName: testCase.fullName ?? null,
21
+ retryCount: diagnostic ? diagnostic.retryCount : skipped ? 0 : null,
22
+ repeatCount: diagnostic ? diagnostic.repeatCount : skipped ? 0 : null,
23
+ });
24
+ }
25
+
26
+ onTestRunEnd() {
27
+ const output = process.env.TEA_UNIT_CHECK_ATTEMPTS;
28
+ if (output) fs.writeFileSync(output, JSON.stringify(this.tests));
29
+ }
30
+ }