@test-station/core 0.1.7 → 0.2.10
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/package.json +7 -7
- package/src/artifacts.js +34 -0
- package/src/console.js +86 -0
- package/src/index.js +1 -1
- package/src/policy.js +230 -0
- package/src/report.js +29 -3
- package/src/run-report.js +260 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@test-station/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
".": "./src/index.js"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@test-station/adapter-jest": "0.
|
|
13
|
-
"@test-station/adapter-node-test": "0.
|
|
14
|
-
"@test-station/adapter-playwright": "0.
|
|
15
|
-
"@test-station/adapter-shell": "0.
|
|
16
|
-
"@test-station/adapter-vitest": "0.
|
|
17
|
-
"@test-station/plugin-source-analysis": "0.
|
|
12
|
+
"@test-station/adapter-jest": "0.2.10",
|
|
13
|
+
"@test-station/adapter-node-test": "0.2.10",
|
|
14
|
+
"@test-station/adapter-playwright": "0.2.10",
|
|
15
|
+
"@test-station/adapter-shell": "0.2.10",
|
|
16
|
+
"@test-station/adapter-vitest": "0.2.10",
|
|
17
|
+
"@test-station/plugin-source-analysis": "0.2.10"
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
20
|
"build": "node ../../scripts/check-package.mjs ./src/index.js",
|
package/src/artifacts.js
CHANGED
|
@@ -7,6 +7,10 @@ export function writeReportArtifacts(context, report, suiteResults) {
|
|
|
7
7
|
|
|
8
8
|
const reportJsonPath = path.join(context.project.outputDir, 'report.json');
|
|
9
9
|
fs.writeFileSync(reportJsonPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
10
|
+
const modulesJsonPath = path.join(context.project.outputDir, 'modules.json');
|
|
11
|
+
fs.writeFileSync(modulesJsonPath, `${JSON.stringify(createModulesArtifact(report), null, 2)}\n`);
|
|
12
|
+
const ownershipJsonPath = path.join(context.project.outputDir, 'ownership.json');
|
|
13
|
+
fs.writeFileSync(ownershipJsonPath, `${JSON.stringify(createOwnershipArtifact(report), null, 2)}\n`);
|
|
10
14
|
|
|
11
15
|
const rawSuitePaths = [];
|
|
12
16
|
for (const suite of suiteResults) {
|
|
@@ -21,6 +25,7 @@ export function writeReportArtifacts(context, report, suiteResults) {
|
|
|
21
25
|
summary: suite.summary,
|
|
22
26
|
coverage: suite.coverage,
|
|
23
27
|
warnings: suite.warnings,
|
|
28
|
+
diagnostics: suite.diagnostics || null,
|
|
24
29
|
rawArtifacts: suite.rawArtifacts,
|
|
25
30
|
output: suite.output,
|
|
26
31
|
tests: suite.tests,
|
|
@@ -46,10 +51,39 @@ export function writeReportArtifacts(context, report, suiteResults) {
|
|
|
46
51
|
|
|
47
52
|
return {
|
|
48
53
|
reportJsonPath,
|
|
54
|
+
modulesJsonPath,
|
|
55
|
+
ownershipJsonPath,
|
|
49
56
|
rawSuitePaths,
|
|
50
57
|
};
|
|
51
58
|
}
|
|
52
59
|
|
|
60
|
+
function createModulesArtifact(report) {
|
|
61
|
+
return {
|
|
62
|
+
schemaVersion: '1',
|
|
63
|
+
generatedAt: report?.generatedAt || new Date().toISOString(),
|
|
64
|
+
projectName: report?.meta?.projectName || null,
|
|
65
|
+
modules: Array.isArray(report?.modules) ? report.modules : [],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createOwnershipArtifact(report) {
|
|
70
|
+
const modules = Array.isArray(report?.modules) ? report.modules : [];
|
|
71
|
+
return {
|
|
72
|
+
schemaVersion: '1',
|
|
73
|
+
generatedAt: report?.generatedAt || new Date().toISOString(),
|
|
74
|
+
projectName: report?.meta?.projectName || null,
|
|
75
|
+
modules: modules.map((moduleEntry) => ({
|
|
76
|
+
module: moduleEntry.module,
|
|
77
|
+
owner: moduleEntry.owner || null,
|
|
78
|
+
})),
|
|
79
|
+
themes: modules.flatMap((moduleEntry) => (Array.isArray(moduleEntry.themes) ? moduleEntry.themes : []).map((themeEntry) => ({
|
|
80
|
+
module: moduleEntry.module,
|
|
81
|
+
theme: themeEntry.theme,
|
|
82
|
+
owner: themeEntry.owner || null,
|
|
83
|
+
}))),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
53
87
|
function copyArtifactSource(sourcePath, destinationPath, kind) {
|
|
54
88
|
const sourceStat = fs.statSync(sourcePath);
|
|
55
89
|
if (kind === 'directory' || sourceStat.isDirectory()) {
|
package/src/console.js
CHANGED
|
@@ -47,6 +47,17 @@ export function createConsoleProgressReporter(options = {}) {
|
|
|
47
47
|
return;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
if (event.type === 'suite-diagnostics-start') {
|
|
51
|
+
writeLine(stream, ` diagnostics: running ${event.diagnosticsLabel || 'diagnostics rerun'}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (event.type === 'suite-diagnostics-complete') {
|
|
56
|
+
const result = event.result || {};
|
|
57
|
+
writeLine(stream, ` diagnostics: ${formatStatus(result.status)} ${formatDuration(result.durationMs || 0)} ${result.command || ''}`.trimEnd());
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
if (event.type === 'package-complete') {
|
|
51
62
|
writeLine(
|
|
52
63
|
stream,
|
|
@@ -73,6 +84,11 @@ export function formatConsoleSummary(report, artifactPaths = {}, options = {}) {
|
|
|
73
84
|
lines.push(coverageLine);
|
|
74
85
|
}
|
|
75
86
|
|
|
87
|
+
const policyLine = formatPolicyLine(report?.summary?.policy);
|
|
88
|
+
if (policyLine) {
|
|
89
|
+
lines.push(policyLine);
|
|
90
|
+
}
|
|
91
|
+
|
|
76
92
|
lines.push(`Duration: ${formatDuration(report?.durationMs || report?.summary?.durationMs || 0)}`);
|
|
77
93
|
if (options.htmlPath) {
|
|
78
94
|
lines.push(`HTML report: ${options.htmlPath}`);
|
|
@@ -100,6 +116,15 @@ export function formatConsoleSummary(report, artifactPaths = {}, options = {}) {
|
|
|
100
116
|
lines.push(Number.isFinite(lineCoverage) ? `${prefix} L ${lineCoverage.toFixed(2)}%` : prefix);
|
|
101
117
|
}
|
|
102
118
|
|
|
119
|
+
const modules = Array.isArray(report?.modules) ? report.modules : [];
|
|
120
|
+
if (modules.length > 0) {
|
|
121
|
+
lines.push('-'.repeat(SECTION_WIDTH));
|
|
122
|
+
lines.push('Modules');
|
|
123
|
+
for (const moduleEntry of modules) {
|
|
124
|
+
lines.push(formatModuleLine(moduleEntry));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
103
128
|
lines.push('='.repeat(SECTION_WIDTH));
|
|
104
129
|
return `${lines.join('\n')}\n`;
|
|
105
130
|
}
|
|
@@ -123,16 +148,77 @@ function formatCoverageLine(coverage) {
|
|
|
123
148
|
return `Coverage: ${metrics.map(([label, pct]) => `${label} ${pct.toFixed(2)}%`).join(' | ')}`;
|
|
124
149
|
}
|
|
125
150
|
|
|
151
|
+
function formatPolicyLine(policy) {
|
|
152
|
+
const metrics = [];
|
|
153
|
+
if (Number.isFinite(policy?.failedThresholds) && policy.failedThresholds > 0) {
|
|
154
|
+
metrics.push(`threshold failures ${policy.failedThresholds}`);
|
|
155
|
+
}
|
|
156
|
+
if (Number.isFinite(policy?.warningThresholds) && policy.warningThresholds > 0) {
|
|
157
|
+
metrics.push(`threshold warnings ${policy.warningThresholds}`);
|
|
158
|
+
}
|
|
159
|
+
if (Number.isFinite(policy?.diagnosticsSuites) && policy.diagnosticsSuites > 0) {
|
|
160
|
+
metrics.push(`diagnostic reruns ${policy.diagnosticsSuites}`);
|
|
161
|
+
}
|
|
162
|
+
if (Number.isFinite(policy?.failedDiagnostics) && policy.failedDiagnostics > 0) {
|
|
163
|
+
metrics.push(`failed diagnostics ${policy.failedDiagnostics}`);
|
|
164
|
+
}
|
|
165
|
+
if (metrics.length === 0) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
return `Policy: ${metrics.join(' | ')}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
126
171
|
function formatSummaryInline(summary) {
|
|
127
172
|
return `tests ${summary.total || 0} | pass ${summary.passed || 0} | fail ${summary.failed || 0} | skip ${summary.skipped || 0}`;
|
|
128
173
|
}
|
|
129
174
|
|
|
130
175
|
function formatStatus(status) {
|
|
131
176
|
if (status === 'failed') return 'FAIL';
|
|
177
|
+
if (status === 'warn') return 'WARN';
|
|
132
178
|
if (status === 'skipped') return 'SKIP';
|
|
133
179
|
return 'PASS';
|
|
134
180
|
}
|
|
135
181
|
|
|
182
|
+
function formatModuleLine(moduleEntry) {
|
|
183
|
+
const status = resolveModuleStatus(moduleEntry);
|
|
184
|
+
const base = [
|
|
185
|
+
formatStatus(status).padEnd(5),
|
|
186
|
+
String(moduleEntry?.module || 'uncategorized').padEnd(20),
|
|
187
|
+
formatDuration(moduleEntry?.durationMs || 0),
|
|
188
|
+
formatSummaryInline(moduleEntry?.summary || zeroSummary()),
|
|
189
|
+
].join(' ');
|
|
190
|
+
const details = [];
|
|
191
|
+
|
|
192
|
+
const lineCoverage = moduleEntry?.coverage?.lines?.pct;
|
|
193
|
+
if (Number.isFinite(lineCoverage)) {
|
|
194
|
+
details.push(`L ${lineCoverage.toFixed(2)}%`);
|
|
195
|
+
}
|
|
196
|
+
if (moduleEntry?.owner) {
|
|
197
|
+
details.push(`owner ${moduleEntry.owner}`);
|
|
198
|
+
}
|
|
199
|
+
if (moduleEntry?.threshold?.configured) {
|
|
200
|
+
details.push(`threshold ${moduleEntry.threshold.status}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return details.length > 0 ? `${base} ${details.join(' | ')}` : base;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function resolveModuleStatus(moduleEntry) {
|
|
207
|
+
if (moduleEntry?.threshold?.status === 'failed') {
|
|
208
|
+
return 'failed';
|
|
209
|
+
}
|
|
210
|
+
if (moduleEntry?.threshold?.status === 'warn') {
|
|
211
|
+
return 'warn';
|
|
212
|
+
}
|
|
213
|
+
if ((moduleEntry?.summary?.failed || 0) > 0) {
|
|
214
|
+
return 'failed';
|
|
215
|
+
}
|
|
216
|
+
if ((moduleEntry?.summary?.total || 0) === 0 && !moduleEntry?.coverage) {
|
|
217
|
+
return 'skipped';
|
|
218
|
+
}
|
|
219
|
+
return 'passed';
|
|
220
|
+
}
|
|
221
|
+
|
|
136
222
|
function formatDuration(durationMs) {
|
|
137
223
|
const totalSeconds = Math.max(0, Math.round((durationMs || 0) / 1000));
|
|
138
224
|
const hours = Math.floor(totalSeconds / 3600);
|
package/src/index.js
CHANGED
|
@@ -2,7 +2,7 @@ export { CONFIG_SCHEMA_VERSION, REPORT_SCHEMA_VERSION, defineConfig, loadConfig,
|
|
|
2
2
|
export { createPhase1ScaffoldReport, createSummary, normalizeTestResult, normalizeSuiteResult, buildReportFromSuiteResults } from './report.js';
|
|
3
3
|
export { createCoverageMetric, normalizeCoverageSummary, mergeCoverageSummaries } from './coverage.js';
|
|
4
4
|
export { resolveAdapterForSuite } from './adapters.js';
|
|
5
|
-
export { preparePolicyContext, applyPolicyPipeline, collectCoverageAttribution, lookupOwner } from './policy.js';
|
|
5
|
+
export { preparePolicyContext, applyPolicyPipeline, collectCoverageAttribution, lookupOwner, evaluateCoverageThresholds } from './policy.js';
|
|
6
6
|
export { writeReportArtifacts } from './artifacts.js';
|
|
7
7
|
export { formatConsoleSummary, createConsoleProgressReporter } from './console.js';
|
|
8
8
|
export { runReport } from './run-report.js';
|
package/src/policy.js
CHANGED
|
@@ -21,6 +21,73 @@ export async function preparePolicyContext(loadedConfig, project) {
|
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export function evaluateCoverageThresholds(policy, modules, options = {}) {
|
|
25
|
+
const thresholdManifest = policy?.manifests?.thresholds?.thresholds || {};
|
|
26
|
+
const moduleRules = buildThresholdRuleMap(thresholdManifest.modules || [], 'module');
|
|
27
|
+
const themeRules = buildThresholdRuleMap(thresholdManifest.themes || [], 'theme');
|
|
28
|
+
const coverageEnabled = options.coverageEnabled !== false;
|
|
29
|
+
const violations = [];
|
|
30
|
+
let configuredRules = moduleRules.size + themeRules.size;
|
|
31
|
+
let evaluatedRules = 0;
|
|
32
|
+
let passedRules = 0;
|
|
33
|
+
let failedRules = 0;
|
|
34
|
+
let warningRules = 0;
|
|
35
|
+
|
|
36
|
+
const annotatedModules = (Array.isArray(modules) ? modules : []).map((moduleEntry) => {
|
|
37
|
+
const moduleRule = moduleRules.get(moduleEntry.module) || null;
|
|
38
|
+
const evaluatedModuleThreshold = evaluateThresholdRule(coverageEnabled ? moduleEntry.coverage : null, moduleRule, {
|
|
39
|
+
scopeType: 'module',
|
|
40
|
+
module: moduleEntry.module,
|
|
41
|
+
theme: null,
|
|
42
|
+
label: moduleEntry.module,
|
|
43
|
+
});
|
|
44
|
+
if (evaluatedModuleThreshold && evaluatedModuleThreshold.status !== 'skipped') {
|
|
45
|
+
evaluatedRules += 1;
|
|
46
|
+
if (evaluatedModuleThreshold.status === 'failed') failedRules += 1;
|
|
47
|
+
if (evaluatedModuleThreshold.status === 'warn') warningRules += 1;
|
|
48
|
+
if (evaluatedModuleThreshold.status === 'passed') passedRules += 1;
|
|
49
|
+
violations.push(...evaluatedModuleThreshold.violations);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
...moduleEntry,
|
|
54
|
+
threshold: evaluatedModuleThreshold,
|
|
55
|
+
themes: (Array.isArray(moduleEntry.themes) ? moduleEntry.themes : []).map((themeEntry) => {
|
|
56
|
+
const themeRule = themeRules.get(`${moduleEntry.module}/${themeEntry.theme}`) || null;
|
|
57
|
+
const evaluatedThemeThreshold = evaluateThresholdRule(coverageEnabled ? themeEntry.coverage : null, themeRule, {
|
|
58
|
+
scopeType: 'theme',
|
|
59
|
+
module: moduleEntry.module,
|
|
60
|
+
theme: themeEntry.theme,
|
|
61
|
+
label: `${moduleEntry.module} / ${themeEntry.theme}`,
|
|
62
|
+
});
|
|
63
|
+
if (evaluatedThemeThreshold && evaluatedThemeThreshold.status !== 'skipped') {
|
|
64
|
+
evaluatedRules += 1;
|
|
65
|
+
if (evaluatedThemeThreshold.status === 'failed') failedRules += 1;
|
|
66
|
+
if (evaluatedThemeThreshold.status === 'warn') warningRules += 1;
|
|
67
|
+
if (evaluatedThemeThreshold.status === 'passed') passedRules += 1;
|
|
68
|
+
violations.push(...evaluatedThemeThreshold.violations);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
...themeEntry,
|
|
72
|
+
threshold: evaluatedThemeThreshold,
|
|
73
|
+
};
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
modules: annotatedModules,
|
|
80
|
+
summary: {
|
|
81
|
+
totalRules: configuredRules,
|
|
82
|
+
evaluatedRules,
|
|
83
|
+
passedRules,
|
|
84
|
+
failedRules,
|
|
85
|
+
warningRules,
|
|
86
|
+
violations,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
24
91
|
export async function applyPolicyPipeline(context, suiteResults) {
|
|
25
92
|
if (!context?.policy) {
|
|
26
93
|
return suiteResults;
|
|
@@ -370,6 +437,7 @@ function loadPolicyManifests(config, configDir) {
|
|
|
370
437
|
classification: loadManifestEntry(resolveManifestPath(config, 'classification'), configDir, cache),
|
|
371
438
|
coverageAttribution: loadManifestEntry(resolveManifestPath(config, 'coverageAttribution'), configDir, cache),
|
|
372
439
|
ownership: loadManifestEntry(resolveManifestPath(config, 'ownership'), configDir, cache),
|
|
440
|
+
thresholds: loadManifestEntry(resolveManifestPath(config, 'thresholds'), configDir, cache),
|
|
373
441
|
};
|
|
374
442
|
}
|
|
375
443
|
|
|
@@ -405,6 +473,10 @@ function loadManifestEntry(manifestPath, configDir, cache) {
|
|
|
405
473
|
modules: Array.isArray(payload.ownership?.modules) ? payload.ownership.modules : [],
|
|
406
474
|
themes: Array.isArray(payload.ownership?.themes) ? payload.ownership.themes : [],
|
|
407
475
|
},
|
|
476
|
+
thresholds: {
|
|
477
|
+
modules: Array.isArray(payload.thresholds?.modules) ? payload.thresholds.modules : [],
|
|
478
|
+
themes: Array.isArray(payload.thresholds?.themes) ? payload.thresholds.themes : [],
|
|
479
|
+
},
|
|
408
480
|
raw: payload,
|
|
409
481
|
};
|
|
410
482
|
cache.set(resolved, manifest);
|
|
@@ -534,6 +606,164 @@ function getPackageRelativeCandidates(context, suite, filePath) {
|
|
|
534
606
|
return dedupe(candidates);
|
|
535
607
|
}
|
|
536
608
|
|
|
609
|
+
function buildThresholdRuleMap(entries, scopeType) {
|
|
610
|
+
const ruleMap = new Map();
|
|
611
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
612
|
+
const normalized = normalizeThresholdRule(entry, scopeType);
|
|
613
|
+
if (!normalized) {
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
ruleMap.set(normalized.key, normalized);
|
|
617
|
+
}
|
|
618
|
+
return ruleMap;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function normalizeThresholdRule(entry, scopeType) {
|
|
622
|
+
if (!entry || typeof entry !== 'object') {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
const moduleName = typeof entry.module === 'string' && entry.module.trim().length > 0
|
|
626
|
+
? entry.module.trim()
|
|
627
|
+
: null;
|
|
628
|
+
const themeName = typeof entry.theme === 'string' && entry.theme.trim().length > 0
|
|
629
|
+
? entry.theme.trim()
|
|
630
|
+
: null;
|
|
631
|
+
|
|
632
|
+
if (!moduleName) {
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
if (scopeType === 'theme' && !themeName) {
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const metrics = normalizeThresholdMetrics(entry.coverage || entry.minimums || entry.thresholds || {});
|
|
640
|
+
if (metrics.length === 0) {
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const enforcement = entry.enforcement === 'warn' || entry.severity === 'warn'
|
|
645
|
+
? 'warn'
|
|
646
|
+
: 'error';
|
|
647
|
+
|
|
648
|
+
return {
|
|
649
|
+
key: scopeType === 'theme' ? `${moduleName}/${themeName}` : moduleName,
|
|
650
|
+
module: moduleName,
|
|
651
|
+
theme: themeName,
|
|
652
|
+
scopeType,
|
|
653
|
+
enforcement,
|
|
654
|
+
reason: typeof entry.reason === 'string' && entry.reason.trim().length > 0
|
|
655
|
+
? entry.reason.trim()
|
|
656
|
+
: null,
|
|
657
|
+
metrics,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function normalizeThresholdMetrics(source) {
|
|
662
|
+
const metrics = [];
|
|
663
|
+
for (const metricKey of ['lines', 'branches', 'functions', 'statements']) {
|
|
664
|
+
const minPct = resolveThresholdMetric(source, metricKey);
|
|
665
|
+
if (!Number.isFinite(minPct)) {
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
metrics.push({
|
|
669
|
+
metric: metricKey,
|
|
670
|
+
minPct: Number(Number(minPct).toFixed(2)),
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
return metrics;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function resolveThresholdMetric(source, metricKey) {
|
|
677
|
+
if (!source || typeof source !== 'object') {
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (Number.isFinite(source[`${metricKey}Pct`])) {
|
|
682
|
+
return source[`${metricKey}Pct`];
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (Number.isFinite(source[metricKey])) {
|
|
686
|
+
return source[metricKey];
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const nested = source[metricKey];
|
|
690
|
+
if (!nested || typeof nested !== 'object') {
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
for (const key of ['minPct', 'minimum', 'pct', 'threshold']) {
|
|
695
|
+
if (Number.isFinite(nested[key])) {
|
|
696
|
+
return nested[key];
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function evaluateThresholdRule(coverage, rule, scope) {
|
|
704
|
+
if (!rule) {
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const metrics = rule.metrics.map((metric) => {
|
|
709
|
+
const actualPct = coverage?.[metric.metric]?.pct;
|
|
710
|
+
return {
|
|
711
|
+
metric: metric.metric,
|
|
712
|
+
minPct: metric.minPct,
|
|
713
|
+
actualPct: Number.isFinite(actualPct) ? Number(Number(actualPct).toFixed(2)) : null,
|
|
714
|
+
passed: Number.isFinite(actualPct) && actualPct >= metric.minPct,
|
|
715
|
+
};
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
if (!metrics.some((metric) => Number.isFinite(metric.actualPct))) {
|
|
719
|
+
return {
|
|
720
|
+
configured: true,
|
|
721
|
+
scopeType: scope.scopeType,
|
|
722
|
+
module: scope.module,
|
|
723
|
+
theme: scope.theme,
|
|
724
|
+
label: scope.label,
|
|
725
|
+
enforcement: rule.enforcement,
|
|
726
|
+
reason: rule.reason,
|
|
727
|
+
status: 'skipped',
|
|
728
|
+
metrics,
|
|
729
|
+
violations: [],
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const failures = metrics.filter((metric) => !metric.passed);
|
|
734
|
+
const status = failures.length === 0
|
|
735
|
+
? 'passed'
|
|
736
|
+
: (rule.enforcement === 'warn' ? 'warn' : 'failed');
|
|
737
|
+
|
|
738
|
+
return {
|
|
739
|
+
configured: true,
|
|
740
|
+
scopeType: scope.scopeType,
|
|
741
|
+
module: scope.module,
|
|
742
|
+
theme: scope.theme,
|
|
743
|
+
label: scope.label,
|
|
744
|
+
enforcement: rule.enforcement,
|
|
745
|
+
reason: rule.reason,
|
|
746
|
+
status,
|
|
747
|
+
metrics,
|
|
748
|
+
violations: failures.map((metric) => ({
|
|
749
|
+
scopeType: scope.scopeType,
|
|
750
|
+
module: scope.module,
|
|
751
|
+
theme: scope.theme,
|
|
752
|
+
label: scope.label,
|
|
753
|
+
enforcement: rule.enforcement,
|
|
754
|
+
metric: metric.metric,
|
|
755
|
+
minPct: metric.minPct,
|
|
756
|
+
actualPct: metric.actualPct,
|
|
757
|
+
message: formatThresholdViolation(scope.label, metric),
|
|
758
|
+
})),
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function formatThresholdViolation(label, metric) {
|
|
763
|
+
const actual = Number.isFinite(metric.actualPct) ? `${metric.actualPct.toFixed(2)}%` : 'n/a';
|
|
764
|
+
return `${label} ${metric.metric} coverage ${actual} is below ${metric.minPct.toFixed(2)}%`;
|
|
765
|
+
}
|
|
766
|
+
|
|
537
767
|
function normalizeProjectRelative(rootDir, filePath) {
|
|
538
768
|
if (!rootDir || !filePath) {
|
|
539
769
|
return null;
|
package/src/report.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { mergeCoverageSummaries, normalizeCoverageSummary } from './coverage.js';
|
|
3
|
-
import { collectCoverageAttribution, lookupOwner } from './policy.js';
|
|
3
|
+
import { collectCoverageAttribution, lookupOwner, evaluateCoverageThresholds } from './policy.js';
|
|
4
4
|
|
|
5
5
|
export function createSummary(values = {}) {
|
|
6
6
|
return {
|
|
@@ -145,8 +145,13 @@ export function buildReportFromSuiteResults(context, suiteResults, durationMs) {
|
|
|
145
145
|
|
|
146
146
|
const allTests = packages.flatMap((pkg) => pkg.suites.flatMap((suite) => suite.tests));
|
|
147
147
|
const coverageAttribution = collectCoverageAttribution(context.policy, packages, context.project);
|
|
148
|
-
const
|
|
148
|
+
const moduleResults = buildModulesFromPackages(packages, coverageAttribution.files, context.policy);
|
|
149
|
+
const thresholdEvaluation = evaluateCoverageThresholds(context.policy, moduleResults, {
|
|
150
|
+
coverageEnabled: !context.execution?.coverageExplicitlyDisabled,
|
|
151
|
+
});
|
|
152
|
+
const modules = thresholdEvaluation.modules;
|
|
149
153
|
const overallCoverage = mergeCoverageSummaries(packages.map((pkg) => pkg.coverage).filter(Boolean));
|
|
154
|
+
const diagnosticsSummary = summarizeDiagnostics(packages);
|
|
150
155
|
|
|
151
156
|
return {
|
|
152
157
|
schemaVersion: '1',
|
|
@@ -169,6 +174,12 @@ export function buildReportFromSuiteResults(context, suiteResults, durationMs) {
|
|
|
169
174
|
coverage: overallCoverage,
|
|
170
175
|
classification: summarizeClassification(allTests),
|
|
171
176
|
coverageAttribution: coverageAttribution.summary,
|
|
177
|
+
policy: {
|
|
178
|
+
failedThresholds: thresholdEvaluation.summary.failedRules,
|
|
179
|
+
warningThresholds: thresholdEvaluation.summary.warningRules,
|
|
180
|
+
diagnosticsSuites: diagnosticsSummary.totalSuites,
|
|
181
|
+
failedDiagnostics: diagnosticsSummary.failedSuites,
|
|
182
|
+
},
|
|
172
183
|
filterOptions: {
|
|
173
184
|
modules: dedupe(modules.map((moduleEntry) => moduleEntry.module)).sort(),
|
|
174
185
|
packages: packages.map((pkg) => pkg.name).sort(),
|
|
@@ -177,8 +188,12 @@ export function buildReportFromSuiteResults(context, suiteResults, durationMs) {
|
|
|
177
188
|
},
|
|
178
189
|
packages,
|
|
179
190
|
modules,
|
|
191
|
+
policy: {
|
|
192
|
+
thresholds: thresholdEvaluation.summary,
|
|
193
|
+
diagnostics: diagnosticsSummary,
|
|
194
|
+
},
|
|
180
195
|
meta: {
|
|
181
|
-
phase:
|
|
196
|
+
phase: 8,
|
|
182
197
|
projectName: context.project.name,
|
|
183
198
|
projectRootDir: context.project.rootDir,
|
|
184
199
|
outputDir: context.project.outputDir,
|
|
@@ -219,6 +234,7 @@ function buildModulesFromPackages(packages, coverageFiles, policy) {
|
|
|
219
234
|
command: suite.command,
|
|
220
235
|
warnings: suite.warnings,
|
|
221
236
|
coverage: null,
|
|
237
|
+
diagnostics: suite.diagnostics || null,
|
|
222
238
|
rawArtifacts: suite.rawArtifacts,
|
|
223
239
|
tests: [],
|
|
224
240
|
summary: createSummary(),
|
|
@@ -366,6 +382,16 @@ function stripSuitePackageName(suite) {
|
|
|
366
382
|
return rest;
|
|
367
383
|
}
|
|
368
384
|
|
|
385
|
+
function summarizeDiagnostics(packages) {
|
|
386
|
+
const diagnostics = (packages || []).flatMap((pkg) => pkg.suites.map((suite) => suite.diagnostics).filter(Boolean));
|
|
387
|
+
return {
|
|
388
|
+
totalSuites: diagnostics.length,
|
|
389
|
+
passedSuites: diagnostics.filter((entry) => entry.status === 'passed').length,
|
|
390
|
+
failedSuites: diagnostics.filter((entry) => entry.status === 'failed').length,
|
|
391
|
+
skippedSuites: diagnostics.filter((entry) => entry.status === 'skipped').length,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
369
395
|
function normalizeRawArtifacts(rawArtifacts, suite) {
|
|
370
396
|
return (Array.isArray(rawArtifacts) ? rawArtifacts : [])
|
|
371
397
|
.map((artifact) => normalizeRawArtifact(artifact, suite))
|
package/src/run-report.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
2
3
|
import { loadConfig, applyConfigOverrides, resolveProjectContext } from './config.js';
|
|
3
4
|
import { preparePolicyContext, applyPolicyPipeline } from './policy.js';
|
|
4
5
|
import { resolveAdapterForSuite } from './adapters.js';
|
|
@@ -25,6 +26,7 @@ export async function runReport(options = {}) {
|
|
|
25
26
|
execution: {
|
|
26
27
|
dryRun: options.dryRun ?? Boolean(effectiveConfig?.execution?.dryRun),
|
|
27
28
|
coverage: options.coverage ?? Boolean(effectiveConfig?.execution?.defaultCoverage),
|
|
29
|
+
coverageExplicitlyDisabled: options.coverage === false,
|
|
28
30
|
},
|
|
29
31
|
};
|
|
30
32
|
context.policy = await preparePolicyContext(effectiveLoaded, context.project);
|
|
@@ -126,6 +128,13 @@ export async function runReport(options = {}) {
|
|
|
126
128
|
}
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
if (!context.execution.dryRun && normalized.status === 'failed') {
|
|
132
|
+
const diagnostics = await runSuiteDiagnostics(suite, context, options);
|
|
133
|
+
if (diagnostics) {
|
|
134
|
+
normalized = attachDiagnostics(normalized, diagnostics);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
129
138
|
suiteResults.push(normalized);
|
|
130
139
|
packageSuiteResults.push(normalized);
|
|
131
140
|
emitEvent(options, {
|
|
@@ -282,3 +291,254 @@ function relativePathSafe(fromPath, toPath) {
|
|
|
282
291
|
return null;
|
|
283
292
|
}
|
|
284
293
|
}
|
|
294
|
+
|
|
295
|
+
async function runSuiteDiagnostics(suite, context, options) {
|
|
296
|
+
const config = normalizeDiagnosticsConfig(suite);
|
|
297
|
+
if (!config) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const label = config.label || 'Diagnostics rerun';
|
|
302
|
+
emitEvent(options, {
|
|
303
|
+
type: 'suite-diagnostics-start',
|
|
304
|
+
packageName: suite.packageName,
|
|
305
|
+
packageLocation: derivePackageLocation(suite, context.project),
|
|
306
|
+
suiteId: suite.id,
|
|
307
|
+
suiteLabel: suite.label,
|
|
308
|
+
diagnosticsLabel: label,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const startedAt = Date.now();
|
|
312
|
+
const command = config.command || suite.command;
|
|
313
|
+
const commandText = formatCommand(command);
|
|
314
|
+
const result = await executeDiagnosticCommand(command, {
|
|
315
|
+
cwd: config.cwd || suite.cwd || context.project.rootDir,
|
|
316
|
+
env: {
|
|
317
|
+
...(suite.env || {}),
|
|
318
|
+
...(config.env || {}),
|
|
319
|
+
},
|
|
320
|
+
timeoutMs: config.timeoutMs,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const durationMs = Date.now() - startedAt;
|
|
324
|
+
const artifactBase = `diagnostics/${slugify(suite.packageName || 'default')}-${slugify(suite.id)}-rerun`;
|
|
325
|
+
const rawArtifacts = [
|
|
326
|
+
{
|
|
327
|
+
relativePath: `${artifactBase}.log`,
|
|
328
|
+
label: `${label} log`,
|
|
329
|
+
content: buildDiagnosticsLog(result),
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
relativePath: `${artifactBase}.json`,
|
|
333
|
+
label: `${label} metadata`,
|
|
334
|
+
content: `${JSON.stringify({
|
|
335
|
+
label,
|
|
336
|
+
command: commandText,
|
|
337
|
+
cwd: config.cwd || suite.cwd || context.project.rootDir,
|
|
338
|
+
status: result.status,
|
|
339
|
+
exitCode: result.exitCode,
|
|
340
|
+
signal: result.signal,
|
|
341
|
+
timedOut: result.timedOut,
|
|
342
|
+
durationMs,
|
|
343
|
+
}, null, 2)}\n`,
|
|
344
|
+
},
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
const diagnostics = {
|
|
348
|
+
label,
|
|
349
|
+
status: result.status,
|
|
350
|
+
command: commandText,
|
|
351
|
+
cwd: config.cwd || suite.cwd || context.project.rootDir,
|
|
352
|
+
durationMs,
|
|
353
|
+
exitCode: result.exitCode,
|
|
354
|
+
signal: result.signal,
|
|
355
|
+
timedOut: result.timedOut,
|
|
356
|
+
output: {
|
|
357
|
+
stdout: result.stdout,
|
|
358
|
+
stderr: result.stderr,
|
|
359
|
+
},
|
|
360
|
+
rawArtifacts,
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
emitEvent(options, {
|
|
364
|
+
type: 'suite-diagnostics-complete',
|
|
365
|
+
packageName: suite.packageName,
|
|
366
|
+
packageLocation: derivePackageLocation(suite, context.project),
|
|
367
|
+
suiteId: suite.id,
|
|
368
|
+
suiteLabel: suite.label,
|
|
369
|
+
diagnosticsLabel: label,
|
|
370
|
+
result: diagnostics,
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
return diagnostics;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function normalizeDiagnosticsConfig(suite) {
|
|
377
|
+
if (!suite?.diagnostics || typeof suite.diagnostics !== 'object') {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
label: typeof suite.diagnostics.label === 'string' && suite.diagnostics.label.trim().length > 0
|
|
382
|
+
? suite.diagnostics.label.trim()
|
|
383
|
+
: 'Diagnostics rerun',
|
|
384
|
+
command: suite.diagnostics.command || suite.command,
|
|
385
|
+
cwd: suite.diagnostics.cwd || null,
|
|
386
|
+
env: suite.diagnostics.env && typeof suite.diagnostics.env === 'object'
|
|
387
|
+
? suite.diagnostics.env
|
|
388
|
+
: {},
|
|
389
|
+
timeoutMs: Number.isFinite(suite.diagnostics.timeoutMs) ? suite.diagnostics.timeoutMs : null,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function executeDiagnosticCommand(command, options) {
|
|
394
|
+
const spec = normalizeCommand(command);
|
|
395
|
+
if (!spec) {
|
|
396
|
+
return {
|
|
397
|
+
status: 'skipped',
|
|
398
|
+
exitCode: null,
|
|
399
|
+
signal: null,
|
|
400
|
+
timedOut: false,
|
|
401
|
+
stdout: '',
|
|
402
|
+
stderr: 'No diagnostic command configured.',
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return new Promise((resolve) => {
|
|
407
|
+
const child = spawn(spec.command, spec.args, {
|
|
408
|
+
cwd: options.cwd,
|
|
409
|
+
env: {
|
|
410
|
+
...process.env,
|
|
411
|
+
...(options.env || {}),
|
|
412
|
+
},
|
|
413
|
+
shell: spec.shell,
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
let stdout = '';
|
|
417
|
+
let stderr = '';
|
|
418
|
+
let timedOut = false;
|
|
419
|
+
let settled = false;
|
|
420
|
+
let timeoutId = null;
|
|
421
|
+
|
|
422
|
+
if (child.stdout) {
|
|
423
|
+
child.stdout.on('data', (chunk) => {
|
|
424
|
+
stdout += String(chunk);
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (child.stderr) {
|
|
429
|
+
child.stderr.on('data', (chunk) => {
|
|
430
|
+
stderr += String(chunk);
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (Number.isFinite(options.timeoutMs) && options.timeoutMs > 0) {
|
|
435
|
+
timeoutId = setTimeout(() => {
|
|
436
|
+
timedOut = true;
|
|
437
|
+
child.kill('SIGTERM');
|
|
438
|
+
}, options.timeoutMs);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const finish = (payload) => {
|
|
442
|
+
if (settled) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
settled = true;
|
|
446
|
+
if (timeoutId) {
|
|
447
|
+
clearTimeout(timeoutId);
|
|
448
|
+
}
|
|
449
|
+
resolve(payload);
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
child.on('error', (error) => {
|
|
453
|
+
finish({
|
|
454
|
+
status: 'failed',
|
|
455
|
+
exitCode: null,
|
|
456
|
+
signal: null,
|
|
457
|
+
timedOut,
|
|
458
|
+
stdout,
|
|
459
|
+
stderr: `${stderr}${error instanceof Error ? error.stack || error.message : String(error)}\n`,
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
child.on('close', (code, signal) => {
|
|
464
|
+
finish({
|
|
465
|
+
status: timedOut
|
|
466
|
+
? 'failed'
|
|
467
|
+
: (code === 0 ? 'passed' : 'failed'),
|
|
468
|
+
exitCode: Number.isFinite(code) ? code : null,
|
|
469
|
+
signal: signal || null,
|
|
470
|
+
timedOut,
|
|
471
|
+
stdout,
|
|
472
|
+
stderr,
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function normalizeCommand(command) {
|
|
479
|
+
if (Array.isArray(command)) {
|
|
480
|
+
const parts = command.map((value) => String(value || '')).filter(Boolean);
|
|
481
|
+
if (parts.length === 0) {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
command: parts[0],
|
|
486
|
+
args: parts.slice(1),
|
|
487
|
+
shell: false,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (typeof command === 'string' && command.trim().length > 0) {
|
|
492
|
+
return {
|
|
493
|
+
command,
|
|
494
|
+
args: [],
|
|
495
|
+
shell: true,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function attachDiagnostics(suiteResult, diagnostics) {
|
|
503
|
+
const warnings = [...(suiteResult.warnings || [])];
|
|
504
|
+
warnings.push(`Diagnostics rerun ${diagnostics.status} (${diagnostics.label}).`);
|
|
505
|
+
return {
|
|
506
|
+
...suiteResult,
|
|
507
|
+
warnings,
|
|
508
|
+
diagnostics,
|
|
509
|
+
rawArtifacts: [
|
|
510
|
+
...(suiteResult.rawArtifacts || []),
|
|
511
|
+
...(diagnostics.rawArtifacts || []),
|
|
512
|
+
],
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function buildDiagnosticsLog(result) {
|
|
517
|
+
const sections = [
|
|
518
|
+
'# stdout',
|
|
519
|
+
result.stdout || '',
|
|
520
|
+
'',
|
|
521
|
+
'# stderr',
|
|
522
|
+
result.stderr || '',
|
|
523
|
+
];
|
|
524
|
+
return `${sections.join('\n')}\n`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function formatCommand(command) {
|
|
528
|
+
if (Array.isArray(command)) {
|
|
529
|
+
return command.map((entry) => shellEscape(entry)).join(' ');
|
|
530
|
+
}
|
|
531
|
+
return typeof command === 'string' ? command : '';
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function shellEscape(value) {
|
|
535
|
+
const normalized = String(value || '');
|
|
536
|
+
if (/^[A-Za-z0-9_./:=+-]+$/.test(normalized)) {
|
|
537
|
+
return normalized;
|
|
538
|
+
}
|
|
539
|
+
return JSON.stringify(normalized);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function slugify(value) {
|
|
543
|
+
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
544
|
+
}
|