@test-station/core 0.1.8 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@test-station/core",
3
- "version": "0.1.8",
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.1.8",
13
- "@test-station/adapter-node-test": "0.1.8",
14
- "@test-station/adapter-playwright": "0.1.8",
15
- "@test-station/adapter-shell": "0.1.8",
16
- "@test-station/adapter-vitest": "0.1.8",
17
- "@test-station/plugin-source-analysis": "0.1.8"
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 modules = buildModulesFromPackages(packages, coverageAttribution.files, context.policy);
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: 5,
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
+ }