@testomatio/reporter 2.20.0 → 2.21.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.
@@ -1,8 +1,11 @@
1
+ import { createRequire } from 'module';
1
2
  import { playwrightLogsMarkers } from './adapter/utils/playwright.js';
2
3
  import { isPlaywright } from './helpers.js';
3
4
  import { services } from './services/index.js';
4
5
  import pc from 'picocolors';
5
6
 
7
+ const requireModule = createRequire(`${process.cwd()}/package.json`);
8
+
6
9
  /**
7
10
  * Stores path to file as artifact and uploads it to the S3 storage
8
11
  * @param {string | {path: string, type: string, name: string}} data - path to file or object with path, type and name
@@ -44,6 +47,36 @@ function addStep(message, logs) {
44
47
  else services.logger.step(message, logs);
45
48
  }
46
49
 
50
+ /**
51
+ * Returns the currently running Vitest test task, so `meta()` (and similar
52
+ * functions) can attach data to it. The task `.meta` object is serialized by
53
+ * Vitest back to the reporter (works across all pools: threads, forks, vmThreads).
54
+ *
55
+ * Resolution order matters:
56
+ * 1. `globalThis.__vitest_worker__.current` — set by Vitest in every worker and
57
+ * independent of module resolution, so it works even when the reporter is the
58
+ * externalized package and `@vitest/runner` is deduped to a different instance
59
+ * (monorepos, pnpm, hoisting). This is also populated inside hooks (afterEach),
60
+ * pointing at the test that the hook belongs to.
61
+ * 2. `@vitest/runner` `getCurrentTest()` — fallback for setups/versions where the
62
+ * worker global is not exposed.
63
+ *
64
+ * @returns {any | null} Vitest test task or null when no test is active
65
+ */
66
+ function getCurrentVitestTest() {
67
+ // @ts-ignore - injected by Vitest into the worker global scope
68
+ const worker = globalThis.__vitest_worker__;
69
+ const current = worker?.current;
70
+ if (current && (current.type === 'test' || current.type === undefined)) return current;
71
+
72
+ try {
73
+ const test = requireModule('@vitest/runner').getCurrentTest?.();
74
+ if (test) return test;
75
+ } catch {}
76
+
77
+ return null;
78
+ }
79
+
47
80
  /**
48
81
  * Add key-value pair(s) to the test report
49
82
  * @param {{[key: string]: string} | string} keyValue - object { key: value } (multiple props allowed) OR key (string)
@@ -67,6 +100,14 @@ function setKeyValue(keyValue, value = undefined) {
67
100
  return;
68
101
  }
69
102
 
103
+ if (process.env.VITEST || process.env.VITEST_WORKER_ID) {
104
+ const vitestTest = getCurrentVitestTest();
105
+ if (vitestTest) {
106
+ Object.assign((vitestTest.meta ||= {}), keyValue);
107
+ return;
108
+ }
109
+ }
110
+
70
111
  // in this case keyValue is expected to be an object
71
112
  services.keyValues.put(keyValue);
72
113
  }
package/src/uploader.js CHANGED
@@ -347,6 +347,33 @@ export class S3Uploader {
347
347
  );
348
348
  }
349
349
 
350
+ /**
351
+ * Download an artifact by its link: from the configured bucket through S3, otherwise over plain HTTP.
352
+ *
353
+ * @param {string} link
354
+ * @param {string} filePath
355
+ */
356
+ async downloadFile(link, filePath) {
357
+ const { S3_BUCKET } = this.getConfig();
358
+ const Key = getS3KeyFromUrl(link, S3_BUCKET);
359
+ let body;
360
+
361
+ if (Key) {
362
+ debug('Downloading from S3:', Key);
363
+ const s3 = new S3(this.#getS3Config());
364
+ const object = await s3.getObject({ Bucket: S3_BUCKET, Key });
365
+ body = Buffer.from(await object.Body.transformToByteArray());
366
+ } else {
367
+ debug('Downloading over HTTP:', link);
368
+ const response = await fetch(link);
369
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
370
+ body = Buffer.from(await response.arrayBuffer());
371
+ }
372
+
373
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
374
+ fs.writeFileSync(filePath, body);
375
+ }
376
+
350
377
  async getS3LocationLink(out) {
351
378
  const response = await out.done();
352
379
 
@@ -409,3 +436,27 @@ export class S3Uploader {
409
436
  return cfg;
410
437
  }
411
438
  }
439
+
440
+ /**
441
+ * Object key of an artifact link in the given bucket, for both virtual-hosted (bucket.host/key)
442
+ * and path style (host/bucket/key) links; null when the link points to another bucket.
443
+ *
444
+ * @param {string} link
445
+ * @param {string} [bucket]
446
+ * @returns {string|null}
447
+ */
448
+ export function getS3KeyFromUrl(link, bucket) {
449
+ if (!bucket) return null;
450
+
451
+ let url;
452
+ try {
453
+ url = new URL(link);
454
+ } catch (e) {
455
+ return null;
456
+ }
457
+
458
+ const objectPath = decodeURIComponent(url.pathname.slice(1));
459
+ if (url.hostname.startsWith(`${bucket}.`)) return objectPath || null;
460
+ if (objectPath.startsWith(`${bucket}/`)) return objectPath.slice(bucket.length + 1) || null;
461
+ return null;
462
+ }
@@ -0,0 +1,151 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import pc from 'picocolors';
4
+
5
+ const LAUNCHER_PREFIXES = ['npx', 'npm', 'yarn', 'pnpm', 'bun', 'node', 'bunx'];
6
+
7
+ const PLACEHOLDER_PHRASE_RE = /^<?-*(?:run|your|test)\s+(?:[\w-]+\s+)?(?:tests?|command|runner)>?$/i;
8
+
9
+ const RUNNERS = [
10
+ {
11
+ name: 'Playwright',
12
+ configs: ['playwright.config.js', 'playwright.config.ts', 'playwright.config.mjs', 'playwright.config.cjs'],
13
+ dependencies: ['@playwright/test'],
14
+ command: 'npx playwright test',
15
+ },
16
+ {
17
+ name: 'CodeceptJS',
18
+ configs: ['codecept.conf.js', 'codecept.conf.ts'],
19
+ dependencies: ['codeceptjs'],
20
+ command: 'npx codeceptjs run',
21
+ },
22
+ {
23
+ name: 'WebdriverIO',
24
+ configs: ['wdio.conf.js', 'wdio.conf.ts'],
25
+ dependencies: ['@wdio/cli'],
26
+ dependencyPrefix: '@wdio/',
27
+ command: 'npx wdio run ./wdio.conf.js',
28
+ },
29
+ {
30
+ name: 'Cypress',
31
+ configs: ['cypress.config.js', 'cypress.config.ts'],
32
+ dependencies: ['cypress'],
33
+ command: 'npx cypress run',
34
+ },
35
+ {
36
+ name: 'Jest',
37
+ configs: ['jest.config.js', 'jest.config.ts', 'jest.config.mjs', 'jest.config.cjs', 'jest.config.json'],
38
+ dependencies: ['jest'],
39
+ command: 'npx jest',
40
+ },
41
+ {
42
+ name: 'Vitest',
43
+ configs: ['vitest.config.js', 'vitest.config.ts', 'vitest.config.mjs'],
44
+ dependencies: ['vitest'],
45
+ command: 'npx vitest run',
46
+ },
47
+ {
48
+ name: 'Mocha',
49
+ configs: ['.mocharc.js', '.mocharc.cjs', '.mocharc.json', '.mocharc.yml', '.mocharc.yaml'],
50
+ dependencies: ['mocha'],
51
+ command: 'npx mocha',
52
+ },
53
+ {
54
+ name: 'Cucumber',
55
+ configs: ['cucumber.js', 'cucumber.cjs'],
56
+ dependencies: ['@cucumber/cucumber'],
57
+ command: 'npx cucumber-js',
58
+ },
59
+ ];
60
+
61
+ /**
62
+ * @param {string} command
63
+ * @returns {boolean}
64
+ */
65
+ function isPlaceholderCommand(command) {
66
+ const cmd = (command || '').trim();
67
+ if (!cmd) return false;
68
+
69
+ if (cmd.includes('<') || cmd.includes('>')) return true;
70
+
71
+ const firstToken = cmd.split(/\s+/)[0].toLowerCase();
72
+ if (LAUNCHER_PREFIXES.includes(firstToken)) return false;
73
+
74
+ return PLACEHOLDER_PHRASE_RE.test(cmd);
75
+ }
76
+
77
+ /**
78
+ * @param {string} cwd
79
+ * @returns {Record<string, string>}
80
+ */
81
+ function readPackageDependencies(cwd) {
82
+ try {
83
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
84
+ return { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
85
+ } catch {
86
+ return {};
87
+ }
88
+ }
89
+
90
+ /**
91
+ * @param {string} [cwd]
92
+ * @returns {{ name: string, command: string } | null}
93
+ */
94
+ function detectRunner(cwd = process.cwd()) {
95
+ const dependencies = readPackageDependencies(cwd);
96
+
97
+ for (const runner of RUNNERS) {
98
+ const config = runner.configs.find(file => fs.existsSync(path.join(cwd, file)));
99
+
100
+ const hasDependency = runner.dependencies?.some(dep => dependencies[dep])
101
+ || (runner.dependencyPrefix && Object.keys(dependencies).some(dep => dep.startsWith(runner.dependencyPrefix)));
102
+
103
+ if (!config && !hasDependency) continue;
104
+
105
+ let command = runner.command;
106
+ if (runner.name === 'WebdriverIO') {
107
+ command = `npx wdio run ./${config || 'wdio.conf.js'}`;
108
+ }
109
+
110
+ return { name: runner.name, command };
111
+ }
112
+
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * @param {string} [cwd]
118
+ * @returns {string}
119
+ */
120
+ function runnerHint(cwd = process.cwd()) {
121
+ const runner = detectRunner(cwd);
122
+ if (runner) {
123
+ return `💡 Looks like this project uses ${runner.name}. Try: ${pc.green(runner.command)}`;
124
+ }
125
+ return `💡 Replace the placeholder with your test runner command, e.g. ${pc.green('npx playwright test')}`;
126
+ }
127
+
128
+ /**
129
+ * @param {string} command
130
+ * @returns {string[]}
131
+ */
132
+ function buildFilterWarning(command) {
133
+ return [
134
+ '⚠️ The runner exited with a non-zero code while a Testomat.io filter was applied.',
135
+ 'Your command was modified to run only tests matching Testomat IDs:',
136
+ ` ${pc.bold(command)}`,
137
+ 'This usually means no local tests matched the filter:',
138
+ ' - tests are not annotated with the matching @Txxxxxx IDs in their titles',
139
+ ' - the filter selected tests from another repository or branch',
140
+ ' - the test runner does not support the appended --grep / --testNamePattern option',
141
+ '💡 Hint: run the printed command without the appended filter option to see all your tests,',
142
+ 'then annotate them with Testomat IDs.',
143
+ ];
144
+ }
145
+
146
+ export {
147
+ isPlaceholderCommand,
148
+ detectRunner,
149
+ runnerHint,
150
+ buildFilterWarning,
151
+ };