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.
- package/README.md +3 -3
- package/bin/cli.js +6 -26
- package/package.json +39 -4
- package/src/adapter/codecept.js +626 -0
- package/src/adapter/cucumber/current.js +230 -0
- package/src/adapter/cucumber/legacy.js +158 -0
- package/src/adapter/cucumber.js +4 -0
- package/src/adapter/cypress-plugin/index.js +110 -0
- package/src/adapter/jasmine.js +60 -0
- package/src/adapter/jest.js +108 -0
- package/src/adapter/mocha.cjs +2 -0
- package/src/adapter/mocha.js +211 -0
- package/src/adapter/nightwatch.js +88 -0
- package/src/adapter/playwright.js +343 -0
- package/src/adapter/utils/playwright.js +121 -0
- package/src/adapter/utils/step-formatter.js +232 -0
- package/src/adapter/vitest.js +455 -0
- package/src/adapter/webdriver.js +201 -0
- package/src/bin/cli.js +507 -0
- package/src/bin/reportXml.js +79 -0
- package/src/bin/startTest.js +54 -0
- package/src/bin/uploadArtifacts.js +91 -0
- package/src/client.js +524 -0
- package/src/config.js +30 -0
- package/src/constants.js +72 -0
- package/src/data-storage.js +204 -0
- package/src/helpers.js +1 -0
- package/src/junit-adapter/adapter.js +23 -0
- package/src/junit-adapter/csharp.js +70 -0
- package/src/junit-adapter/index.js +28 -0
- package/src/junit-adapter/java.js +58 -0
- package/src/junit-adapter/javascript.js +31 -0
- package/src/junit-adapter/nunit-parser.js +474 -0
- package/src/junit-adapter/python.js +42 -0
- package/src/junit-adapter/ruby.js +10 -0
- package/src/output.js +57 -0
- package/src/pipe/bitbucket.js +285 -0
- package/src/pipe/coverage.js +500 -0
- package/src/pipe/csv.js +161 -0
- package/src/pipe/debug.js +143 -0
- package/src/pipe/github.js +256 -0
- package/src/pipe/gitlab.js +258 -0
- package/src/pipe/html.js +1153 -0
- package/src/pipe/index.js +73 -0
- package/src/pipe/markdown.js +753 -0
- package/src/pipe/testomatio.js +707 -0
- package/src/replay.js +274 -0
- package/src/reporter-functions.js +155 -0
- package/src/reporter.js +42 -0
- package/src/services/artifacts.js +59 -0
- package/src/services/index.js +15 -0
- package/src/services/key-values.js +59 -0
- package/src/services/links.js +69 -0
- package/src/services/logger.js +320 -0
- package/src/template/emptyData.svg +23 -0
- package/src/template/testomatio-old.hbs +1421 -0
- package/src/template/testomatio.hbs +3726 -0
- package/src/uploader.js +382 -0
- package/src/utils/constants.js +12 -0
- package/src/utils/debug.js +20 -0
- package/src/utils/log-formatter.js +118 -0
- package/src/utils/log.js +88 -0
- package/src/utils/pipe_utils.js +193 -0
- package/src/utils/utils.js +732 -0
- package/src/xmlReader.js +834 -0
- package/types/types.d.ts +425 -0
- 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 };
|