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