@test-station/adapter-node-test 0.1.8 → 0.2.10

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 (3) hide show
  1. package/README.md +30 -1
  2. package/package.json +1 -1
  3. package/src/index.js +221 -8
package/README.md CHANGED
@@ -14,7 +14,7 @@ npm install --save-dev @test-station/adapter-node-test
14
14
 
15
15
  - runs `node --test` suites and normalizes the output into the shared report model
16
16
  - injects Node's built-in `--test-reporter` flag for NDJSON capture
17
- - supports coverage when the suite command is a direct `node --test ...` invocation
17
+ - supports coverage for direct `node --test ...` commands and supported package-script wrappers
18
18
  - writes raw NDJSON artifacts under `raw/`
19
19
 
20
20
  ## Direct Use
@@ -26,3 +26,32 @@ const adapter = createNodeTestAdapter();
26
26
  ```
27
27
 
28
28
  Use adapter id `node-test` in `test-station.config.mjs`.
29
+
30
+ ## Supported Coverage Patterns
31
+
32
+ Coverage collection works when the adapter can safely resolve the executed command to a single `node --test ...` invocation. Supported patterns are:
33
+
34
+ - direct commands such as `['node', '--test', 'tests/**/*.test.js']`
35
+ - package scripts invoked with `yarn`, `npm run`, or `pnpm run` when the script itself resolves directly to `node --test ...`
36
+ - explicit `suite.coverage.command` values that resolve to one of the patterns above
37
+
38
+ Examples:
39
+
40
+ ```js
41
+ {
42
+ adapter: 'node-test',
43
+ command: ['yarn', 'test:runtime'],
44
+ }
45
+ ```
46
+
47
+ ```js
48
+ {
49
+ adapter: 'node-test',
50
+ command: ['yarn', 'test:runtime'],
51
+ coverage: {
52
+ command: ['node', '--test', 'tests/runtime/**/*.test.js'],
53
+ },
54
+ }
55
+ ```
56
+
57
+ Shell-heavy wrappers such as chained commands (`&&`, `||`, pipes, redirections) are intentionally not treated as coverage-safe.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@test-station/adapter-node-test",
3
- "version": "0.1.8",
3
+ "version": "0.2.10",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/index.js CHANGED
@@ -14,10 +14,12 @@ export function createNodeTestAdapter() {
14
14
  phase: 3,
15
15
  async run({ project, suite, execution }) {
16
16
  const commandSpec = parseCommandSpec(suite.command);
17
- const directNodeTest = isDirectNodeTestCommand(commandSpec);
18
- const primaryExecution = await executeNodeTestRun(commandSpec, {
17
+ const resolvedCommand = resolveNodeTestCommand(commandSpec, {
19
18
  cwd: suite.cwd || project.rootDir,
20
- suiteEnv: suite.env,
19
+ });
20
+ const primaryExecution = await executeNodeTestRun(resolvedCommand.commandSpec, {
21
+ cwd: suite.cwd || project.rootDir,
22
+ suiteEnv: mergeEnvRecords(suite.env, resolvedCommand.env),
21
23
  rawRelativePath: `${slugify(suite.packageName || 'default')}-${slugify(suite.id)}-node.ndjson`,
22
24
  });
23
25
  const parsed = parseNodeEvents(parseNdjson(primaryExecution.stdout), suite.cwd || project.rootDir);
@@ -26,13 +28,20 @@ export function createNodeTestAdapter() {
26
28
  let coverageArtifact = null;
27
29
 
28
30
  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
+ const coverageCommandSpec = suite?.coverage?.command
32
+ ? parseCommandSpec(suite.coverage.command)
33
+ : commandSpec;
34
+ const resolvedCoverageCommand = resolveNodeTestCommand(coverageCommandSpec, {
35
+ cwd: suite.cwd || project.rootDir,
36
+ });
37
+
38
+ if (!resolvedCoverageCommand.directNodeTest) {
39
+ warnings.push(createUnsupportedCoverageWarning(Boolean(suite?.coverage?.command)));
31
40
  } else {
32
- const coverageExecution = await executeNodeTestRun(commandSpec, {
41
+ const coverageExecution = await executeNodeTestRun(resolvedCoverageCommand.commandSpec, {
33
42
  cwd: suite.cwd || project.rootDir,
34
43
  enableCoverage: true,
35
- suiteEnv: suite.env,
44
+ suiteEnv: mergeEnvRecords(suite.env, resolvedCoverageCommand.env),
36
45
  rawRelativePath: `${slugify(suite.packageName || 'default')}-${slugify(suite.id)}-node-coverage.ndjson`,
37
46
  });
38
47
  const coverageParsed = parseNodeEvents(parseNdjson(coverageExecution.stdout), suite.cwd || project.rootDir);
@@ -153,10 +162,207 @@ function tokenizeCommand(command) {
153
162
  }
154
163
 
155
164
  function isDirectNodeTestCommand(commandSpec) {
156
- const binary = path.basename(commandSpec.command).toLowerCase();
165
+ const binary = normalizeBinaryName(commandSpec.command);
157
166
  return binary.startsWith('node') && commandSpec.args.includes('--test');
158
167
  }
159
168
 
169
+ function resolveNodeTestCommand(commandSpec, options = {}) {
170
+ const normalized = extractLeadingEnvAssignments(commandSpec);
171
+ if (isDirectNodeTestCommand(normalized.commandSpec)) {
172
+ return {
173
+ commandSpec: normalized.commandSpec,
174
+ env: normalized.env,
175
+ directNodeTest: true,
176
+ };
177
+ }
178
+
179
+ const packageScript = resolvePackageScriptNodeTestCommand(normalized.commandSpec, options);
180
+ if (packageScript) {
181
+ return {
182
+ commandSpec: packageScript.commandSpec,
183
+ env: mergeEnvRecords(normalized.env, packageScript.env),
184
+ directNodeTest: true,
185
+ };
186
+ }
187
+
188
+ return {
189
+ commandSpec: normalized.commandSpec,
190
+ env: normalized.env,
191
+ directNodeTest: false,
192
+ };
193
+ }
194
+
195
+ function resolvePackageScriptNodeTestCommand(commandSpec, options = {}) {
196
+ const invocation = parsePackageScriptInvocation(commandSpec);
197
+ if (!invocation) {
198
+ return null;
199
+ }
200
+
201
+ const packageJsonPath = findClosestPackageJson(options.cwd);
202
+ if (!packageJsonPath) {
203
+ return null;
204
+ }
205
+
206
+ const seenScripts = options.seenScripts || new Set();
207
+ const scriptKey = `${packageJsonPath}:${invocation.scriptName}`;
208
+ if (seenScripts.has(scriptKey)) {
209
+ return null;
210
+ }
211
+
212
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
213
+ const scriptValue = pkg?.scripts?.[invocation.scriptName];
214
+ if (typeof scriptValue !== 'string' || scriptValue.trim().length === 0) {
215
+ return null;
216
+ }
217
+
218
+ const scriptCommand = parseCommandSpec(scriptValue);
219
+ if (containsShellControlTokens([scriptCommand.command, ...scriptCommand.args])) {
220
+ return null;
221
+ }
222
+
223
+ const normalizedScript = extractLeadingEnvAssignments(scriptCommand);
224
+ const scriptCommandSpec = appendForwardedArgs(normalizedScript.commandSpec, invocation.forwardedArgs);
225
+ if (isDirectNodeTestCommand(scriptCommandSpec)) {
226
+ return {
227
+ commandSpec: scriptCommandSpec,
228
+ env: normalizedScript.env,
229
+ };
230
+ }
231
+
232
+ const nested = resolvePackageScriptNodeTestCommand(scriptCommandSpec, {
233
+ cwd: path.dirname(packageJsonPath),
234
+ seenScripts: new Set([...seenScripts, scriptKey]),
235
+ });
236
+ if (!nested) {
237
+ return null;
238
+ }
239
+
240
+ return {
241
+ commandSpec: nested.commandSpec,
242
+ env: mergeEnvRecords(normalizedScript.env, nested.env),
243
+ };
244
+ }
245
+
246
+ function extractLeadingEnvAssignments(commandSpec) {
247
+ const tokens = [commandSpec.command, ...commandSpec.args];
248
+ const env = {};
249
+ let index = 0;
250
+
251
+ while (index < tokens.length && isEnvAssignmentToken(tokens[index])) {
252
+ const token = tokens[index];
253
+ const equalsIndex = token.indexOf('=');
254
+ env[token.slice(0, equalsIndex)] = token.slice(equalsIndex + 1);
255
+ index += 1;
256
+ }
257
+
258
+ if (index >= tokens.length) {
259
+ return {
260
+ commandSpec,
261
+ env,
262
+ };
263
+ }
264
+
265
+ return {
266
+ commandSpec: {
267
+ command: tokens[index],
268
+ args: tokens.slice(index + 1),
269
+ },
270
+ env,
271
+ };
272
+ }
273
+
274
+ function parsePackageScriptInvocation(commandSpec) {
275
+ const binary = normalizeBinaryName(commandSpec.command);
276
+ const args = [...commandSpec.args];
277
+
278
+ if (binary === 'yarn') {
279
+ let scriptIndex = 0;
280
+ if (args[0] === 'run') {
281
+ scriptIndex = 1;
282
+ }
283
+ const scriptName = args[scriptIndex];
284
+ if (!scriptName || scriptName.startsWith('-')) {
285
+ return null;
286
+ }
287
+ return {
288
+ manager: 'yarn',
289
+ scriptName,
290
+ forwardedArgs: normalizeForwardedArgs(args.slice(scriptIndex + 1)),
291
+ };
292
+ }
293
+
294
+ if (binary === 'npm' || binary === 'pnpm') {
295
+ if (args[0] !== 'run') {
296
+ return null;
297
+ }
298
+ const scriptName = args[1];
299
+ if (!scriptName || scriptName.startsWith('-')) {
300
+ return null;
301
+ }
302
+ return {
303
+ manager: binary,
304
+ scriptName,
305
+ forwardedArgs: normalizeForwardedArgs(args.slice(2)),
306
+ };
307
+ }
308
+
309
+ return null;
310
+ }
311
+
312
+ function normalizeForwardedArgs(args) {
313
+ if (!Array.isArray(args) || args.length === 0) {
314
+ return [];
315
+ }
316
+ if (args[0] === '--') {
317
+ return args.slice(1);
318
+ }
319
+ return args;
320
+ }
321
+
322
+ function appendForwardedArgs(commandSpec, forwardedArgs) {
323
+ if (!Array.isArray(forwardedArgs) || forwardedArgs.length === 0) {
324
+ return commandSpec;
325
+ }
326
+ return {
327
+ command: commandSpec.command,
328
+ args: [...commandSpec.args, ...forwardedArgs],
329
+ };
330
+ }
331
+
332
+ function findClosestPackageJson(startDir) {
333
+ let current = path.resolve(startDir || process.cwd());
334
+ while (true) {
335
+ const candidate = path.join(current, 'package.json');
336
+ if (fs.existsSync(candidate)) {
337
+ return candidate;
338
+ }
339
+ const parent = path.dirname(current);
340
+ if (parent === current) {
341
+ return null;
342
+ }
343
+ current = parent;
344
+ }
345
+ }
346
+
347
+ function containsShellControlTokens(tokens) {
348
+ return tokens.some((token) => ['&&', '||', ';', '|', '>', '>>', '<', '2>', '2>>', '2>&1'].includes(token));
349
+ }
350
+
351
+ function isEnvAssignmentToken(token) {
352
+ return typeof token === 'string' && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token);
353
+ }
354
+
355
+ function normalizeBinaryName(command) {
356
+ return path.basename(command || '').toLowerCase().replace(/\.cmd$/, '');
357
+ }
358
+
359
+ function createUnsupportedCoverageWarning(hasCoverageCommand) {
360
+ if (hasCoverageCommand) {
361
+ return 'Coverage pass skipped for suite.coverage.command because it did not resolve to a supported node:test pattern.';
362
+ }
363
+ return 'Coverage pass skipped for wrapped node:test command; use a direct node --test invocation, a supported yarn/npm/pnpm package script, or suite.coverage.command.';
364
+ }
365
+
160
366
  function withReporterArgs(args, options = {}) {
161
367
  const filtered = [];
162
368
  for (let index = 0; index < args.length; index += 1) {
@@ -499,6 +705,13 @@ function sanitizeEnv(env) {
499
705
  return nextEnv;
500
706
  }
501
707
 
708
+ function mergeEnvRecords(...records) {
709
+ return records.reduce((merged, record) => ({
710
+ ...merged,
711
+ ...normalizeEnvRecord(record),
712
+ }), {});
713
+ }
714
+
502
715
  function resolveSuiteEnv(suiteEnv) {
503
716
  return {
504
717
  ...sanitizeEnv(process.env),