testomatio-reporter-cli 2.8.4 → 2.8.5-beta.2-yarn

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 (67) hide show
  1. package/README.md +3 -3
  2. package/bin/cli.js +6 -26
  3. package/package.json +39 -4
  4. package/src/adapter/codecept.js +626 -0
  5. package/src/adapter/cucumber/current.js +230 -0
  6. package/src/adapter/cucumber/legacy.js +158 -0
  7. package/src/adapter/cucumber.js +4 -0
  8. package/src/adapter/cypress-plugin/index.js +110 -0
  9. package/src/adapter/jasmine.js +60 -0
  10. package/src/adapter/jest.js +108 -0
  11. package/src/adapter/mocha.cjs +2 -0
  12. package/src/adapter/mocha.js +211 -0
  13. package/src/adapter/nightwatch.js +88 -0
  14. package/src/adapter/playwright.js +343 -0
  15. package/src/adapter/utils/playwright.js +121 -0
  16. package/src/adapter/utils/step-formatter.js +232 -0
  17. package/src/adapter/vitest.js +455 -0
  18. package/src/adapter/webdriver.js +201 -0
  19. package/src/bin/cli.js +507 -0
  20. package/src/bin/reportXml.js +79 -0
  21. package/src/bin/startTest.js +54 -0
  22. package/src/bin/uploadArtifacts.js +91 -0
  23. package/src/client.js +524 -0
  24. package/src/config.js +30 -0
  25. package/src/constants.js +72 -0
  26. package/src/data-storage.js +204 -0
  27. package/src/helpers.js +1 -0
  28. package/src/junit-adapter/adapter.js +23 -0
  29. package/src/junit-adapter/csharp.js +70 -0
  30. package/src/junit-adapter/index.js +28 -0
  31. package/src/junit-adapter/java.js +58 -0
  32. package/src/junit-adapter/javascript.js +31 -0
  33. package/src/junit-adapter/nunit-parser.js +474 -0
  34. package/src/junit-adapter/python.js +42 -0
  35. package/src/junit-adapter/ruby.js +10 -0
  36. package/src/output.js +57 -0
  37. package/src/pipe/bitbucket.js +285 -0
  38. package/src/pipe/coverage.js +500 -0
  39. package/src/pipe/csv.js +161 -0
  40. package/src/pipe/debug.js +143 -0
  41. package/src/pipe/github.js +256 -0
  42. package/src/pipe/gitlab.js +258 -0
  43. package/src/pipe/html.js +1153 -0
  44. package/src/pipe/index.js +73 -0
  45. package/src/pipe/markdown.js +753 -0
  46. package/src/pipe/testomatio.js +707 -0
  47. package/src/replay.js +274 -0
  48. package/src/reporter-functions.js +155 -0
  49. package/src/reporter.js +42 -0
  50. package/src/services/artifacts.js +59 -0
  51. package/src/services/index.js +15 -0
  52. package/src/services/key-values.js +59 -0
  53. package/src/services/links.js +69 -0
  54. package/src/services/logger.js +320 -0
  55. package/src/template/emptyData.svg +23 -0
  56. package/src/template/testomatio-old.hbs +1421 -0
  57. package/src/template/testomatio.hbs +3726 -0
  58. package/src/uploader.js +382 -0
  59. package/src/utils/constants.js +12 -0
  60. package/src/utils/debug.js +20 -0
  61. package/src/utils/log-formatter.js +118 -0
  62. package/src/utils/log.js +88 -0
  63. package/src/utils/pipe_utils.js +193 -0
  64. package/src/utils/utils.js +732 -0
  65. package/src/xmlReader.js +834 -0
  66. package/types/types.d.ts +425 -0
  67. package/types/vitest.types.d.ts +93 -0
