bmad-method-test-architecture-enterprise 3.0.2 → 3.2.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/cli/automate.js +7 -0
- package/cli/examples/gitlab-ci.yaml +1 -1
- package/cli/examples/pr-test-review.yml +3 -3
- package/cli/lib/automate-inventory.js +258 -25
- package/cli/lib/automate-prompt.js +34 -2
- package/cli/lib/automate-result.js +74 -2
- package/cli/lib/evaluate/check.js +44 -5
- package/cli/lib/evaluate/corpus-index.js +10 -1
- package/cli/lib/evaluate/discovery/candidate.js +699 -0
- package/cli/lib/evaluate/discovery/candidate.schema.json +563 -0
- package/cli/lib/evaluate/discovery/generator.js +954 -0
- package/cli/lib/evaluate/discovery/report.js +239 -0
- package/cli/lib/evaluate/discovery/review.js +569 -0
- package/cli/lib/unit-runners.js +219 -0
- package/cli/lib/vitest-attempts-reporter.mjs +30 -0
- package/cli/unit-check.js +595 -0
- package/package.json +16 -3
- package/skills/bmad-tea-automate/red/checklist.md +1 -1
- package/skills/bmad-tea-automate/resources/run-and-heal.md +3 -1
- package/skills/bmad-tea-evaluate/references/evaluator.md +2 -2
- package/skills/bmad-tea-evaluate/references/gaps.md +1 -1
- package/skills/bmad-tea-test-review/steps-c/step-03f-aggregate-scores.md +3 -3
- package/skills/bmod-tea/bmod.toml +1 -1
- package/skills/bmod-tea/knowledge/auth-session.md +25 -20
- package/skills/bmod-tea/knowledge/test-healing-patterns.md +2 -0
|
@@ -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
|
+
}
|