@test-station/adapter-vitest 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.
Files changed (2) hide show
  1. package/package.json +15 -0
  2. package/src/index.js +374 -0
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@test-station/adapter-vitest",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "type": "module",
8
+ "exports": {
9
+ ".": "./src/index.js"
10
+ },
11
+ "scripts": {
12
+ "build": "node ../../scripts/check-package.mjs ./src/index.js",
13
+ "lint": "node ../../scripts/lint-syntax.mjs ./src"
14
+ }
15
+ }
package/src/index.js ADDED
@@ -0,0 +1,374 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+
5
+ export const id = 'vitest';
6
+ export const description = 'Vitest adapter';
7
+
8
+ export function createVitestAdapter() {
9
+ return {
10
+ id,
11
+ description,
12
+ phase: 3,
13
+ async run({ project, suite, execution }) {
14
+ const commandSpec = parseCommandSpec(suite.command);
15
+ const slug = `${slugify(suite.packageName || 'default')}-${slugify(suite.id)}`;
16
+ const outputFile = path.join(project.rawDir, `${slug}-vitest.json`);
17
+ const primary = await spawnCommand(commandSpec.command, appendVitestJsonArgs(commandSpec.args, outputFile), {
18
+ cwd: suite.cwd || project.rootDir,
19
+ env: resolveSuiteEnv(suite.env),
20
+ });
21
+
22
+ if (!fs.existsSync(outputFile)) {
23
+ throw new Error(`Vitest adapter expected JSON report at ${outputFile}`);
24
+ }
25
+
26
+ const report = readJson(outputFile);
27
+ const parsed = parseVitestReport(report);
28
+ const warnings = [];
29
+ let coverage = null;
30
+ let coverageArtifact = null;
31
+
32
+ if (execution?.coverage && suite?.coverage?.enabled !== false) {
33
+ const coverageDir = path.join(project.rawDir, `${slug}-coverage`);
34
+ const coverageReportFile = path.join(project.rawDir, `${slug}-vitest-coverage.json`);
35
+ fs.rmSync(coverageDir, { recursive: true, force: true });
36
+ const coverageExecution = await spawnCommand(commandSpec.command, appendVitestCoverageArgs(commandSpec.args, coverageReportFile, coverageDir), {
37
+ cwd: suite.cwd || project.rootDir,
38
+ env: resolveSuiteEnv(suite.env),
39
+ });
40
+ const coverageSummaryPath = path.join(coverageDir, 'coverage-summary.json');
41
+ if (fs.existsSync(coverageSummaryPath)) {
42
+ coverage = normalizeVitestCoverage(readJson(coverageSummaryPath), suite.cwd || project.rootDir);
43
+ coverageArtifact = {
44
+ relativePath: `${slug}-vitest-coverage-summary.json`,
45
+ content: fs.readFileSync(coverageSummaryPath, 'utf8'),
46
+ };
47
+ }
48
+ if (coverageExecution.exitCode !== 0) {
49
+ warnings.push('Coverage pass failed; runtime results still reflect the non-coverage test run.');
50
+ }
51
+ }
52
+
53
+ return {
54
+ status: deriveSuiteStatus(parsed.summary, primary.exitCode),
55
+ durationMs: primary.durationMs,
56
+ summary: parsed.summary,
57
+ coverage,
58
+ tests: parsed.tests,
59
+ warnings,
60
+ output: {
61
+ stdout: primary.stdout,
62
+ stderr: primary.stderr,
63
+ },
64
+ rawArtifacts: [
65
+ {
66
+ relativePath: `${slug}-vitest.json`,
67
+ content: fs.readFileSync(outputFile, 'utf8'),
68
+ },
69
+ ...(coverageArtifact ? [coverageArtifact] : []),
70
+ ],
71
+ };
72
+ },
73
+ };
74
+ }
75
+
76
+ function parseCommandSpec(command) {
77
+ if (Array.isArray(command) && command.length > 0) {
78
+ return {
79
+ command: String(command[0]),
80
+ args: command.slice(1).map((entry) => String(entry)),
81
+ };
82
+ }
83
+ if (typeof command === 'string' && command.trim().length > 0) {
84
+ const tokens = tokenizeCommand(command);
85
+ return {
86
+ command: tokens[0],
87
+ args: tokens.slice(1),
88
+ };
89
+ }
90
+ throw new Error('Vitest adapter requires suite.command as a non-empty string or array.');
91
+ }
92
+
93
+ function appendVitestJsonArgs(args, outputFile) {
94
+ const filtered = stripVitestManagedArgs(args);
95
+ return [...filtered, '--reporter=json', `--outputFile=${outputFile}`];
96
+ }
97
+
98
+ function appendVitestCoverageArgs(args, outputFile, coverageDir) {
99
+ const filtered = stripVitestManagedArgs(args);
100
+ return [
101
+ ...filtered,
102
+ '--reporter=json',
103
+ `--outputFile=${outputFile}`,
104
+ '--coverage',
105
+ '--coverage.provider=v8',
106
+ '--coverage.reporter=json-summary',
107
+ '--coverage.reportOnFailure',
108
+ `--coverage.reportsDirectory=${coverageDir}`,
109
+ ];
110
+ }
111
+
112
+ function stripVitestManagedArgs(args) {
113
+ const filtered = [];
114
+ for (let index = 0; index < args.length; index += 1) {
115
+ const token = args[index];
116
+ if (token === '--reporter' || token === '--outputFile') {
117
+ index += 1;
118
+ continue;
119
+ }
120
+ if (token === '--coverage.provider' || token === '--coverage.reporter' || token === '--coverage.reportsDirectory') {
121
+ index += 1;
122
+ continue;
123
+ }
124
+ if (token === '--coverage' || token === '--coverage.reportOnFailure') {
125
+ continue;
126
+ }
127
+ if (/^--reporter=/.test(token) || /^--outputFile=/.test(token) || /^--coverage(=|$)/.test(token)) {
128
+ continue;
129
+ }
130
+ filtered.push(token);
131
+ }
132
+ return filtered;
133
+ }
134
+
135
+ function spawnCommand(command, args, options) {
136
+ return new Promise((resolve) => {
137
+ const startedAt = Date.now();
138
+ const child = spawn(command, args, {
139
+ cwd: options.cwd,
140
+ env: options.env,
141
+ stdio: ['ignore', 'pipe', 'pipe'],
142
+ });
143
+
144
+ let stdout = '';
145
+ let stderr = '';
146
+ child.stdout.on('data', (chunk) => {
147
+ stdout += chunk.toString();
148
+ });
149
+ child.stderr.on('data', (chunk) => {
150
+ stderr += chunk.toString();
151
+ });
152
+ child.on('error', (error) => {
153
+ stderr += `${error.message}\n`;
154
+ resolve({ exitCode: 1, stdout, stderr, durationMs: Date.now() - startedAt });
155
+ });
156
+ child.on('close', (code) => {
157
+ resolve({
158
+ exitCode: Number.isInteger(code) ? code : 1,
159
+ stdout,
160
+ stderr,
161
+ durationMs: Date.now() - startedAt,
162
+ });
163
+ });
164
+ });
165
+ }
166
+
167
+ function tokenizeCommand(command) {
168
+ const tokens = [];
169
+ let current = '';
170
+ let quote = null;
171
+ for (let index = 0; index < command.length; index += 1) {
172
+ const char = command[index];
173
+ if (quote) {
174
+ if (char === quote) {
175
+ quote = null;
176
+ } else if (char === '\\' && quote === '"' && index + 1 < command.length) {
177
+ current += command[index + 1];
178
+ index += 1;
179
+ } else {
180
+ current += char;
181
+ }
182
+ continue;
183
+ }
184
+ if (char === '"' || char === "'") {
185
+ quote = char;
186
+ continue;
187
+ }
188
+ if (/\s/.test(char)) {
189
+ if (current.length > 0) {
190
+ tokens.push(current);
191
+ current = '';
192
+ }
193
+ continue;
194
+ }
195
+ current += char;
196
+ }
197
+ if (current.length > 0) {
198
+ tokens.push(current);
199
+ }
200
+ return tokens;
201
+ }
202
+
203
+ function parseVitestReport(report) {
204
+ const tests = [];
205
+ for (const fileResult of report.testResults || []) {
206
+ for (const assertion of fileResult.assertionResults || []) {
207
+ tests.push({
208
+ name: assertion.title,
209
+ fullName: assertion.fullName || [...(assertion.ancestorTitles || []), assertion.title].join(' '),
210
+ status: normalizeStatus(assertion.status),
211
+ durationMs: Number.isFinite(assertion.duration) ? Math.round(assertion.duration) : 0,
212
+ file: fileResult.name ? path.resolve(fileResult.name) : null,
213
+ line: null,
214
+ column: null,
215
+ failureMessages: Array.isArray(assertion.failureMessages)
216
+ ? assertion.failureMessages.filter(Boolean).map((message) => trimForReport(message, 1000))
217
+ : [],
218
+ rawDetails: assertion.meta && Object.keys(assertion.meta).length > 0 ? { meta: assertion.meta } : {},
219
+ });
220
+ }
221
+ }
222
+
223
+ return {
224
+ summary: createSummary({
225
+ total: report.numTotalTests || tests.length,
226
+ passed: report.numPassedTests || tests.filter((test) => test.status === 'passed').length,
227
+ failed: report.numFailedTests || tests.filter((test) => test.status === 'failed').length,
228
+ skipped: (report.numPendingTests || 0) + (report.numTodoTests || 0),
229
+ }),
230
+ tests: tests.sort(sortTests),
231
+ };
232
+ }
233
+
234
+ function normalizeVitestCoverage(summary, workspaceDir) {
235
+ const files = Object.entries(summary)
236
+ .filter(([filePath]) => filePath !== 'total')
237
+ .map(([filePath, metrics]) => ({
238
+ path: path.resolve(filePath),
239
+ lines: createCoverageMetric(metrics.lines?.covered, metrics.lines?.total),
240
+ statements: createCoverageMetric(metrics.statements?.covered, metrics.statements?.total),
241
+ functions: createCoverageMetric(metrics.functions?.covered, metrics.functions?.total),
242
+ branches: createCoverageMetric(metrics.branches?.covered, metrics.branches?.total),
243
+ }))
244
+ .filter((entry) => shouldIncludeCoverageFile(entry.path, workspaceDir));
245
+
246
+ return createCoverageSummary(files);
247
+ }
248
+
249
+ function shouldIncludeCoverageFile(filePath, workspaceDir) {
250
+ const resolved = path.resolve(filePath);
251
+ const workspaceRoot = path.resolve(workspaceDir);
252
+ if (!resolved.startsWith(workspaceRoot)) {
253
+ return false;
254
+ }
255
+ if (
256
+ /(^|\/)(node_modules|coverage|artifacts|playwright-report|test-results)(\/|$)/.test(resolved)
257
+ ) {
258
+ return false;
259
+ }
260
+ return true;
261
+ }
262
+
263
+ function createCoverageSummary(files) {
264
+ if (!files.length) {
265
+ return null;
266
+ }
267
+ return {
268
+ lines: aggregateCoverageMetric(files, 'lines'),
269
+ statements: aggregateCoverageMetric(files, 'statements'),
270
+ functions: aggregateCoverageMetric(files, 'functions'),
271
+ branches: aggregateCoverageMetric(files, 'branches'),
272
+ files: files.sort((left, right) => left.path.localeCompare(right.path)),
273
+ };
274
+ }
275
+
276
+ function aggregateCoverageMetric(files, metricKey) {
277
+ const valid = files.map((file) => file?.[metricKey]).filter((metric) => metric && Number.isFinite(metric.total));
278
+ if (!valid.length) {
279
+ return null;
280
+ }
281
+ return createCoverageMetric(
282
+ valid.reduce((sum, metric) => sum + metric.covered, 0),
283
+ valid.reduce((sum, metric) => sum + metric.total, 0),
284
+ );
285
+ }
286
+
287
+ function createCoverageMetric(covered, total) {
288
+ if (!Number.isFinite(total)) {
289
+ return null;
290
+ }
291
+ const safeTotal = Math.max(0, total);
292
+ const safeCovered = Number.isFinite(covered) ? Math.max(0, Math.min(safeTotal, covered)) : 0;
293
+ return {
294
+ covered: safeCovered,
295
+ total: safeTotal,
296
+ pct: safeTotal === 0 ? 100 : Number(((safeCovered / safeTotal) * 100).toFixed(2)),
297
+ };
298
+ }
299
+
300
+ function createSummary(values = {}) {
301
+ return {
302
+ total: Number.isFinite(values.total) ? values.total : 0,
303
+ passed: Number.isFinite(values.passed) ? values.passed : 0,
304
+ failed: Number.isFinite(values.failed) ? values.failed : 0,
305
+ skipped: Number.isFinite(values.skipped) ? values.skipped : 0,
306
+ };
307
+ }
308
+
309
+ function normalizeStatus(status) {
310
+ if (status === 'passed') return 'passed';
311
+ if (status === 'skipped' || status === 'pending' || status === 'todo') return 'skipped';
312
+ return 'failed';
313
+ }
314
+
315
+ function deriveSuiteStatus(summary, exitCode) {
316
+ if (exitCode !== 0 || summary.failed > 0) {
317
+ return 'failed';
318
+ }
319
+ if (summary.total === 0 || summary.skipped === summary.total) {
320
+ return 'skipped';
321
+ }
322
+ return 'passed';
323
+ }
324
+
325
+ function sortTests(left, right) {
326
+ const leftFile = left.file || '';
327
+ const rightFile = right.file || '';
328
+ if (leftFile !== rightFile) {
329
+ return leftFile.localeCompare(rightFile);
330
+ }
331
+ return left.name.localeCompare(right.name);
332
+ }
333
+
334
+ function readJson(filePath) {
335
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
336
+ }
337
+
338
+ function trimForReport(value, limit) {
339
+ if (typeof value !== 'string') {
340
+ return '';
341
+ }
342
+ return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`;
343
+ }
344
+
345
+ function slugify(value) {
346
+ return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
347
+ }
348
+
349
+ function sanitizeEnv(env) {
350
+ const nextEnv = { ...env };
351
+ delete nextEnv.NODE_TEST_CONTEXT;
352
+ return nextEnv;
353
+ }
354
+
355
+ function resolveSuiteEnv(suiteEnv) {
356
+ return {
357
+ ...sanitizeEnv(process.env),
358
+ ...normalizeEnvRecord(suiteEnv),
359
+ };
360
+ }
361
+
362
+ function normalizeEnvRecord(env) {
363
+ if (!env || typeof env !== 'object') {
364
+ return {};
365
+ }
366
+ const normalized = {};
367
+ for (const [key, value] of Object.entries(env)) {
368
+ if (value === undefined || value === null) {
369
+ continue;
370
+ }
371
+ normalized[key] = String(value);
372
+ }
373
+ return normalized;
374
+ }