@test-station/adapter-shell 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 +361 -0
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@test-station/adapter-shell",
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,361 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ export const id = 'shell';
4
+ export const description = 'Shell command adapter';
5
+
6
+ export function createShellAdapter() {
7
+ return {
8
+ id,
9
+ description,
10
+ phase: 3,
11
+ async run({ project, suite }) {
12
+ const commandSpec = parseCommandSpec(suite.command);
13
+ const execution = await spawnCommand(commandSpec.command, commandSpec.args, {
14
+ cwd: suite.cwd || project.rootDir,
15
+ env: resolveSuiteEnv(suite.env),
16
+ });
17
+ if (suite.resultFormat === 'single-check-json-v1') {
18
+ return buildSingleCheckJsonResult(project, suite, execution);
19
+ }
20
+
21
+ const combinedOutput = [execution.stdout, execution.stderr].filter(Boolean).join('\n');
22
+ const parsedSummary = parseShellSummary(combinedOutput);
23
+ const syntheticStatus = execution.exitCode === 0 ? 'passed' : 'failed';
24
+ const syntheticTest = {
25
+ name: `${suite.label} completed`,
26
+ fullName: `${suite.packageName || 'default'} ${suite.label} completed`,
27
+ status: syntheticStatus,
28
+ durationMs: execution.durationMs,
29
+ file: null,
30
+ line: null,
31
+ column: null,
32
+ assertions: ['Shell command completed without adapter-level execution errors.'],
33
+ setup: [],
34
+ mocks: [],
35
+ failureMessages: execution.exitCode === 0 ? [] : [execution.stderr || execution.stdout || 'Shell command failed.'],
36
+ rawDetails: {
37
+ stdout: trimForReport(execution.stdout, 2000),
38
+ stderr: trimForReport(execution.stderr, 2000),
39
+ },
40
+ };
41
+ const summary = parsedSummary.total > 0
42
+ ? parsedSummary
43
+ : createSummary({
44
+ total: 1,
45
+ passed: syntheticStatus === 'passed' ? 1 : 0,
46
+ failed: syntheticStatus === 'failed' ? 1 : 0,
47
+ skipped: 0,
48
+ });
49
+
50
+ return {
51
+ status: syntheticStatus,
52
+ durationMs: execution.durationMs,
53
+ summary,
54
+ coverage: null,
55
+ tests: [syntheticTest],
56
+ warnings: [],
57
+ output: {
58
+ stdout: execution.stdout,
59
+ stderr: execution.stderr,
60
+ },
61
+ rawArtifacts: [
62
+ {
63
+ relativePath: `${slugify(suite.packageName || 'default')}-${slugify(suite.id)}-shell.log`,
64
+ content: combinedOutput,
65
+ },
66
+ ],
67
+ };
68
+ },
69
+ };
70
+ }
71
+
72
+ function buildSingleCheckJsonResult(project, suite, execution) {
73
+ const payload = parseJsonSafe(execution.stdout);
74
+ const options = suite.resultFormatOptions || {};
75
+ const status = deriveJsonCheckStatus(execution, payload, options);
76
+ const combinedOutput = [execution.stdout, execution.stderr].filter(Boolean).join('\n');
77
+ const testName = options.name || suite.label || `${suite.id || 'suite'} completed`;
78
+ const fullName = options.fullName || `${suite.packageName || 'default'} ${testName}`;
79
+ const failureMessage = status === 'failed'
80
+ ? deriveJsonCheckFailureMessage(execution, payload, options)
81
+ : null;
82
+
83
+ return {
84
+ status,
85
+ durationMs: execution.durationMs,
86
+ summary: createSummary({
87
+ total: 1,
88
+ passed: status === 'passed' ? 1 : 0,
89
+ failed: status === 'failed' ? 1 : 0,
90
+ skipped: 0,
91
+ }),
92
+ coverage: null,
93
+ tests: [
94
+ {
95
+ name: testName,
96
+ fullName,
97
+ status,
98
+ durationMs: execution.durationMs,
99
+ file: options.file || null,
100
+ line: normalizeNumber(options.line),
101
+ column: normalizeNumber(options.column),
102
+ assertions: normalizeStringArray(options.assertions),
103
+ setup: normalizeStringArray(options.setup),
104
+ mocks: normalizeStringArray(options.mocks),
105
+ failureMessages: failureMessage ? [failureMessage] : [],
106
+ rawDetails: selectRawDetails(payload, options),
107
+ module: options.module || null,
108
+ theme: options.theme || null,
109
+ classificationSource: options.classificationSource || 'adapter',
110
+ },
111
+ ],
112
+ warnings: collectJsonCheckWarnings(payload, options),
113
+ output: {
114
+ stdout: execution.stdout,
115
+ stderr: execution.stderr,
116
+ },
117
+ rawArtifacts: [
118
+ {
119
+ relativePath: options.artifactFileName || `${slugify(suite.packageName || 'default')}-${slugify(suite.id)}-shell.json`,
120
+ content: execution.stdout || '{}\n',
121
+ },
122
+ ],
123
+ };
124
+ }
125
+
126
+ function parseCommandSpec(command) {
127
+ if (Array.isArray(command) && command.length > 0) {
128
+ return { command: String(command[0]), args: command.slice(1).map((entry) => String(entry)) };
129
+ }
130
+ if (typeof command === 'string' && command.trim().length > 0) {
131
+ const tokens = tokenizeCommand(command);
132
+ return { command: tokens[0], args: tokens.slice(1) };
133
+ }
134
+ throw new Error('Shell adapter requires suite.command as a non-empty string or array.');
135
+ }
136
+
137
+ function tokenizeCommand(command) {
138
+ const tokens = [];
139
+ let current = '';
140
+ let quote = null;
141
+ for (let index = 0; index < command.length; index += 1) {
142
+ const char = command[index];
143
+ if (quote) {
144
+ if (char === quote) {
145
+ quote = null;
146
+ } else if (char === '\\' && quote === '"' && index + 1 < command.length) {
147
+ current += command[index + 1];
148
+ index += 1;
149
+ } else {
150
+ current += char;
151
+ }
152
+ continue;
153
+ }
154
+ if (char === '"' || char === "'") {
155
+ quote = char;
156
+ continue;
157
+ }
158
+ if (/\s/.test(char)) {
159
+ if (current.length > 0) {
160
+ tokens.push(current);
161
+ current = '';
162
+ }
163
+ continue;
164
+ }
165
+ current += char;
166
+ }
167
+ if (current.length > 0) {
168
+ tokens.push(current);
169
+ }
170
+ return tokens;
171
+ }
172
+
173
+ function spawnCommand(command, args, options) {
174
+ return new Promise((resolve) => {
175
+ const startedAt = Date.now();
176
+ const child = spawn(command, args, {
177
+ cwd: options.cwd,
178
+ env: options.env,
179
+ stdio: ['ignore', 'pipe', 'pipe'],
180
+ });
181
+
182
+ let stdout = '';
183
+ let stderr = '';
184
+ child.stdout.on('data', (chunk) => {
185
+ stdout += chunk.toString();
186
+ });
187
+ child.stderr.on('data', (chunk) => {
188
+ stderr += chunk.toString();
189
+ });
190
+ child.on('error', (error) => {
191
+ stderr += `${error.message}\n`;
192
+ resolve({ exitCode: 1, stdout, stderr, durationMs: Date.now() - startedAt });
193
+ });
194
+ child.on('close', (code) => {
195
+ resolve({
196
+ exitCode: Number.isInteger(code) ? code : 1,
197
+ stdout,
198
+ stderr,
199
+ durationMs: Date.now() - startedAt,
200
+ });
201
+ });
202
+ });
203
+ }
204
+
205
+ function parseShellSummary(output) {
206
+ const lines = String(output || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
207
+ const matchers = [
208
+ /^tests?\s+(\d+)\s*\|\s*pass\s+(\d+)\s*\|\s*fail\s+(\d+)\s*\|\s*skip\s+(\d+)$/i,
209
+ /^Tests:\s*(?:.*?)(\d+)\s+total.*?(\d+)\s+passed.*?(\d+)\s+failed.*?(\d+)\s+(?:skipped|pending|todo)/i,
210
+ ];
211
+
212
+ for (const line of lines.reverse()) {
213
+ for (const matcher of matchers) {
214
+ const matched = line.match(matcher);
215
+ if (matched) {
216
+ return createSummary({
217
+ total: Number(matched[1]),
218
+ passed: Number(matched[2]),
219
+ failed: Number(matched[3]),
220
+ skipped: Number(matched[4]),
221
+ });
222
+ }
223
+ }
224
+ }
225
+
226
+ return createSummary();
227
+ }
228
+
229
+ function createSummary(values = {}) {
230
+ return {
231
+ total: Number.isFinite(values.total) ? values.total : 0,
232
+ passed: Number.isFinite(values.passed) ? values.passed : 0,
233
+ failed: Number.isFinite(values.failed) ? values.failed : 0,
234
+ skipped: Number.isFinite(values.skipped) ? values.skipped : 0,
235
+ };
236
+ }
237
+
238
+ function collectJsonCheckWarnings(payload, options) {
239
+ return (Array.isArray(options.warningFields) ? options.warningFields : [])
240
+ .map((entry) => formatJsonCheckWarning(payload, entry))
241
+ .filter(Boolean);
242
+ }
243
+
244
+ function formatJsonCheckWarning(payload, entry) {
245
+ const field = entry?.field;
246
+ if (!field) {
247
+ return null;
248
+ }
249
+ const value = payload?.[field];
250
+ const label = String(entry?.label || field);
251
+ const mode = entry?.mode || inferWarningMode(value);
252
+
253
+ if (mode === 'count-array') {
254
+ return Array.isArray(value) && value.length > 0 ? `${value.length} ${label}` : null;
255
+ }
256
+ if (mode === 'count-number') {
257
+ return Number.isFinite(value) && value > 0 ? `${value} ${label}` : null;
258
+ }
259
+ if (mode === 'truthy') {
260
+ return value ? label : null;
261
+ }
262
+
263
+ return null;
264
+ }
265
+
266
+ function inferWarningMode(value) {
267
+ if (Array.isArray(value)) {
268
+ return 'count-array';
269
+ }
270
+ if (typeof value === 'number') {
271
+ return 'count-number';
272
+ }
273
+ return 'truthy';
274
+ }
275
+
276
+ function deriveJsonCheckStatus(execution, payload, options) {
277
+ if (typeof options.statusField === 'string' && payload?.[options.statusField]) {
278
+ return String(payload[options.statusField]);
279
+ }
280
+ return execution.exitCode === 0 ? 'passed' : 'failed';
281
+ }
282
+
283
+ function deriveJsonCheckFailureMessage(execution, payload, options) {
284
+ if (typeof options.failureMessageField === 'string' && payload?.[options.failureMessageField]) {
285
+ return String(payload[options.failureMessageField]);
286
+ }
287
+ if (typeof payload?.message === 'string' && payload.message.trim()) {
288
+ return payload.message;
289
+ }
290
+ return execution.stderr || execution.stdout || 'Shell command failed.';
291
+ }
292
+
293
+ function selectRawDetails(payload, options) {
294
+ const fields = Array.isArray(options.rawDetailsFields) ? options.rawDetailsFields : null;
295
+ if (!fields || fields.length === 0) {
296
+ return payload;
297
+ }
298
+ const selected = {};
299
+ for (const field of fields) {
300
+ if (Object.prototype.hasOwnProperty.call(payload || {}, field)) {
301
+ selected[field] = payload[field];
302
+ }
303
+ }
304
+ return selected;
305
+ }
306
+
307
+ function parseJsonSafe(value) {
308
+ try {
309
+ return JSON.parse(String(value || '{}'));
310
+ } catch {
311
+ return {};
312
+ }
313
+ }
314
+
315
+ function normalizeStringArray(values) {
316
+ return (Array.isArray(values) ? values : [])
317
+ .map((entry) => String(entry || '').trim())
318
+ .filter(Boolean);
319
+ }
320
+
321
+ function normalizeNumber(value) {
322
+ return Number.isFinite(value) ? value : null;
323
+ }
324
+
325
+ function trimForReport(value, limit) {
326
+ if (typeof value !== 'string') {
327
+ return '';
328
+ }
329
+ return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`;
330
+ }
331
+
332
+ function slugify(value) {
333
+ return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
334
+ }
335
+
336
+ function sanitizeEnv(env) {
337
+ const nextEnv = { ...env };
338
+ delete nextEnv.NODE_TEST_CONTEXT;
339
+ return nextEnv;
340
+ }
341
+
342
+ function resolveSuiteEnv(suiteEnv) {
343
+ return {
344
+ ...sanitizeEnv(process.env),
345
+ ...normalizeEnvRecord(suiteEnv),
346
+ };
347
+ }
348
+
349
+ function normalizeEnvRecord(env) {
350
+ if (!env || typeof env !== 'object') {
351
+ return {};
352
+ }
353
+ const normalized = {};
354
+ for (const [key, value] of Object.entries(env)) {
355
+ if (value === undefined || value === null) {
356
+ continue;
357
+ }
358
+ normalized[key] = String(value);
359
+ }
360
+ return normalized;
361
+ }