@test-station/core 0.1.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/src/report.js ADDED
@@ -0,0 +1,517 @@
1
+ import path from 'node:path';
2
+ import { mergeCoverageSummaries, normalizeCoverageSummary } from './coverage.js';
3
+ import { collectCoverageAttribution, lookupOwner } from './policy.js';
4
+
5
+ export function createSummary(values = {}) {
6
+ return {
7
+ total: Number.isFinite(values.total) ? values.total : 0,
8
+ passed: Number.isFinite(values.passed) ? values.passed : 0,
9
+ failed: Number.isFinite(values.failed) ? values.failed : 0,
10
+ skipped: Number.isFinite(values.skipped) ? values.skipped : 0,
11
+ };
12
+ }
13
+
14
+ export function createPhase1ScaffoldReport(config) {
15
+ return {
16
+ schemaVersion: '1',
17
+ generatedAt: new Date().toISOString(),
18
+ durationMs: 0,
19
+ summary: {
20
+ totalPackages: 0,
21
+ totalModules: 0,
22
+ totalSuites: Array.isArray(config?.suites) ? config.suites.length : 0,
23
+ totalTests: 0,
24
+ passedTests: 0,
25
+ failedTests: 0,
26
+ skippedTests: 0,
27
+ filterOptions: {
28
+ modules: [],
29
+ packages: [],
30
+ frameworks: [],
31
+ },
32
+ },
33
+ packages: [],
34
+ modules: [],
35
+ meta: {
36
+ phase: 1,
37
+ message: 'Phase 1 scaffold only. Execution engine arrives in later phases.',
38
+ },
39
+ };
40
+ }
41
+
42
+ export function normalizeTestResult(test, suite) {
43
+ const status = normalizeStatus(test?.status || 'passed');
44
+ const name = test?.name || test?.fullName || `${suite.label} result`;
45
+ return {
46
+ name,
47
+ fullName: test?.fullName || name,
48
+ status,
49
+ durationMs: Number.isFinite(test?.durationMs) ? test.durationMs : 0,
50
+ file: test?.file || null,
51
+ line: Number.isFinite(test?.line) ? test.line : null,
52
+ column: Number.isFinite(test?.column) ? test.column : null,
53
+ failureMessages: Array.isArray(test?.failureMessages) ? test.failureMessages : [],
54
+ assertions: Array.isArray(test?.assertions) ? test.assertions : [],
55
+ setup: Array.isArray(test?.setup) ? test.setup : [],
56
+ mocks: Array.isArray(test?.mocks) ? test.mocks : [],
57
+ rawDetails: test?.rawDetails && typeof test.rawDetails === 'object' ? test.rawDetails : {},
58
+ sourceSnippet: typeof test?.sourceSnippet === 'string' ? test.sourceSnippet : null,
59
+ module: test?.module || 'uncategorized',
60
+ theme: test?.theme || 'uncategorized',
61
+ classificationSource: test?.classificationSource || 'default',
62
+ };
63
+ }
64
+
65
+ export function normalizeSuiteResult(rawResult, suite, packageName) {
66
+ const tests = Array.isArray(rawResult?.tests)
67
+ ? rawResult.tests.map((test) => normalizeTestResult(test, suite))
68
+ : [];
69
+ const summary = rawResult?.summary || summarizeTests(tests);
70
+ const coverage = normalizeCoverageSummary(rawResult?.coverage, packageName);
71
+ const rawArtifacts = normalizeRawArtifacts(rawResult?.rawArtifacts, suite);
72
+ return {
73
+ id: suite.id,
74
+ label: suite.label,
75
+ runtime: suite.adapter,
76
+ command: formatCommand(suite.command),
77
+ cwd: suite.cwd,
78
+ status: normalizeStatus(rawResult?.status || deriveStatusFromSummary(summary)),
79
+ durationMs: Number.isFinite(rawResult?.durationMs) ? rawResult.durationMs : 0,
80
+ summary,
81
+ coverage,
82
+ tests,
83
+ warnings: Array.isArray(rawResult?.warnings) ? rawResult.warnings : [],
84
+ output: {
85
+ stdout: typeof rawResult?.output?.stdout === 'string' ? rawResult.output.stdout : '',
86
+ stderr: typeof rawResult?.output?.stderr === 'string' ? rawResult.output.stderr : '',
87
+ },
88
+ rawArtifacts,
89
+ packageName,
90
+ };
91
+ }
92
+
93
+ export function buildReportFromSuiteResults(context, suiteResults, durationMs) {
94
+ const packageMap = new Map();
95
+ const packageCatalog = Array.isArray(context?.packageCatalog) ? context.packageCatalog : [];
96
+
97
+ for (const packageEntry of packageCatalog) {
98
+ if (!packageMap.has(packageEntry.name)) {
99
+ packageMap.set(packageEntry.name, {
100
+ name: packageEntry.name,
101
+ location: packageEntry.location || packageEntry.name,
102
+ sortIndex: Number.isFinite(packageEntry.index) ? packageEntry.index : packageMap.size,
103
+ status: 'skipped',
104
+ durationMs: 0,
105
+ summary: createSummary(),
106
+ suites: [],
107
+ coverage: null,
108
+ modules: [],
109
+ frameworks: [],
110
+ });
111
+ }
112
+ }
113
+
114
+ for (const suite of suiteResults) {
115
+ const packageName = suite.packageName || 'default';
116
+ if (!packageMap.has(packageName)) {
117
+ packageMap.set(packageName, {
118
+ name: packageName,
119
+ location: packageName,
120
+ sortIndex: Number.MAX_SAFE_INTEGER,
121
+ status: 'skipped',
122
+ durationMs: 0,
123
+ summary: createSummary(),
124
+ suites: [],
125
+ coverage: null,
126
+ modules: [],
127
+ frameworks: [],
128
+ });
129
+ }
130
+ const pkg = packageMap.get(packageName);
131
+ pkg.suites.push(stripSuitePackageName(suite));
132
+ pkg.durationMs += suite.durationMs;
133
+ }
134
+
135
+ const packages = Array.from(packageMap.values())
136
+ .map((pkg) => finalizePackageResult(pkg))
137
+ .sort((left, right) => {
138
+ const leftIndex = Number.isFinite(left.sortIndex) ? left.sortIndex : Number.MAX_SAFE_INTEGER;
139
+ const rightIndex = Number.isFinite(right.sortIndex) ? right.sortIndex : Number.MAX_SAFE_INTEGER;
140
+ if (leftIndex !== rightIndex) {
141
+ return leftIndex - rightIndex;
142
+ }
143
+ return left.name.localeCompare(right.name);
144
+ });
145
+
146
+ const allTests = packages.flatMap((pkg) => pkg.suites.flatMap((suite) => suite.tests));
147
+ const coverageAttribution = collectCoverageAttribution(context.policy, packages, context.project);
148
+ const modules = buildModulesFromPackages(packages, coverageAttribution.files, context.policy);
149
+ const overallCoverage = mergeCoverageSummaries(packages.map((pkg) => pkg.coverage).filter(Boolean));
150
+
151
+ return {
152
+ schemaVersion: '1',
153
+ generatedAt: new Date().toISOString(),
154
+ durationMs,
155
+ summary: {
156
+ generatedAt: new Date().toISOString(),
157
+ durationMs,
158
+ totalPackages: packages.length,
159
+ totalModules: modules.length,
160
+ passedPackages: packages.filter((pkg) => pkg.status === 'passed').length,
161
+ failedPackages: packages.filter((pkg) => pkg.status === 'failed').length,
162
+ skippedPackages: packages.filter((pkg) => pkg.status === 'skipped').length,
163
+ totalSuites: packages.reduce((sum, pkg) => sum + pkg.suites.length, 0),
164
+ failedSuites: packages.reduce((sum, pkg) => sum + pkg.suites.filter((suite) => suite.status === 'failed').length, 0),
165
+ totalTests: allTests.length,
166
+ passedTests: allTests.filter((test) => test.status === 'passed').length,
167
+ failedTests: allTests.filter((test) => test.status === 'failed').length,
168
+ skippedTests: allTests.filter((test) => test.status === 'skipped').length,
169
+ coverage: overallCoverage,
170
+ classification: summarizeClassification(allTests),
171
+ coverageAttribution: coverageAttribution.summary,
172
+ filterOptions: {
173
+ modules: dedupe(modules.map((moduleEntry) => moduleEntry.module)).sort(),
174
+ packages: packages.map((pkg) => pkg.name).sort(),
175
+ frameworks: dedupe(packages.flatMap((pkg) => pkg.frameworks)).sort(),
176
+ },
177
+ },
178
+ packages,
179
+ modules,
180
+ meta: {
181
+ phase: 5,
182
+ projectName: context.project.name,
183
+ projectRootDir: context.project.rootDir,
184
+ outputDir: context.project.outputDir,
185
+ render: {
186
+ defaultView: context.config?.render?.defaultView || 'module',
187
+ includeDetailedAnalysisToggle: context.config?.render?.includeDetailedAnalysisToggle !== false,
188
+ },
189
+ },
190
+ };
191
+ }
192
+
193
+ function buildModulesFromPackages(packages, coverageFiles, policy) {
194
+ const moduleMap = new Map();
195
+
196
+ for (const pkg of packages) {
197
+ for (const suite of pkg.suites) {
198
+ for (const test of suite.tests) {
199
+ const moduleEntry = ensureModuleEntry(moduleMap, test.module || 'uncategorized', policy);
200
+ moduleEntry.tests.push(test);
201
+ moduleEntry.packageNames.add(pkg.name);
202
+ moduleEntry.frameworks.add(suite.runtime);
203
+
204
+ const themeEntry = ensureThemeEntry(moduleEntry, test.theme || 'uncategorized', policy);
205
+ themeEntry.tests.push(test);
206
+ themeEntry.packageNames.add(pkg.name);
207
+ themeEntry.frameworks.add(suite.runtime);
208
+
209
+ const packageEntry = ensurePackageEntry(themeEntry, pkg.name);
210
+ packageEntry.tests.push(test);
211
+ packageEntry.frameworks.add(suite.runtime);
212
+
213
+ const suiteKey = `${suite.id}:${suite.label}`;
214
+ if (!packageEntry.suites.has(suiteKey)) {
215
+ packageEntry.suites.set(suiteKey, {
216
+ id: suite.id,
217
+ label: suite.label,
218
+ runtime: suite.runtime,
219
+ command: suite.command,
220
+ warnings: suite.warnings,
221
+ coverage: null,
222
+ rawArtifacts: suite.rawArtifacts,
223
+ tests: [],
224
+ summary: createSummary(),
225
+ durationMs: suite.durationMs,
226
+ status: suite.status,
227
+ });
228
+ }
229
+ packageEntry.suites.get(suiteKey).tests.push(test);
230
+ }
231
+ }
232
+ }
233
+
234
+ for (const file of coverageFiles || []) {
235
+ const moduleEntry = ensureModuleEntry(moduleMap, file.module || 'uncategorized', policy);
236
+ moduleEntry.coverageFiles.push(file);
237
+ if (file.packageName) {
238
+ moduleEntry.packageNames.add(file.packageName);
239
+ moduleEntry.coveragePackageNames.add(file.packageName);
240
+ }
241
+
242
+ const themeName = file.theme || 'uncategorized';
243
+ const themeEntry = ensureThemeEntry(moduleEntry, themeName, policy);
244
+ themeEntry.coverageFiles.push(file);
245
+ if (file.packageName) {
246
+ themeEntry.packageNames.add(file.packageName);
247
+ themeEntry.coveragePackageNames.add(file.packageName);
248
+ ensurePackageEntry(themeEntry, file.packageName);
249
+ }
250
+ }
251
+
252
+ return Array.from(moduleMap.values())
253
+ .map((moduleEntry) => {
254
+ const packageNames = Array.from(new Set([...moduleEntry.packageNames, ...moduleEntry.coveragePackageNames])).sort();
255
+ return {
256
+ module: moduleEntry.module,
257
+ summary: summarizeTests(moduleEntry.tests),
258
+ durationMs: summarizeDuration(moduleEntry.tests),
259
+ packageCount: packageNames.length,
260
+ packages: packageNames,
261
+ frameworks: Array.from(moduleEntry.frameworks).sort(),
262
+ owner: moduleEntry.owner,
263
+ dominantPackages: packageNames.slice(0, 3),
264
+ coverage: coverageSummaryFromFiles(moduleEntry.coverageFiles),
265
+ themes: Array.from(moduleEntry.themes.values())
266
+ .map((themeEntry) => {
267
+ const themePackageNames = Array.from(new Set([...themeEntry.packageNames, ...themeEntry.coveragePackageNames])).sort();
268
+ return {
269
+ theme: themeEntry.theme,
270
+ summary: summarizeTests(themeEntry.tests),
271
+ durationMs: summarizeDuration(themeEntry.tests),
272
+ packageCount: themePackageNames.length,
273
+ packageNames: themePackageNames,
274
+ frameworks: Array.from(themeEntry.frameworks).sort(),
275
+ owner: themeEntry.owner,
276
+ coverage: coverageSummaryFromFiles(themeEntry.coverageFiles),
277
+ packages: Array.from(themeEntry.packageMap.values())
278
+ .map((packageEntry) => ({
279
+ name: packageEntry.name,
280
+ summary: summarizeTests(packageEntry.tests),
281
+ durationMs: summarizeDuration(packageEntry.tests),
282
+ frameworks: Array.from(packageEntry.frameworks).sort(),
283
+ suites: Array.from(packageEntry.suites.values()).map((suiteEntry) => {
284
+ const suiteSummary = summarizeTests(suiteEntry.tests);
285
+ return {
286
+ ...suiteEntry,
287
+ summary: suiteSummary,
288
+ status: deriveStatusFromSummary(suiteSummary),
289
+ };
290
+ }),
291
+ }))
292
+ .sort((left, right) => left.name.localeCompare(right.name)),
293
+ };
294
+ })
295
+ .sort((left, right) => left.theme.localeCompare(right.theme)),
296
+ };
297
+ })
298
+ .sort((left, right) => left.module.localeCompare(right.module));
299
+ }
300
+
301
+ function ensureModuleEntry(moduleMap, moduleName, policy) {
302
+ if (!moduleMap.has(moduleName)) {
303
+ moduleMap.set(moduleName, {
304
+ module: moduleName,
305
+ tests: [],
306
+ packageNames: new Set(),
307
+ coveragePackageNames: new Set(),
308
+ frameworks: new Set(),
309
+ coverageFiles: [],
310
+ owner: lookupOwner(policy, moduleName),
311
+ themes: new Map(),
312
+ });
313
+ }
314
+ return moduleMap.get(moduleName);
315
+ }
316
+
317
+ function ensureThemeEntry(moduleEntry, themeName, policy) {
318
+ if (!moduleEntry.themes.has(themeName)) {
319
+ moduleEntry.themes.set(themeName, {
320
+ theme: themeName,
321
+ tests: [],
322
+ packageNames: new Set(),
323
+ coveragePackageNames: new Set(),
324
+ frameworks: new Set(),
325
+ coverageFiles: [],
326
+ owner: lookupOwner(policy, moduleEntry.module, themeName),
327
+ packageMap: new Map(),
328
+ });
329
+ }
330
+ return moduleEntry.themes.get(themeName);
331
+ }
332
+
333
+ function ensurePackageEntry(themeEntry, packageName) {
334
+ if (!themeEntry.packageMap.has(packageName)) {
335
+ themeEntry.packageMap.set(packageName, {
336
+ name: packageName,
337
+ tests: [],
338
+ frameworks: new Set(),
339
+ suites: new Map(),
340
+ });
341
+ }
342
+ return themeEntry.packageMap.get(packageName);
343
+ }
344
+
345
+ function coverageSummaryFromFiles(files) {
346
+ if (!files || files.length === 0) {
347
+ return null;
348
+ }
349
+ return normalizeCoverageSummary({ files });
350
+ }
351
+
352
+ function finalizePackageResult(pkg) {
353
+ const summary = summarizeSuites(pkg.suites);
354
+ return {
355
+ ...pkg,
356
+ status: deriveStatusFromSummary(summary),
357
+ summary,
358
+ coverage: mergeCoverageSummaries(pkg.suites.map((suite) => suite.coverage).filter(Boolean)),
359
+ modules: dedupe(pkg.suites.flatMap((suite) => suite.tests.map((test) => test.module || 'uncategorized'))).sort(),
360
+ frameworks: dedupe(pkg.suites.map((suite) => suite.runtime)).sort(),
361
+ };
362
+ }
363
+
364
+ function stripSuitePackageName(suite) {
365
+ const { packageName, ...rest } = suite;
366
+ return rest;
367
+ }
368
+
369
+ function normalizeRawArtifacts(rawArtifacts, suite) {
370
+ return (Array.isArray(rawArtifacts) ? rawArtifacts : [])
371
+ .map((artifact) => normalizeRawArtifact(artifact, suite))
372
+ .filter(Boolean);
373
+ }
374
+
375
+ function normalizeRawArtifact(artifact, suite) {
376
+ if (!artifact || typeof artifact !== 'object') {
377
+ return null;
378
+ }
379
+
380
+ const relativePath = normalizeRelativeArtifactPath(artifact.relativePath);
381
+ if (!relativePath) {
382
+ return null;
383
+ }
384
+
385
+ const kind = artifact.kind === 'directory' ? 'directory' : 'file';
386
+ const normalized = {
387
+ relativePath,
388
+ href: toArtifactHref(relativePath),
389
+ label: typeof artifact.label === 'string' && artifact.label.trim().length > 0
390
+ ? artifact.label.trim()
391
+ : pathBaseName(relativePath),
392
+ kind,
393
+ mediaType: typeof artifact.mediaType === 'string' && artifact.mediaType.trim().length > 0
394
+ ? artifact.mediaType.trim()
395
+ : null,
396
+ sourcePath: resolveArtifactSourcePath(artifact.sourcePath, suite?.cwd),
397
+ encoding: typeof artifact.encoding === 'string' && artifact.encoding.length > 0 ? artifact.encoding : null,
398
+ content: typeof artifact.content === 'string' || Buffer.isBuffer(artifact.content) ? artifact.content : null,
399
+ };
400
+
401
+ if (!normalized.content && !normalized.sourcePath) {
402
+ return {
403
+ ...normalized,
404
+ sourcePath: null,
405
+ };
406
+ }
407
+
408
+ return normalized;
409
+ }
410
+
411
+ function normalizeRelativeArtifactPath(value) {
412
+ if (typeof value !== 'string' || value.trim().length === 0) {
413
+ return null;
414
+ }
415
+ return value
416
+ .replace(/\\/g, '/')
417
+ .replace(/^\/+/, '')
418
+ .split('/')
419
+ .filter((segment) => segment && segment !== '.' && segment !== '..')
420
+ .join('/');
421
+ }
422
+
423
+ function resolveArtifactSourcePath(sourcePath, suiteCwd) {
424
+ if (typeof sourcePath !== 'string' || sourcePath.trim().length === 0) {
425
+ return null;
426
+ }
427
+ if (path.isAbsolute(sourcePath)) {
428
+ return sourcePath;
429
+ }
430
+ const baseDir = suiteCwd || process.cwd();
431
+ return path.resolve(baseDir, sourcePath);
432
+ }
433
+
434
+ function toArtifactHref(relativePath) {
435
+ return ['raw', ...relativePath.split('/').filter(Boolean)].join('/');
436
+ }
437
+
438
+ function pathBaseName(relativePath) {
439
+ const parts = String(relativePath || '').split('/');
440
+ return parts[parts.length - 1] || relativePath;
441
+ }
442
+
443
+ export function summarizeSuites(suites) {
444
+ return suites.reduce((acc, suite) => ({
445
+ total: acc.total + suite.summary.total,
446
+ passed: acc.passed + suite.summary.passed,
447
+ failed: acc.failed + suite.summary.failed,
448
+ skipped: acc.skipped + suite.summary.skipped,
449
+ }), createSummary());
450
+ }
451
+
452
+ export function summarizeTests(tests) {
453
+ return tests.reduce((acc, test) => {
454
+ acc.total += 1;
455
+ if (test.status === 'failed') acc.failed += 1;
456
+ else if (test.status === 'skipped') acc.skipped += 1;
457
+ else acc.passed += 1;
458
+ return acc;
459
+ }, createSummary());
460
+ }
461
+
462
+ export function summarizeDuration(tests) {
463
+ return tests.reduce((sum, test) => sum + (Number.isFinite(test.durationMs) ? test.durationMs : 0), 0);
464
+ }
465
+
466
+ export function deriveStatusFromSummary(summary) {
467
+ if (!summary || summary.total === 0) return 'skipped';
468
+ if (summary.failed > 0) return 'failed';
469
+ if (summary.skipped === summary.total) return 'skipped';
470
+ return 'passed';
471
+ }
472
+
473
+ export function normalizeStatus(status) {
474
+ if (status === 'failed') return 'failed';
475
+ if (status === 'skipped') return 'skipped';
476
+ return 'passed';
477
+ }
478
+
479
+ export function formatCommand(command) {
480
+ if (Array.isArray(command)) {
481
+ return command.join(' ');
482
+ }
483
+ return String(command || '');
484
+ }
485
+
486
+ function summarizeClassification(tests) {
487
+ const modules = new Map();
488
+ let uncategorized = 0;
489
+ for (const test of tests) {
490
+ const moduleName = test.module || 'uncategorized';
491
+ const themeName = test.theme || 'uncategorized';
492
+ if (moduleName === 'uncategorized') {
493
+ uncategorized += 1;
494
+ }
495
+ if (!modules.has(moduleName)) {
496
+ modules.set(moduleName, { module: moduleName, total: 0, themes: new Map() });
497
+ }
498
+ const entry = modules.get(moduleName);
499
+ entry.total += 1;
500
+ if (!entry.themes.has(themeName)) {
501
+ entry.themes.set(themeName, 0);
502
+ }
503
+ entry.themes.set(themeName, entry.themes.get(themeName) + 1);
504
+ }
505
+ return {
506
+ uncategorized,
507
+ modules: Array.from(modules.values()).map((entry) => ({
508
+ module: entry.module,
509
+ total: entry.total,
510
+ themes: Array.from(entry.themes.entries()).map(([theme, total]) => ({ theme, total })),
511
+ })),
512
+ };
513
+ }
514
+
515
+ function dedupe(values) {
516
+ return Array.from(new Set((values || []).filter(Boolean)));
517
+ }