@@ -0,0 +1,211 @@
1
+ import Mocha from 'mocha';
2
+ import TestomatClient from '../client.js';
3
+ import { STATUS, TESTOMAT_TMP_STORAGE_DIR } from '../constants.js';
4
+ import { getTestomatIdFromTestTitle, fileSystem } from '../utils/utils.js';
5
+ import { config } from '../config.js';
6
+ import { services } from '../services/index.js';
7
+ import pc from 'picocolors';
8
+
9
+ const {
10
+ EVENT_RUN_BEGIN,
11
+ EVENT_RUN_END,
12
+ EVENT_TEST_FAIL,
13
+ EVENT_TEST_PASS,
14
+ EVENT_TEST_PENDING,
15
+ EVENT_SUITE_BEGIN,
16
+ EVENT_SUITE_END,
17
+ EVENT_TEST_BEGIN,
18
+ EVENT_TEST_END,
19
+ } = Mocha.Runner.constants;
20
+
21
+ function MochaReporter(runner, opts) {
22
+ Mocha.reporters.Base.call(this, runner);
23
+ let passes = 0;
24
+ let failures = 0;
25
+ let skipped = 0;
26
+ // let artifactStore;
27
+
28
+ const apiKey = opts?.reporterOptions?.apiKey || config.TESTOMATIO;
29
+
30
+ const client = new TestomatClient({ apiKey });
31
+
32
+ // Track hook failures
33
+ const hookFailures = new Map();
34
+
35
+ runner.on(EVENT_RUN_BEGIN, () => {
36
+ client.createRun();
37
+
38
+ // clear dir with artifacts/logs
39
+ fileSystem.clearDir(TESTOMAT_TMP_STORAGE_DIR);
40
+ });
41
+
42
+ runner.on(EVENT_SUITE_BEGIN, async suite => {
43
+ services.setContext(suite.fullTitle());
44
+ });
45
+
46
+ runner.on(EVENT_SUITE_END, async suite => {
47
+ if (hookFailures.has(suite.fullTitle())) {
48
+ const { error, suiteTitle } = hookFailures.get(suite.fullTitle());
49
+
50
+ for (const test of suite.tests) {
51
+ if (test.state === 'pending' || !test.state) {
52
+ const testId = getTestomatIdFromTestTitle(test.title);
53
+ const artifacts = services.artifacts.get(test.fullTitle());
54
+ const keyValues = services.keyValues.get(test.fullTitle());
55
+ const links = services.links.get(test.fullTitle());
56
+
57
+ client.addTestRun(STATUS.FAILED, {
58
+ error,
59
+ suite_title: suiteTitle || suite.title,
60
+ file: suite.file,
61
+ test_id: testId,
62
+ title: test.title,
63
+ code: process.env.TESTOMATIO_UPDATE_CODE ? test.body.toString() : '',
64
+ time: 0,
65
+ manuallyAttachedArtifacts: artifacts,
66
+ meta: keyValues,
67
+ links,
68
+ });
69
+ }
70
+ }
71
+ }
72
+
73
+ services.setContext(null);
74
+ });
75
+
76
+ runner.on(EVENT_TEST_BEGIN, async test => {
77
+ services.setContext(test.fullTitle());
78
+ });
79
+
80
+ runner.on(EVENT_TEST_END, async () => {
81
+ services.setContext(null);
82
+ });
83
+
84
+ runner.on(EVENT_TEST_PASS, async test => {
85
+ passes += 1;
86
+
87
+ console.log(pc.bold(pc.green('✔')), test.fullTitle());
88
+ const testId = getTestomatIdFromTestTitle(test.title);
89
+
90
+ const logs = getTestLogs(test);
91
+ const artifacts = services.artifacts.get(test.fullTitle());
92
+ const keyValues = services.keyValues.get(test.fullTitle());
93
+ const links = services.links.get(test.fullTitle());
94
+
95
+ client.addTestRun(STATUS.PASSED, {
96
+ test_id: testId,
97
+ suite_title: getSuiteTitle(test),
98
+ title: getTestName(test),
99
+ code: process.env.TESTOMATIO_UPDATE_CODE ? test.body.toString() : '',
100
+ file: getFile(test),
101
+ time: test.duration,
102
+ logs,
103
+ manuallyAttachedArtifacts: artifacts,
104
+ meta: keyValues,
105
+ links,
106
+ });
107
+ });
108
+
109
+ runner.on(EVENT_TEST_PENDING, test => {
110
+ skipped += 1;
111
+ console.log('skip: %s', test.fullTitle());
112
+ const testId = getTestomatIdFromTestTitle(test.title);
113
+ const artifacts = services.artifacts.get(test.fullTitle());
114
+ const keyValues = services.keyValues.get(test.fullTitle());
115
+ const links = services.links.get(test.fullTitle());
116
+
117
+ client.addTestRun(STATUS.SKIPPED, {
118
+ title: getTestName(test),
119
+ suite_title: getSuiteTitle(test),
120
+ code: process.env.TESTOMATIO_UPDATE_CODE ? test.body.toString() : '',
121
+ file: getFile(test),
122
+ test_id: testId,
123
+ time: test.duration,
124
+ manuallyAttachedArtifacts: artifacts,
125
+ meta: keyValues,
126
+ links,
127
+ });
128
+ });
129
+
130
+ runner.on(EVENT_TEST_FAIL, async (test, err) => {
131
+ failures += 1;
132
+ console.log(pc.bold(pc.red('✖')), test.fullTitle(), pc.gray(err.message));
133
+
134
+ const isHookFailure = test.title.includes('before each') || test.title.includes('after each');
135
+
136
+ if (isHookFailure && test.parent) {
137
+ hookFailures.set(test.parent.fullTitle(), {
138
+ error: err,
139
+ suiteTitle: getSuiteTitle(test),
140
+ });
141
+ return;
142
+ }
143
+
144
+ const testId = getTestomatIdFromTestTitle(test.title);
145
+
146
+ const logs = getTestLogs(test);
147
+ const artifacts = services.artifacts.get(test.fullTitle());
148
+ const keyValues = services.keyValues.get(test.fullTitle());
149
+ const links = services.links.get(test.fullTitle());
150
+
151
+ client.addTestRun(STATUS.FAILED, {
152
+ error: err,
153
+ suite_title: getSuiteTitle(test),
154
+ file: getFile(test),
155
+ test_id: testId,
156
+ title: getTestName(test),
157
+ code: process.env.TESTOMATIO_UPDATE_CODE ? test.body.toString() : '',
158
+ time: test.duration,
159
+ logs,
160
+ manuallyAttachedArtifacts: artifacts,
161
+ meta: keyValues,
162
+ links,
163
+ });
164
+ });
165
+
166
+ runner.on(EVENT_RUN_END, () => {
167
+ const status = failures === 0 ? STATUS.PASSED : STATUS.FAILED;
168
+ console.log(pc.bold(status), `${passes} passed, ${failures} failed, ${skipped} skipped`);
169
+ // @ts-ignore
170
+ client.updateRunStatus(status);
171
+ });
172
+ }
173
+
174
+ function getTestLogs(test) {
175
+ const suiteLogsArr = services.logger.getLogs(test.parent.fullTitle());
176
+ const suiteLogs = suiteLogsArr ? suiteLogsArr.join('\n').trim() : '';
177
+ const testLogsArr = services.logger.getLogs(test.fullTitle());
178
+ const testLogs = testLogsArr ? testLogsArr.join('\n').trim() : '';
179
+
180
+ let logs = '';
181
+ if (suiteLogs) {
182
+ logs += `${pc.bold('\t--- BeforeSuite ---')}\n${suiteLogs}`;
183
+ }
184
+ if (testLogs) {
185
+ logs += `\n${pc.bold('\t--- Test ---')}\n${testLogs}`;
186
+ }
187
+ return logs;
188
+ }
189
+
190
+ function getSuiteTitle(test, pathArr = []) {
191
+ if (test.parent.parent) getSuiteTitle(test.parent, pathArr);
192
+
193
+ pathArr.push(test.parent.title);
194
+
195
+ return pathArr.filter(t => !!t)[0];
196
+ }
197
+
198
+ function getFile(test) {
199
+ return test.parent.file?.replace(process.cwd(), '');
200
+ }
201
+
202
+ function getTestName(test) {
203
+ if (process.env.TESTOMATIO_CREATE === 'fulltitle') return test.fullTitle();
204
+ return test.title;
205
+ }
206
+
207
+ // To have this reporter "extend" a built-in reporter uncomment the following line:
208
+ // @ts-ignore
209
+ Mocha.utils.inherits(MochaReporter, Mocha.reporters.Spec);
210
+
211
+ export default MochaReporter;
@@ -0,0 +1,88 @@
1
+ import TestomatClient from '../client.js';
2
+ import { config } from '../config.js';
3
+ import { STATUS } from '../constants.js';
4
+ import { getTestomatIdFromTestTitle } from '../utils/utils.js';
5
+
6
+ const apiKey = config.TESTOMATIO;
7
+ const client = new TestomatClient({ apiKey });
8
+
9
+ export default {
10
+ write: async (results, options, done) => {
11
+ await client.createRun();
12
+
13
+ const testFiles = results.modules;
14
+
15
+ for (const fileName in testFiles) {
16
+ // in nightwatch: object containing tests from a single file
17
+ const testModule = testFiles[fileName];
18
+
19
+ // passed and failed tests (tests with assertions)
20
+ const completedTests = testModule.completed;
21
+
22
+ // skipped tests (skipped by user or tests without assertions)
23
+ const skippedTests = testModule.skipped;
24
+
25
+ const tags = testModule.tags || [];
26
+
27
+ // if test file contains multiple suites, the last suite name is used as a name 🤷‍♂️
28
+ // no other places which contain suite name (even inside test object)
29
+ const suiteTitle = testModule.name;
30
+
31
+ for (const testTitle in completedTests) {
32
+ const test = completedTests[testTitle];
33
+ let status;
34
+ switch (test.status) {
35
+ case 'pass':
36
+ status = STATUS.PASSED;
37
+ break;
38
+ case 'fail':
39
+ status = STATUS.FAILED;
40
+ break;
41
+ // probably not required (because skipped tests are in separate array), but just in case
42
+ case 'skip':
43
+ status = STATUS.SKIPPED;
44
+ console.info('Skipped test is in completed tests array:', test, 'Not expected behavior.');
45
+ break;
46
+ default:
47
+ console.error('Test status processing error:', test.status);
48
+ }
49
+
50
+ const testId = getTestomatIdFromTestTitle(testTitle);
51
+
52
+ client.addTestRun(status, {
53
+ error: { name: test.assertions?.[0]?.name, message: test.assertions?.[0]?.message, stack: test.stackTrace },
54
+ file: testModule.modulePath?.replace(process.cwd(), ''),
55
+ message: test.assertions?.[0]?.message,
56
+ rid: `${testModule.uuid || ''}_${testTitle || ''}`,
57
+ stack: test.stackTrace,
58
+ suite_title: suiteTitle,
59
+ tags,
60
+ test_id: testId,
61
+ time: test.timeMs,
62
+ title: testTitle,
63
+ });
64
+ }
65
+
66
+ // just array with skipped tests titles, no any other info
67
+ for (const testTitle of skippedTests) {
68
+ client.addTestRun(STATUS.SKIPPED, {
69
+ suite_title: suiteTitle,
70
+ tags,
71
+ rid: `${testModule.uuid || ''}_${testTitle || ''}`,
72
+ title: testTitle,
73
+ });
74
+ }
75
+ }
76
+
77
+ /**
78
+ * @type {'passed' | 'failed' | 'finished'}
79
+ */
80
+ let runStatus = 'finished';
81
+ if (results.failed) runStatus = 'failed';
82
+ else if (results.passed) runStatus = 'passed';
83
+
84
+ await client.updateRunStatus(runStatus);
85
+
86
+ done();
87
+ },
88
+ };
@@ -0,0 +1,343 @@
1
+ import crypto from 'crypto';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { v4 as uuidv4 } from 'uuid';
5
+ import fs from 'fs';
6
+ import { APP_PREFIX, STATUS as Status, TESTOMAT_TMP_STORAGE_DIR, SCREENSHOTS_ON_STEPS } from '../constants.js';
7
+ import TestomatioClient from '../client.js';
8
+ import { getTestomatIdFromTestTitle, fileSystem, truncate } from '../utils/utils.js';
9
+ import { services } from '../services/index.js';
10
+ import { dataStorage } from '../data-storage.js';
11
+ import { extensionMap } from '../utils/constants.js';
12
+ import pc from 'picocolors';
13
+ import { fetchLinksFromLogs } from './utils/playwright.js';
14
+ import { formatStep, addStatusToStep, addArtifactsToStep } from './utils/step-formatter.js';
15
+ import { log } from '../utils/log.js';
16
+
17
+ const reportTestPromises = [];
18
+
19
+ class PlaywrightReporter {
20
+ constructor(config = {}) {
21
+ this.client = new TestomatioClient({ apiKey: config?.apiKey });
22
+
23
+ this.uploads = [];
24
+ }
25
+
26
+ onBegin(config, suite) {
27
+ // clean data storage
28
+ fileSystem.clearDir(TESTOMAT_TMP_STORAGE_DIR);
29
+ if (!this.client) return;
30
+ this.suite = suite;
31
+ this.config = config;
32
+ this.client.createRun();
33
+ }
34
+
35
+ onTestBegin(testInfo) {
36
+ const fullTestTitle = getTestContextName(testInfo);
37
+ dataStorage.setContext(fullTestTitle);
38
+ }
39
+
40
+ async onTestEnd(test, result) {
41
+ // test.parent.project().__projectId
42
+
43
+ if (!this.client) return;
44
+
45
+ const { title } = test;
46
+ const { error, duration } = result;
47
+ const pwAttachments = (result.attachments || []).filter(a => a.body || a.path);
48
+
49
+ const files = pwAttachments
50
+ .map(att => ({
51
+ path: this.#getArtifactPath(att),
52
+ title: att.name || title,
53
+ type: att.contentType,
54
+ }))
55
+ .filter(f => f.path);
56
+
57
+ const suite_title = test.parent ? test.parent?.title : path.basename(test?.location?.file);
58
+
59
+ const rid = test.id || test.testId || uuidv4();
60
+
61
+ /**
62
+ * @type {{
63
+ * browser?: string,
64
+ * dependencies: string[],
65
+ * isMobile?: boolean
66
+ * metadata: Record<string, any>,
67
+ * name: string,
68
+ * }}
69
+ */
70
+ const project = {
71
+ browser: test.parent.project().use.defaultBrowserType,
72
+ dependencies: test.parent.project().dependencies,
73
+ isMobile: test.parent.project().use.isMobile,
74
+ metadata: test.parent.project().metadata,
75
+ name: test.parent.project().name,
76
+ };
77
+
78
+ const steps = result.steps.map(step => appendStep(step, 0)).filter(step => step !== null);
79
+
80
+ // Extract and normalize tags
81
+ const tags = extractTags(test);
82
+
83
+ const fullTestTitle = getTestContextName(test);
84
+
85
+ let logs = '';
86
+ // get links along with filtered logs (liks related logs removed)
87
+ const { stdout: filteredStdout, links, meta } = fetchLinksFromLogs(result.stdout);
88
+ if (filteredStdout?.length || result.stderr?.length) {
89
+ logs = `\n\n${pc.bold('Logs:')}\n${pc.red(result.stderr.join(''))}\n${filteredStdout.join('')}`;
90
+ }
91
+
92
+ /*
93
+ All services fucntions work different for Playwright.
94
+ We don't have access to test title (as result, to test id) when calling this functions inside a test.
95
+ Thus, when user calls services functions inside a test, we just log this data to console.
96
+ Playwright intercepts the console.log on it's end and we just get this data from it.
97
+ Thus, we have a tiny drawback: all data from services functions inside a test will be logged to console.
98
+ And this requires a condition to be added for each service function – if its Playwright, then log to console.
99
+
100
+ "get" method of services will not return data for Playwright, we should parse stdout.
101
+ */
102
+ const manuallyAttachedArtifacts = services.artifacts.get(fullTestTitle);
103
+ const testMeta = services.keyValues.get(fullTestTitle);
104
+
105
+ let status = result.status;
106
+ // process test.fail() annotation
107
+ if (test.expectedStatus === 'failed') {
108
+ // actual status = expected
109
+ if (result.status === 'failed') status = 'passed';
110
+ // actual status != expected
111
+ if (result.status === 'passed') status = 'failed';
112
+ }
113
+
114
+ const reportTestPromise = this.client.addTestRun(checkStatus(status), {
115
+ rid: `${rid}-${project.name}`,
116
+ error,
117
+ test_id: getTestomatIdFromTestTitle(`${title} ${tags.join(' ')}`),
118
+ suite_title,
119
+ title,
120
+ tags: tags.map(tag => tag.replace('@', '')),
121
+ steps: steps.length ? steps : undefined,
122
+ time: duration,
123
+ logs,
124
+ links,
125
+ manuallyAttachedArtifacts,
126
+ files: files.length ? files : undefined,
127
+ meta: {
128
+ browser: project.browser,
129
+ isMobile: project.isMobile,
130
+ project: project.name,
131
+ projectDependencies: project.dependencies?.length ? project.dependencies : null,
132
+ ...testMeta,
133
+ ...meta,
134
+ ...project.metadata, // metadata has any type (in playwright), but we will stringify it in client.js
135
+ ...test.annotations?.reduce((acc, annotation) => {
136
+ acc[annotation.type] = annotation.description;
137
+ return acc;
138
+ }, {}),
139
+ },
140
+ file: test.location?.file,
141
+ });
142
+
143
+ this.uploads.push({
144
+ rid: `${rid}-${project.name}`,
145
+ title: test.title,
146
+ files: pwAttachments,
147
+ file: test.location?.file,
148
+ });
149
+ // remove empty uploads
150
+ this.uploads = this.uploads.filter(anUpload => anUpload.files.length);
151
+
152
+ reportTestPromises.push(reportTestPromise);
153
+ }
154
+
155
+ #getArtifactPath(artifact) {
156
+ if (artifact.path) {
157
+ if (path.isAbsolute(artifact.path)) return artifact.path;
158
+ return path.join(this.config.outputDir || this.config.projects[0].outputDir, artifact.path);
159
+ }
160
+ if (artifact.body) {
161
+ let filePath = generateTmpFilepath(artifact.name);
162
+ const hasExtension = artifact.name && path.extname(artifact.name);
163
+ if (!hasExtension && artifact.contentType) {
164
+ const extension = extensionMap[artifact.contentType] || artifact.contentType.split('/')[1];
165
+ if (extension) filePath += `.${extension}`;
166
+ }
167
+ fs.writeFileSync(filePath, artifact.body);
168
+ return filePath;
169
+ }
170
+ return null;
171
+ }
172
+
173
+ async onEnd(result) {
174
+ if (!this.client) return;
175
+
176
+ await Promise.all(reportTestPromises);
177
+
178
+ if (this.uploads.length) {
179
+ if (this.client.uploader.isEnabled) log.info(`🎞️ Uploading ${this.uploads.length} files...`);
180
+
181
+ const promises = [];
182
+
183
+ // ? possible move to addTestRun (needs investigation if files are ready)
184
+ for (const upload of this.uploads) {
185
+ const { rid, file, title } = upload;
186
+
187
+ const files = upload.files.map(attachment => ({
188
+ path: this.#getArtifactPath(attachment),
189
+ title,
190
+ type: attachment.contentType,
191
+ }));
192
+
193
+ if (!this.client.uploader.isEnabled) {
194
+ files.forEach(f => this.client.uploader.storeUploadedFile(f, this.client.runId, rid, false));
195
+ continue;
196
+ }
197
+
198
+ promises.push(
199
+ this.client.addTestRun(undefined, {
200
+ rid,
201
+ title,
202
+ files,
203
+ file,
204
+ }),
205
+ );
206
+ }
207
+ await Promise.all(promises);
208
+ }
209
+
210
+ await this.client.updateRunStatus(checkStatus(result.status));
211
+ }
212
+ }
213
+
214
+ function checkStatus(status) {
215
+ return (
216
+ {
217
+ skipped: Status.SKIPPED,
218
+ timedOut: Status.FAILED,
219
+ passed: Status.PASSED,
220
+ }[status] || Status.FAILED
221
+ );
222
+ }
223
+
224
+ function appendStep(step, shift = 0) {
225
+ // nesting too deep, ignore those steps
226
+ if (shift >= 10) return;
227
+
228
+ let newCategory = step.category;
229
+ switch (newCategory) {
230
+ case 'test.step':
231
+ newCategory = 'user';
232
+ break;
233
+ case 'hook':
234
+ newCategory = 'hook';
235
+ break;
236
+ case 'attach':
237
+ return null; // Skip steps with category 'attach'
238
+ default:
239
+ newCategory = 'framework';
240
+ }
241
+
242
+ const resultStep = formatStep({
243
+ category: newCategory,
244
+ title: step.title,
245
+ duration: step.duration,
246
+ });
247
+
248
+ // Add status based on error
249
+ addStatusToStep(resultStep, step.error ? 'failed' : 'passed', step.error);
250
+
251
+ // Add error if present
252
+ if (step.error !== undefined) {
253
+ if (typeof step.error === 'object') {
254
+ resultStep.error = {
255
+ message: truncate(String(step.error.message), 250),
256
+ stack: truncate(String(step.error.stack || ''), 250),
257
+ };
258
+ } else {
259
+ resultStep.error = truncate(String(step.error), 250);
260
+ }
261
+ }
262
+
263
+ // Add log if present
264
+ if (step.log) {
265
+ resultStep.log = truncate(String(step.log), 250);
266
+ }
267
+
268
+ // Add artifacts from attachments
269
+ if (step.attachments && step.attachments.length > 0 && SCREENSHOTS_ON_STEPS) {
270
+ const screenshotAttachment = step.attachments.find(att =>
271
+ att.contentType === 'image/png' && att.name === 'screenshot'
272
+ );
273
+ if (screenshotAttachment && screenshotAttachment.path) {
274
+ const artifacts = { screenshot: screenshotAttachment.path };
275
+ addArtifactsToStep(resultStep, artifacts);
276
+ }
277
+ }
278
+
279
+ // Process nested steps
280
+ const formattedSteps = [];
281
+ for (const child of step.steps || []) {
282
+ const appendedChild = appendStep(child, shift + 2);
283
+ if (appendedChild) {
284
+ formattedSteps.push(appendedChild);
285
+ }
286
+ }
287
+
288
+ if (formattedSteps.length) {
289
+ resultStep.steps = formattedSteps.filter(s => !!s);
290
+ }
291
+
292
+ return resultStep;
293
+ }
294
+
295
+ function generateTmpFilepath(filename = '') {
296
+ filename = filename || `tmp.${crypto.randomBytes(16).toString('hex')}`;
297
+ const tmpdir = os.tmpdir();
298
+ return path.join(tmpdir, filename);
299
+ }
300
+
301
+ /**
302
+ * Extracts tags from test title, test options, and suite level
303
+ * Identifies duplicate tags (case-insensitive)
304
+ * @param {*} test - testInfo object from Playwright
305
+ * @returns {string[]} - array of normalized tags with @ prefix
306
+ */
307
+ function extractTags(test) {
308
+ const tagsMap = new Map(); // key: lowercase tag, value: original case tag
309
+
310
+ function addTag(tag) {
311
+ if (typeof tag !== 'string') return;
312
+ const trimmed = tag.trim();
313
+ if (!trimmed) return;
314
+ const normalizedTag = trimmed.startsWith('@') ? trimmed : `@${trimmed}`;
315
+ const lowercaseTag = normalizedTag.toLowerCase();
316
+ if (!tagsMap.has(lowercaseTag)) {
317
+ tagsMap.set(lowercaseTag, normalizedTag);
318
+ }
319
+ }
320
+
321
+ // Extract tags from test title (@tag format); only test title is considered
322
+ const titleTagsMatch = test.title.match(/@[A-Za-z0-9_-]+/g) || [];
323
+ titleTagsMatch.forEach(addTag);
324
+
325
+ // Extract tags from test.tags (Playwright built-in tags); ignore parents
326
+ if (Array.isArray(test.tags)) {
327
+ test.tags.forEach(addTag);
328
+ }
329
+
330
+ return Array.from(tagsMap.values());
331
+ }
332
+
333
+ /**
334
+ * Returns filename + test title
335
+ * @param {*} test - testInfo object from Playwright
336
+ * @returns
337
+ */
338
+ function getTestContextName(test) {
339
+ return `${test._requireFile || ''}_${test.title}`;
340
+ }
341
+
342
+ export default PlaywrightReporter;
343
+ export { extractTags, fetchLinksFromLogs };