@test-station/adapter-node-test 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 +521 -0
- package/src/ndjson-reporter.js +7 -0
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@test-station/adapter-node-test",
|
|
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 && node --check ./src/ndjson-reporter.js",
|
|
13
|
+
"lint": "node ../../scripts/lint-syntax.mjs ./src"
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
export const id = 'node-test';
|
|
6
|
+
export const description = 'node:test adapter';
|
|
7
|
+
|
|
8
|
+
const reporterPath = path.resolve(import.meta.dirname, 'ndjson-reporter.js');
|
|
9
|
+
|
|
10
|
+
export function createNodeTestAdapter() {
|
|
11
|
+
return {
|
|
12
|
+
id,
|
|
13
|
+
description,
|
|
14
|
+
phase: 3,
|
|
15
|
+
async run({ project, suite, execution }) {
|
|
16
|
+
const commandSpec = parseCommandSpec(suite.command);
|
|
17
|
+
const directNodeTest = isDirectNodeTestCommand(commandSpec);
|
|
18
|
+
const primaryExecution = await executeNodeTestRun(commandSpec, {
|
|
19
|
+
cwd: suite.cwd || project.rootDir,
|
|
20
|
+
suiteEnv: suite.env,
|
|
21
|
+
rawRelativePath: `${slugify(suite.packageName || 'default')}-${slugify(suite.id)}-node.ndjson`,
|
|
22
|
+
});
|
|
23
|
+
const parsed = parseNodeEvents(parseNdjson(primaryExecution.stdout), suite.cwd || project.rootDir);
|
|
24
|
+
const warnings = [];
|
|
25
|
+
let coverage = null;
|
|
26
|
+
let coverageArtifact = null;
|
|
27
|
+
|
|
28
|
+
if (execution?.coverage && suite?.coverage?.enabled !== false) {
|
|
29
|
+
if (!directNodeTest) {
|
|
30
|
+
warnings.push('Coverage pass skipped for wrapped node:test command; use a direct node --test invocation to collect coverage.');
|
|
31
|
+
} else {
|
|
32
|
+
const coverageExecution = await executeNodeTestRun(commandSpec, {
|
|
33
|
+
cwd: suite.cwd || project.rootDir,
|
|
34
|
+
enableCoverage: true,
|
|
35
|
+
suiteEnv: suite.env,
|
|
36
|
+
rawRelativePath: `${slugify(suite.packageName || 'default')}-${slugify(suite.id)}-node-coverage.ndjson`,
|
|
37
|
+
});
|
|
38
|
+
const coverageParsed = parseNodeEvents(parseNdjson(coverageExecution.stdout), suite.cwd || project.rootDir);
|
|
39
|
+
coverage = coverageParsed.coverage;
|
|
40
|
+
coverageArtifact = {
|
|
41
|
+
relativePath: `${slugify(suite.packageName || 'default')}-${slugify(suite.id)}-node-coverage.ndjson`,
|
|
42
|
+
content: coverageExecution.stdout,
|
|
43
|
+
};
|
|
44
|
+
if (coverageExecution.exitCode !== 0) {
|
|
45
|
+
warnings.push('Coverage pass failed; coverage may be incomplete.');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
status: deriveSuiteStatus(parsed.summary, primaryExecution.exitCode),
|
|
52
|
+
durationMs: primaryExecution.durationMs,
|
|
53
|
+
summary: parsed.summary,
|
|
54
|
+
coverage,
|
|
55
|
+
tests: parsed.tests,
|
|
56
|
+
warnings,
|
|
57
|
+
output: {
|
|
58
|
+
stdout: primaryExecution.stdout,
|
|
59
|
+
stderr: primaryExecution.stderr,
|
|
60
|
+
},
|
|
61
|
+
rawArtifacts: [
|
|
62
|
+
{
|
|
63
|
+
relativePath: `${slugify(suite.packageName || 'default')}-${slugify(suite.id)}-node.ndjson`,
|
|
64
|
+
content: primaryExecution.stdout,
|
|
65
|
+
},
|
|
66
|
+
...(coverageArtifact ? [coverageArtifact] : []),
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function executeNodeTestRun(commandSpec, options) {
|
|
74
|
+
const directNodeTest = isDirectNodeTestCommand(commandSpec);
|
|
75
|
+
const env = resolveSuiteEnv(options.suiteEnv);
|
|
76
|
+
let command = commandSpec.command;
|
|
77
|
+
let args = [...commandSpec.args];
|
|
78
|
+
|
|
79
|
+
if (directNodeTest) {
|
|
80
|
+
args = withReporterArgs(args, { enableCoverage: Boolean(options.enableCoverage) });
|
|
81
|
+
} else {
|
|
82
|
+
env.NODE_OPTIONS = appendNodeOption(env.NODE_OPTIONS, `--test-reporter=${reporterPath}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return spawnCommand(command, args, {
|
|
86
|
+
cwd: options.cwd,
|
|
87
|
+
env,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseCommandSpec(command) {
|
|
92
|
+
if (Array.isArray(command) && command.length > 0) {
|
|
93
|
+
return {
|
|
94
|
+
command: String(command[0]),
|
|
95
|
+
args: command.slice(1).map((entry) => String(entry)),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (typeof command === 'string' && command.trim().length > 0) {
|
|
100
|
+
const tokens = tokenizeCommand(command);
|
|
101
|
+
if (tokens.length === 0) {
|
|
102
|
+
throw new Error('node:test adapter received an empty command string.');
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
command: tokens[0],
|
|
106
|
+
args: tokens.slice(1),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
throw new Error('node:test adapter requires suite.command as a non-empty string or array.');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function tokenizeCommand(command) {
|
|
114
|
+
const tokens = [];
|
|
115
|
+
let current = '';
|
|
116
|
+
let quote = null;
|
|
117
|
+
|
|
118
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
119
|
+
const char = command[index];
|
|
120
|
+
if (quote) {
|
|
121
|
+
if (char === quote) {
|
|
122
|
+
quote = null;
|
|
123
|
+
} else if (char === '\\' && quote === '"' && index + 1 < command.length) {
|
|
124
|
+
current += command[index + 1];
|
|
125
|
+
index += 1;
|
|
126
|
+
} else {
|
|
127
|
+
current += char;
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (char === '"' || char === "'") {
|
|
133
|
+
quote = char;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (/\s/.test(char)) {
|
|
138
|
+
if (current.length > 0) {
|
|
139
|
+
tokens.push(current);
|
|
140
|
+
current = '';
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
current += char;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (current.length > 0) {
|
|
149
|
+
tokens.push(current);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return tokens;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function isDirectNodeTestCommand(commandSpec) {
|
|
156
|
+
const binary = path.basename(commandSpec.command).toLowerCase();
|
|
157
|
+
return binary.startsWith('node') && commandSpec.args.includes('--test');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function withReporterArgs(args, options = {}) {
|
|
161
|
+
const filtered = [];
|
|
162
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
163
|
+
const token = args[index];
|
|
164
|
+
if (token === '--test-reporter' || token === '--test-reporter-destination') {
|
|
165
|
+
index += 1;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (token === '--experimental-test-coverage') {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (token.startsWith('--test-reporter=')) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (token.startsWith('--test-reporter-destination=')) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
filtered.push(token);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const testIndex = filtered.indexOf('--test');
|
|
181
|
+
if (testIndex === -1) {
|
|
182
|
+
throw new Error('node:test adapter requires a direct node --test command to inject the reporter.');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const injected = [...filtered];
|
|
186
|
+
injected.splice(testIndex + 1, 0, `--test-reporter=${reporterPath}`);
|
|
187
|
+
if (options.enableCoverage) {
|
|
188
|
+
injected.splice(testIndex + 1, 0, '--experimental-test-coverage');
|
|
189
|
+
}
|
|
190
|
+
return injected;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function appendNodeOption(existing, option) {
|
|
194
|
+
const prefix = typeof existing === 'string' && existing.trim().length > 0 ? `${existing.trim()} ` : '';
|
|
195
|
+
return `${prefix}${option}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function spawnCommand(command, args, options) {
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
const startedAt = Date.now();
|
|
201
|
+
const child = spawn(command, args, {
|
|
202
|
+
cwd: options.cwd,
|
|
203
|
+
env: options.env,
|
|
204
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
let stdout = '';
|
|
208
|
+
let stderr = '';
|
|
209
|
+
|
|
210
|
+
child.stdout.on('data', (chunk) => {
|
|
211
|
+
stdout += chunk.toString();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
child.stderr.on('data', (chunk) => {
|
|
215
|
+
stderr += chunk.toString();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
child.on('error', (error) => {
|
|
219
|
+
stderr += `${error.message}\n`;
|
|
220
|
+
resolve({ exitCode: 1, stdout, stderr, durationMs: Date.now() - startedAt });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
child.on('close', (code) => {
|
|
224
|
+
resolve({
|
|
225
|
+
exitCode: Number.isInteger(code) ? code : 1,
|
|
226
|
+
stdout,
|
|
227
|
+
stderr,
|
|
228
|
+
durationMs: Date.now() - startedAt,
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function parseNdjson(value) {
|
|
235
|
+
return String(value || '')
|
|
236
|
+
.split(/\r?\n/)
|
|
237
|
+
.map((line) => line.trim())
|
|
238
|
+
.filter(Boolean)
|
|
239
|
+
.flatMap((line) => {
|
|
240
|
+
try {
|
|
241
|
+
return [JSON.parse(line)];
|
|
242
|
+
} catch {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function parseNodeEvents(events, workspaceDir) {
|
|
249
|
+
const testsByKey = new Map();
|
|
250
|
+
const summary = createSummary();
|
|
251
|
+
let coverage = null;
|
|
252
|
+
|
|
253
|
+
for (const event of events) {
|
|
254
|
+
if (event.type === 'test:coverage' && event.data?.summary) {
|
|
255
|
+
coverage = normalizeNodeCoverage(event.data.summary, workspaceDir);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (event.type === 'test:summary' && event.data?.counts) {
|
|
260
|
+
summary.total = event.data.counts.tests || 0;
|
|
261
|
+
summary.passed = event.data.counts.passed || 0;
|
|
262
|
+
summary.failed = event.data.counts.failed || 0;
|
|
263
|
+
summary.skipped = (event.data.counts.skipped || 0) + (event.data.counts.todo || 0);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!['test:pass', 'test:fail', 'test:skip'].includes(event.type)) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const data = event.data || {};
|
|
272
|
+
if (isNodeFileHarnessEvent(data, workspaceDir)) {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const filePath = resolveMaybeRelative(workspaceDir, data.file);
|
|
277
|
+
const key = [filePath, data.line, data.column, data.name, data.testNumber].join(':');
|
|
278
|
+
testsByKey.set(key, {
|
|
279
|
+
name: data.name,
|
|
280
|
+
fullName: data.name,
|
|
281
|
+
status: data.skip || event.type === 'test:skip'
|
|
282
|
+
? 'skipped'
|
|
283
|
+
: event.type === 'test:pass'
|
|
284
|
+
? 'passed'
|
|
285
|
+
: 'failed',
|
|
286
|
+
durationMs: Number.isFinite(data.details?.duration_ms) ? Math.round(data.details.duration_ms) : 0,
|
|
287
|
+
file: filePath,
|
|
288
|
+
line: Number.isFinite(data.line) ? data.line : null,
|
|
289
|
+
column: Number.isFinite(data.column) ? data.column : null,
|
|
290
|
+
failureMessages: serializeNodeFailure(data.details?.error),
|
|
291
|
+
rawDetails: data.details?.error ? { error: data.details.error } : {},
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
coverage,
|
|
297
|
+
summary: summary.total > 0 ? summary : summarizeTests(Array.from(testsByKey.values())),
|
|
298
|
+
tests: Array.from(testsByKey.values()).sort(sortTests),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function isNodeFileHarnessEvent(data, workspaceDir) {
|
|
303
|
+
if (!data?.file || typeof data.name !== 'string') {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
const resolvedFile = realPathSafe(resolveMaybeRelative(workspaceDir, data.file));
|
|
307
|
+
const resolvedName = realPathSafe(resolveMaybeRelative(workspaceDir, data.name));
|
|
308
|
+
return resolvedFile === resolvedName || data.line === 1;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function resolveMaybeRelative(baseDir, filePath) {
|
|
312
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
if (path.isAbsolute(filePath)) {
|
|
316
|
+
return realPathSafe(filePath);
|
|
317
|
+
}
|
|
318
|
+
return realPathSafe(path.resolve(baseDir, filePath));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function realPathSafe(filePath) {
|
|
322
|
+
try {
|
|
323
|
+
return fs.realpathSync(filePath);
|
|
324
|
+
} catch {
|
|
325
|
+
return path.resolve(filePath);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function serializeNodeFailure(error) {
|
|
330
|
+
if (!error) {
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const messages = [];
|
|
335
|
+
if (typeof error.message === 'string' && error.message.length > 0) {
|
|
336
|
+
messages.push(error.message);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (error.cause && typeof error.cause === 'object') {
|
|
340
|
+
if (typeof error.cause.message === 'string' && error.cause.message.length > 0) {
|
|
341
|
+
messages.push(error.cause.message);
|
|
342
|
+
} else if ('expected' in error.cause || 'actual' in error.cause) {
|
|
343
|
+
messages.push(`expected ${safePreview(error.cause.expected)} but received ${safePreview(error.cause.actual)}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (messages.length === 0) {
|
|
348
|
+
messages.push(trimForReport(JSON.stringify(error, null, 2), 800));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return Array.from(new Set(messages));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function safePreview(value) {
|
|
355
|
+
if (typeof value === 'string') {
|
|
356
|
+
return JSON.stringify(value);
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
return JSON.stringify(value);
|
|
360
|
+
} catch {
|
|
361
|
+
return String(value);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function normalizeNodeCoverage(summary, workspaceDir) {
|
|
366
|
+
const files = (summary.files || [])
|
|
367
|
+
.map((entry) => ({
|
|
368
|
+
path: realPathSafe(entry.path),
|
|
369
|
+
lines: createCoverageMetric(entry.coveredLineCount, entry.totalLineCount),
|
|
370
|
+
branches: createCoverageMetric(entry.coveredBranchCount, entry.totalBranchCount),
|
|
371
|
+
functions: createCoverageMetric(entry.coveredFunctionCount, entry.totalFunctionCount),
|
|
372
|
+
statements: null,
|
|
373
|
+
}))
|
|
374
|
+
.filter((entry) => shouldIncludeCoverageFile(entry.path, workspaceDir));
|
|
375
|
+
|
|
376
|
+
return createCoverageSummary(files);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function shouldIncludeCoverageFile(filePath, workspaceDir) {
|
|
380
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
const resolved = realPathSafe(filePath);
|
|
384
|
+
const workspaceRoot = realPathSafe(workspaceDir);
|
|
385
|
+
if (!resolved.startsWith(workspaceRoot)) {
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
if (
|
|
389
|
+
/(^|\/)(node_modules|coverage|artifacts|playwright-report|test-results)(\/|$)/.test(resolved)
|
|
390
|
+
) {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function createCoverageSummary(files) {
|
|
397
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
lines: aggregateCoverageMetric(files, 'lines'),
|
|
403
|
+
statements: aggregateCoverageMetric(files, 'statements'),
|
|
404
|
+
functions: aggregateCoverageMetric(files, 'functions'),
|
|
405
|
+
branches: aggregateCoverageMetric(files, 'branches'),
|
|
406
|
+
files: files.sort((left, right) => {
|
|
407
|
+
const leftPct = left.lines?.pct ?? 100;
|
|
408
|
+
const rightPct = right.lines?.pct ?? 100;
|
|
409
|
+
if (leftPct !== rightPct) {
|
|
410
|
+
return leftPct - rightPct;
|
|
411
|
+
}
|
|
412
|
+
return left.path.localeCompare(right.path);
|
|
413
|
+
}),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function aggregateCoverageMetric(files, metricKey) {
|
|
418
|
+
const valid = files.map((file) => file?.[metricKey]).filter((metric) => metric && Number.isFinite(metric.total));
|
|
419
|
+
if (valid.length === 0) {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
const total = valid.reduce((sum, metric) => sum + metric.total, 0);
|
|
423
|
+
const covered = valid.reduce((sum, metric) => sum + metric.covered, 0);
|
|
424
|
+
return createCoverageMetric(covered, total);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function createCoverageMetric(covered, total) {
|
|
428
|
+
if (!Number.isFinite(total)) {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
const safeTotal = Math.max(0, total);
|
|
432
|
+
const safeCovered = Number.isFinite(covered) ? Math.max(0, Math.min(safeTotal, covered)) : 0;
|
|
433
|
+
const pct = safeTotal === 0 ? 100 : Number(((safeCovered / safeTotal) * 100).toFixed(2));
|
|
434
|
+
return {
|
|
435
|
+
covered: safeCovered,
|
|
436
|
+
total: safeTotal,
|
|
437
|
+
pct,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function summarizeTests(tests) {
|
|
442
|
+
return tests.reduce((acc, entry) => {
|
|
443
|
+
acc.total += 1;
|
|
444
|
+
if (entry.status === 'failed') acc.failed += 1;
|
|
445
|
+
else if (entry.status === 'skipped') acc.skipped += 1;
|
|
446
|
+
else acc.passed += 1;
|
|
447
|
+
return acc;
|
|
448
|
+
}, createSummary());
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function createSummary(values = {}) {
|
|
452
|
+
return {
|
|
453
|
+
total: Number.isFinite(values.total) ? values.total : 0,
|
|
454
|
+
passed: Number.isFinite(values.passed) ? values.passed : 0,
|
|
455
|
+
failed: Number.isFinite(values.failed) ? values.failed : 0,
|
|
456
|
+
skipped: Number.isFinite(values.skipped) ? values.skipped : 0,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function deriveSuiteStatus(summary, exitCode) {
|
|
461
|
+
if (exitCode !== 0 || summary.failed > 0) {
|
|
462
|
+
return 'failed';
|
|
463
|
+
}
|
|
464
|
+
if (summary.total === 0 || summary.skipped === summary.total) {
|
|
465
|
+
return 'skipped';
|
|
466
|
+
}
|
|
467
|
+
return 'passed';
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function sortTests(left, right) {
|
|
471
|
+
const leftFile = left.file || '';
|
|
472
|
+
const rightFile = right.file || '';
|
|
473
|
+
if (leftFile !== rightFile) {
|
|
474
|
+
return leftFile.localeCompare(rightFile);
|
|
475
|
+
}
|
|
476
|
+
if ((left.line || 0) !== (right.line || 0)) {
|
|
477
|
+
return (left.line || 0) - (right.line || 0);
|
|
478
|
+
}
|
|
479
|
+
return left.name.localeCompare(right.name);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function trimForReport(value, limit) {
|
|
483
|
+
if (typeof value !== 'string') {
|
|
484
|
+
return '';
|
|
485
|
+
}
|
|
486
|
+
if (value.length <= limit) {
|
|
487
|
+
return value;
|
|
488
|
+
}
|
|
489
|
+
return `${value.slice(0, limit - 1)}…`;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function slugify(value) {
|
|
493
|
+
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function sanitizeEnv(env) {
|
|
497
|
+
const nextEnv = { ...env };
|
|
498
|
+
delete nextEnv.NODE_TEST_CONTEXT;
|
|
499
|
+
return nextEnv;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function resolveSuiteEnv(suiteEnv) {
|
|
503
|
+
return {
|
|
504
|
+
...sanitizeEnv(process.env),
|
|
505
|
+
...normalizeEnvRecord(suiteEnv),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function normalizeEnvRecord(env) {
|
|
510
|
+
if (!env || typeof env !== 'object') {
|
|
511
|
+
return {};
|
|
512
|
+
}
|
|
513
|
+
const normalized = {};
|
|
514
|
+
for (const [key, value] of Object.entries(env)) {
|
|
515
|
+
if (value === undefined || value === null) {
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
normalized[key] = String(value);
|
|
519
|
+
}
|
|
520
|
+
return normalized;
|
|
521
|
+
}
|