@testomatio/reporter 2.5.2 → 2.6.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,4 +1,4 @@
1
- import pc from 'picocolors';
1
+ import createDebugMessages from 'debug';
2
2
  import crypto from 'crypto';
3
3
  import os from 'os';
4
4
  import path from 'path';
@@ -10,8 +10,10 @@ import { getTestomatIdFromTestTitle, fileSystem } from '../utils/utils.js';
10
10
  import { services } from '../services/index.js';
11
11
  import { dataStorage } from '../data-storage.js';
12
12
  import { extensionMap } from '../utils/constants.js';
13
+ import pc from 'picocolors';
13
14
 
14
15
  const reportTestPromises = [];
16
+ const debug = createDebugMessages('@testomatio/reporter:adapter-playwright');
15
17
 
16
18
  class PlaywrightReporter {
17
19
  constructor(config = {}) {
@@ -55,13 +57,26 @@ class PlaywrightReporter {
55
57
  const tags = extractTags(test);
56
58
 
57
59
  const fullTestTitle = getTestContextName(test);
60
+
58
61
  let logs = '';
59
- if (result.stderr.length || result.stdout.length) {
60
- logs = `\n\n${pc.bold('Logs:')}\n${pc.red(result.stderr.join(''))}\n${result.stdout.join('')}`;
62
+ // get links along with filtered logs (liks related logs removed)
63
+ const { stdout: filteredStdout, links } = fetchLinksFromLogs(result.stdout);
64
+ if (filteredStdout?.length || result.stderr?.length) {
65
+ logs = `\n\n${pc.bold('Logs:')}\n${pc.red(result.stderr.join(''))}\n${filteredStdout.join('')}`;
61
66
  }
67
+
68
+ /*
69
+ All services fucntions work different for Playwright.
70
+ We don't have access to test title (as result, to test id) when calling this functions inside a test.
71
+ Thus, when user calls services functions inside a test, we just log this data to console.
72
+ Playwright intercepts the console.log on it's end and we just get this data from it.
73
+ Thus, we have a tiny drawback: all data from services functions inside a test will be logged to console.
74
+ And this requires a condition to be added for each service function – if its Playwright, then log to console.
75
+
76
+ "get" method of services will not return data for Playwright, we should parse stdout.
77
+ */
62
78
  const manuallyAttachedArtifacts = services.artifacts.get(fullTestTitle);
63
79
  const testMeta = services.keyValues.get(fullTestTitle);
64
- const links = services.links.get(fullTestTitle);
65
80
  const rid = test.id || test.testId || uuidv4();
66
81
 
67
82
  /**
@@ -288,5 +303,80 @@ function getTestContextName(test) {
288
303
  return `${test._requireFile || ''}_${test.title}`;
289
304
  }
290
305
 
306
+ /**
307
+ * Fetches links from stdout. Returns links and filtered stdout (without data containing markers)
308
+ *
309
+ * @param {(string | Buffer)[]} stdout
310
+ * @returns {{ links: { [key: 'test' | 'jira']: string }[], stdout: (string | Buffer)[] }}
311
+ */
312
+ function fetchLinksFromLogs(stdout) {
313
+ const links = [];
314
+
315
+ const markers = [
316
+ { key: '[TESTOMATIO-LINK-TESTS]', type: 'test' },
317
+ { key: '[TESTOMATIO-LINK-JIRA]', type: 'jira' },
318
+ ];
319
+
320
+ const filteredStdout = [];
321
+
322
+ stdout.forEach(entry => {
323
+ if (typeof entry !== 'string') {
324
+ filteredStdout.push(entry);
325
+ return;
326
+ }
327
+
328
+ // check if entry contains any of markers
329
+ if (!markers.some(m => entry.includes(m.key))) {
330
+ filteredStdout.push(entry);
331
+ return;
332
+ }
333
+
334
+ const newEntryLines = [];
335
+ entry.split('\n').forEach(line => {
336
+ line = line.trim();
337
+ let hasMarker = false;
338
+ for (const marker of markers) {
339
+ if (line.includes(marker.key)) {
340
+ hasMarker = true;
341
+ try {
342
+ const rawJson = line.split(marker.key)[1]?.trim();
343
+ if (!rawJson) continue;
344
+
345
+ // smart JSON extraction: take until the last ']', otherwise take the whole string
346
+ const lastBracketIndex = rawJson.lastIndexOf(']');
347
+ const jsonStr = lastBracketIndex !== -1 ? rawJson.substring(0, lastBracketIndex + 1) : rawJson;
348
+
349
+ // test ids or jira ids
350
+ const ids = JSON.parse(jsonStr);
351
+ links.push(
352
+ ...ids
353
+ // filter non-truthy ids
354
+ .filter(id => !!id)
355
+ .map(id => ({
356
+ // marker type is either 'test' or 'jira'
357
+ [marker.type]: id,
358
+ })),
359
+ );
360
+ } catch (e) {
361
+ debug('Error parsing links from string:', line, '\n', e);
362
+ }
363
+ }
364
+ }
365
+ if (!hasMarker && line) {
366
+ newEntryLines.push(line);
367
+ }
368
+ });
369
+
370
+ if (newEntryLines.length) {
371
+ filteredStdout.push(newEntryLines.join('\n'));
372
+ }
373
+ });
374
+
375
+ return {
376
+ stdout: filteredStdout,
377
+ links,
378
+ };
379
+ }
380
+
291
381
  export default PlaywrightReporter;
292
- export { extractTags };
382
+ export { extractTags, fetchLinksFromLogs };
package/src/bin/cli.js CHANGED
@@ -79,22 +79,17 @@ program
79
79
  .command('run')
80
80
  .alias('test')
81
81
  .description('Run tests with the specified command')
82
- .argument('<command>', 'Test runner command')
82
+ .argument('[command]', 'Test runner command')
83
83
  .option('--filter <filter>', 'Additional execution filter')
84
84
  .option('--filter-list <filter>', 'Get a list of all tests by filter before running')
85
85
  .option('--kind <type>', 'Specify run type: automated, manual, or mixed')
86
86
  .action(async (command, opts) => {
87
87
  const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config.TESTOMATIO;
88
88
  const title = process.env.TESTOMATIO_TITLE;
89
-
90
- if (!command || !command.split) {
91
- console.log(APP_PREFIX, `No command provided. Use -c option to launch a test runner.`);
92
- return process.exit(255);
93
- }
94
-
95
89
  const client = new TestomatClient({ apiKey, title });
96
90
 
97
91
  if (opts.filter || opts.filterList) {
92
+ console.log(APP_PREFIX,'Filtering tests...');
98
93
  // Example of use: npx @testomatio/reporter run "npx jest" --filter "testomatio:tag-name=frontend"
99
94
  // Example of use: npx @testomatio/reporter run "npx jest" --filter "coverage:file=coverage.yml"
100
95
  // Example of use: npx @testomatio/reporter run "npx jest" --filter-list "coverage:file=coverage.yml"
@@ -102,6 +97,9 @@ program
102
97
  const pipeOptions = optsArray.join(':');
103
98
 
104
99
  const prepareRunParams = { pipe, pipeOptions };
100
+ if (opts.filterList) {
101
+ client.pipeStore.filterList = true;
102
+ }
105
103
 
106
104
  try {
107
105
  const tests = await client.prepareRun(prepareRunParams);
@@ -118,18 +116,46 @@ program
118
116
 
119
117
  if(opts.filterList) {
120
118
  console.log(APP_PREFIX, pc.blue(`Matched test/suite IDs: ${tests.join(', ')}`));
121
- console.log(APP_PREFIX, pc.green(`Full Running Command: ${filteredCommand}`));
119
+ if (command) console.log(APP_PREFIX, pc.green(`Full Running Command: ${filteredCommand}`));
122
120
  return;
123
121
  }
124
-
125
- command = filteredCommand;
126
- }
122
+
123
+ if (command && command.split) {
124
+ command = filteredCommand;
125
+ }
126
+ }
127
127
  catch (err) {
128
128
  console.log(APP_PREFIX, err.message || err);
129
129
  return;
130
130
  }
131
131
  }
132
132
 
133
+ // just create a run (wich tests which match filters) without executing tests
134
+ if (!command || !command.split) {
135
+ const createRunParams = {};
136
+ if (title) {
137
+ createRunParams.title = title;
138
+ }
139
+ if (opts.kind) {
140
+ createRunParams.kind = opts.kind;
141
+ }
142
+
143
+ if (apiKey) {
144
+ await client.createRun(createRunParams);
145
+ const runId = process.env.TESTOMATIO_RUN || process.env.runId;
146
+ if (client.pipeStore.runUrl) console.log(APP_PREFIX, `📊 Report URL: ${pc.magenta(client.pipeStore.runUrl)}`);
147
+
148
+ if (opts.kind !== 'manual') {
149
+ console.log(APP_PREFIX, `No command passed, so you need to run tests yourself:`);
150
+ console.log(APP_PREFIX, `TESTOMATIO_RUN=${runId} <command>`);
151
+ }
152
+ } else {
153
+ console.log(APP_PREFIX, '⚠️ No API key provided. Cannot create run without TESTOMATIO key.');
154
+ process.exit(1);
155
+ }
156
+ return process.exit(0);
157
+ }
158
+
133
159
  console.log(APP_PREFIX, `🚀 Running`, pc.green(command));
134
160
 
135
161
  const runTests = async () => {
@@ -54,7 +54,7 @@ class CoveragePipe { // or Changes for the future???
54
54
 
55
55
  this.branch = options?.diff || process.env.COVERAGE_BRANCH || this.#GIT.default_branch;
56
56
  this.isBranchDefault = !options.diff && !process.env.COVERAGE_BRANCH;
57
-
57
+
58
58
  if (this.isBranchDefault) {
59
59
  console.log(
60
60
  APP_PREFIX,
@@ -98,7 +98,7 @@ class CoveragePipe { // or Changes for the future???
98
98
  }
99
99
  });
100
100
 
101
- // In case if we have all needed data
101
+ // In case if we have all needed data
102
102
  this.isEnabled = true;
103
103
 
104
104
  debug('Coverage Pipe initialized', {
@@ -108,7 +108,7 @@ class CoveragePipe { // or Changes for the future???
108
108
 
109
109
  this.parsedCoverage = {};
110
110
  this.changedFiles = [];
111
- this.matchedLines = new Set();
111
+ this.matchedLines = new Set();
112
112
  this.tests = new Set();
113
113
  this.suiteIds = new Set();
114
114
  this.tagLabels = new Set();
@@ -118,12 +118,15 @@ class CoveragePipe { // or Changes for the future???
118
118
  debug(`Coverage Pipe: is Enabled = ${this.isEnabled}`);
119
119
  }
120
120
 
121
- async prepareRun(opts) {
121
+ async prepareRun(opts) {
122
122
  // Reset internal mutable state for isolation
123
123
  this.tests.clear();
124
124
  this.suiteIds.clear();
125
125
  this.tagLabels.clear();
126
126
  this.results = [];
127
+ if (this.store) {
128
+ this.store.coverageConfiguration = undefined;
129
+ }
127
130
 
128
131
  if (!this.isEnabled) return [];
129
132
 
@@ -132,6 +135,9 @@ class CoveragePipe { // or Changes for the future???
132
135
 
133
136
  // Step 2: Extract all available tests and compare with coverage file
134
137
  const lines = await this.extractRelevantTestsFromChanges();
138
+ if (this.store?.filterList && lines.size > 0) {
139
+ console.log(APP_PREFIX, `Matched files: ${[...lines].join(', ')}`);
140
+ }
135
141
 
136
142
  if (lines.size === 0) {
137
143
  console.log(APP_PREFIX, 'ℹ️ No matching entries in coverage file for provided Git changes.');
@@ -148,22 +154,33 @@ class CoveragePipe { // or Changes for the future???
148
154
  if (!tests) return [];
149
155
 
150
156
  console.log(
151
- APP_PREFIX,
157
+ APP_PREFIX,
152
158
  `✅ We found ${tests.length === 1 ? 'one entry' : `${tests.length} (test/suite) entries`}` +
153
159
  ' in Testomat.io service side.'
154
160
  );
155
-
161
+
156
162
  tests.forEach(testId => this.tests.add(testId));
157
- }
163
+ }
158
164
  }
159
165
 
160
- if (this.tests.size === 0) {
161
- console.log(APP_PREFIX, 'ℹ️ No tests found for execution based on Git changes.');
166
+ if (this.tests.size === 0 && this.suiteIds.size === 0) {
167
+ console.log(APP_PREFIX, 'ℹ️ No tests found for execution based on Git changes.');
162
168
  return [];
163
169
  }
164
170
 
165
- this.results = [...this.tests];
166
-
171
+ this.results = [...this.tests, ...this.suiteIds];
172
+ if (this.store) {
173
+ this.store.coverageConfiguration = {
174
+ tests: [...this.tests],
175
+ suites: [...this.suiteIds],
176
+ };
177
+ this.store.coverageDescription = this.#buildRunDescription({
178
+ matchedLines: lines,
179
+ testsCount: this.tests.size,
180
+ suitesCount: this.suiteIds.size,
181
+ });
182
+ }
183
+
167
184
  return this.results;
168
185
  }
169
186
 
@@ -214,18 +231,18 @@ class CoveragePipe { // or Changes for the future???
214
231
 
215
232
  if (!Array.isArray(resp.data?.tests) && resp.data?.tests?.length === 0) {
216
233
  console.log(APP_PREFIX, `🔍 No test by ${type}=${id} were found on the Testomat.io server side!`);
217
-
234
+
218
235
  return undefined;
219
236
  }
220
237
 
221
238
  return resp.data.tests;
222
- }
239
+ }
223
240
  catch (err) {
224
241
  console.error(
225
242
  APP_PREFIX,
226
243
  `🚩 Error getting available tests from the Testomat.io by "test_grep" option: ${err}`
227
244
  );
228
-
245
+
229
246
  return undefined;
230
247
  }
231
248
  }
@@ -243,48 +260,48 @@ class CoveragePipe { // or Changes for the future???
243
260
  encoding: 'utf-8',
244
261
  stdio: ['pipe', 'pipe', 'ignore']
245
262
  });
246
-
263
+
247
264
  return result
248
265
  .split('\n')
249
266
  .map(f => f.trim())
250
267
  .filter(Boolean);
251
- }
268
+ }
252
269
  catch (err) {
253
270
  const errorMessage = err.message || '';
254
271
  // Git edge: Not a git repository or other error
255
272
  if (errorMessage.includes('Not a git repository')) {
256
273
  console.error(APP_PREFIX, '❌ Error: This folder is not a Git repository.');
257
- }
274
+ }
258
275
  else {
259
276
  throw new Error(`❌ Git command failed ("${cmd}"):\n`, errorMessage);
260
277
  }
261
-
278
+
262
279
  return [];
263
280
  }
264
281
  }
265
282
 
266
283
  /**
267
- * Builds a Git command string to list file changes between the current state
284
+ * Builds a Git command string to list file changes between the current state
268
285
  * and a specified Git branch using `git diff --name-only`.
269
- *
286
+ *
270
287
  * Private pipe function
271
288
  * @throws {Error} Throws an error if `this.branch` is not defined.
272
289
  * @returns {string} A Git command string, e.g., 'git diff <branch> --name-only'.
273
290
  */
274
291
  #buildGitCommand() {
275
292
  if (!this.branch) throw new Error(`❌ Invalid changes option for setted branch!`);
276
-
277
- return `git diff ${this.branch} --name-only`; // Example: 'git diff <master> --name-only'
293
+
294
+ return `git diff ${this.branch} --name-only`; // Example: 'git diff <master> --name-only'
278
295
  }
279
296
 
280
297
  /**
281
- * Retrieves the list of files changed in the current Git working directory
298
+ * Retrieves the list of files changed in the current Git working directory
282
299
  * compared to a specified branch.
283
300
  *
284
- * This method builds a Git diff command and attempts to retrieve the changed
301
+ * This method builds a Git diff command and attempts to retrieve the changed
285
302
  * files using that command. It logs helpful information and errors during the process.
286
303
  *
287
- * If no changed files are found, or an error occurs at any stage, the method logs
304
+ * If no changed files are found, or an error occurs at any stage, the method logs
288
305
  * the issue and returns `undefined`.
289
306
  *
290
307
  * @returns {this | undefined} Returns the current instance (`this`) if changed files are found;
@@ -295,12 +312,12 @@ class CoveragePipe { // or Changes for the future???
295
312
 
296
313
  try {
297
314
  cmd = this.#buildGitCommand();
298
- }
315
+ }
299
316
  catch (err) {
300
317
  console.error(APP_PREFIX, err.message);
301
318
  return undefined;
302
319
  }
303
-
320
+
304
321
  console.error(APP_PREFIX, `ℹ️ We will use '${cmd}' Git command.`);
305
322
 
306
323
  try {
@@ -325,9 +342,9 @@ class CoveragePipe { // or Changes for the future???
325
342
  console.error(APP_PREFIX, err.message);
326
343
  console.error(APP_PREFIX, "🔍 Pls, check this Git command manually to understand the original problem.");
327
344
  return undefined;
328
- }
345
+ }
329
346
 
330
- console.log(APP_PREFIX, `📑 GIT changed files:\n - ${this.changedFiles.join('\n - ')}`);
347
+ console.log(APP_PREFIX, `📑 GIT changed files:\n - ${this.changedFiles.join('\n - ')}`);
331
348
  return this;
332
349
  }
333
350
 
@@ -412,7 +429,7 @@ class CoveragePipe { // or Changes for the future???
412
429
  for (const [pattern, ids] of Object.entries(this.parsedCoverage)) {
413
430
  if (minimatch(changedFile, pattern)) {
414
431
  this.matchedLines.add(changedFile);
415
-
432
+
416
433
  ids.forEach(id => {
417
434
  // Example: "@Tt74099t1"
418
435
  if (id.startsWith('@T')) {
@@ -420,7 +437,7 @@ class CoveragePipe { // or Changes for the future???
420
437
  }
421
438
  // Example: "@Sd74099c1"
422
439
  else if (id.startsWith('@S')) {
423
- this.tests.add(id.slice(1));
440
+ this.suiteIds.add(id.slice(1));
424
441
  }
425
442
  // Example: "tag:@TestSmoke"
426
443
  else if (id.startsWith('tag')) {
@@ -435,6 +452,47 @@ class CoveragePipe { // or Changes for the future???
435
452
 
436
453
  return this.matchedLines;
437
454
  }
455
+
456
+ #buildRunDescription({ matchedLines, testsCount, suitesCount }) {
457
+ const sourceBranch =
458
+ process.env.GITHUB_HEAD_REF ||
459
+ process.env.GITHUB_REF_NAME ||
460
+ process.env.CI_COMMIT_REF_NAME ||
461
+ this.#getCurrentGitBranch() ||
462
+ 'current branch';
463
+ const targetBranch = this.branch || 'target branch';
464
+ const coverageFile = this.coverageFilePath ? path.basename(this.coverageFilePath) : 'coverage.yml';
465
+ const updatedFiles = matchedLines && matchedLines.size > 0 ? [...matchedLines] : this.changedFiles;
466
+
467
+ let description = `Changes to **${updatedFiles.length}** files in ${sourceBranch} to ${targetBranch}.\n\n`;
468
+ if (suitesCount > 0 || testsCount > 0) {
469
+ const affectedItems = [];
470
+ if (suitesCount > 0) affectedItems.push(`**${suitesCount} suites**`);
471
+ if (testsCount > 0) affectedItems.push(`**${testsCount} individual tests**`);
472
+ description += `May affect ${affectedItems.join(' and ')} which are recommended to be tested for regression.\n\n`; // eslint-disable-line
473
+ }
474
+ description += 'Updated source files:\n';
475
+ if (updatedFiles.length) {
476
+ description += updatedFiles.map(file => `* \`${file}\``).join('\n');
477
+ description += '\n\n';
478
+ } else {
479
+ description += '* No matched files found\n\n';
480
+ }
481
+ description += `Mapping source files to tests set via \`${coverageFile}\` file.`;
482
+ return description;
483
+ }
484
+
485
+ #getCurrentGitBranch() {
486
+ try {
487
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', {
488
+ encoding: 'utf-8',
489
+ stdio: ['pipe', 'pipe', 'ignore'],
490
+ }).trim();
491
+ return branch || undefined;
492
+ } catch (err) {
493
+ return undefined;
494
+ }
495
+ }
438
496
  }
439
497
 
440
- export default CoveragePipe;
498
+ export default CoveragePipe;
@@ -142,6 +142,20 @@ class GitHubPipe {
142
142
  });
143
143
 
144
144
  let body = summary;
145
+ const coverageConfiguration = this.store?.coverageConfiguration;
146
+ const isManualRun = this.store?.runKind === 'manual';
147
+ if (isManualRun && coverageConfiguration) {
148
+ const testsCount = coverageConfiguration.tests?.length || 0;
149
+ const suitesCount = coverageConfiguration.suites?.length || 0;
150
+ body += '\n\n<details>\n<summary><h3>🧭 Coverage Scope</h3></summary>\n\n';
151
+ if (!testsCount && !suitesCount) {
152
+ body += '- No tests were affected, run disabled\n';
153
+ } else {
154
+ body += `- Suites: ${suitesCount}\n`;
155
+ body += `- Tests: ${testsCount}\n`;
156
+ }
157
+ body += '\n</details>';
158
+ }
145
159
 
146
160
  if (failures.length) {
147
161
  body += `\n<details>\n<summary><h3>🟥 Failures (${failures.length})</h4></summary>\n\n${failures.join('\n')}\n`;