@testomatio/reporter 2.3.0-beta.5-links → 2.3.0-beta.6-links
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/lib/adapter/codecept.js +4 -2
- package/lib/adapter/cucumber/current.js +2 -0
- package/lib/adapter/jest.js +2 -0
- package/lib/adapter/playwright.js +3 -27
- package/lib/adapter/webdriver.js +9 -12
- package/lib/bin/cli.js +6 -9
- package/lib/client.d.ts +2 -1
- package/lib/client.js +4 -35
- package/lib/data-storage.js +5 -5
- package/lib/pipe/testomatio.d.ts +1 -0
- package/lib/pipe/testomatio.js +5 -1
- package/lib/replay.js +1 -1
- package/lib/reporter-functions.d.ts +10 -3
- package/lib/reporter-functions.js +13 -4
- package/lib/reporter.d.ts +16 -14
- package/lib/reporter.js +3 -1
- package/lib/services/artifacts.d.ts +1 -1
- package/lib/services/key-values.d.ts +1 -1
- package/lib/services/labels.d.ts +0 -22
- package/lib/services/labels.js +0 -62
- package/lib/services/links.d.ts +1 -1
- package/lib/services/logger.d.ts +1 -1
- package/package.json +1 -1
- package/src/adapter/codecept.js +5 -2
- package/src/adapter/cucumber/current.js +2 -0
- package/src/adapter/jest.js +2 -0
- package/src/adapter/playwright.js +3 -31
- package/src/adapter/webdriver.js +9 -13
- package/src/bin/cli.js +6 -9
- package/src/client.js +4 -35
- package/src/data-storage.js +5 -6
- package/src/pipe/testomatio.js +4 -1
- package/src/replay.js +1 -1
- package/src/reporter-functions.js +16 -8
- package/src/reporter.js +2 -0
- package/src/services/labels.js +0 -58
package/lib/adapter/codecept.js
CHANGED
|
@@ -26,6 +26,8 @@ const HOOK_EXECUTION_ORDER = {
|
|
|
26
26
|
PRE_TEST: ['BeforeSuiteHook', 'BeforeHook'],
|
|
27
27
|
POST_TEST: ['AfterHook', 'AfterSuiteHook']
|
|
28
28
|
};
|
|
29
|
+
// codeceptjs workers are self-contained
|
|
30
|
+
data_storage_js_1.dataStorage.isFileStorage = false;
|
|
29
31
|
const DATA_REGEXP = /[|\s]+?(\{".*\}|\[.*\])/;
|
|
30
32
|
if (MAJOR_VERSION < 3) {
|
|
31
33
|
console.log('🔴 This reporter works with CodeceptJS 3+, please update your tests');
|
|
@@ -399,10 +401,10 @@ function formatHookStep(step) {
|
|
|
399
401
|
// For hook steps, construct title from available properties
|
|
400
402
|
let title = step.name;
|
|
401
403
|
if (step.actor && step.name) {
|
|
402
|
-
title = `${step.actor}
|
|
404
|
+
title = `${step.actor}.${step.name}`;
|
|
403
405
|
if (step.args && step.args.length > 0) {
|
|
404
406
|
const argsStr = step.args.map(arg => JSON.stringify(arg)).join(', ');
|
|
405
|
-
title += `
|
|
407
|
+
title += `(${argsStr})`;
|
|
406
408
|
}
|
|
407
409
|
}
|
|
408
410
|
return {
|
|
@@ -105,6 +105,7 @@ class CucumberReporter extends cucumber_1.Formatter {
|
|
|
105
105
|
const logs = index_js_1.services.logger.getLogs(testTitle).join('\n');
|
|
106
106
|
const artifacts = index_js_1.services.artifacts.get(testTitle);
|
|
107
107
|
const keyValues = index_js_1.services.keyValues.get(testTitle);
|
|
108
|
+
const links = index_js_1.services.links.get(testTitle);
|
|
108
109
|
this.client.addTestRun(status, {
|
|
109
110
|
// error: testCaseAttempt.worstTestStepResult.message,
|
|
110
111
|
message,
|
|
@@ -114,6 +115,7 @@ class CucumberReporter extends cucumber_1.Formatter {
|
|
|
114
115
|
.trim(),
|
|
115
116
|
example: { ...example },
|
|
116
117
|
logs,
|
|
118
|
+
links,
|
|
117
119
|
manuallyAttachedArtifacts: artifacts,
|
|
118
120
|
meta: keyValues,
|
|
119
121
|
title: scenario,
|
package/lib/adapter/jest.js
CHANGED
|
@@ -57,6 +57,7 @@ class JestReporter {
|
|
|
57
57
|
const logs = getTestLogs(result);
|
|
58
58
|
const artifacts = index_js_1.services.artifacts.get(result.fullName);
|
|
59
59
|
const keyValues = index_js_1.services.keyValues.get(result.fullName);
|
|
60
|
+
const links = index_js_1.services.links.get(result.fullName);
|
|
60
61
|
const deducedStatus = status === 'pending' ? 'skipped' : status;
|
|
61
62
|
// In jest if test is not matched with test name pattern it is considered as skipped.
|
|
62
63
|
// So adding a check if it is skipped for real or because of test pattern
|
|
@@ -69,6 +70,7 @@ class JestReporter {
|
|
|
69
70
|
title,
|
|
70
71
|
time: duration,
|
|
71
72
|
logs,
|
|
73
|
+
links,
|
|
72
74
|
manuallyAttachedArtifacts: artifacts,
|
|
73
75
|
meta: keyValues,
|
|
74
76
|
});
|
|
@@ -48,8 +48,6 @@ class PlaywrightReporter {
|
|
|
48
48
|
steps.push(appendedStep);
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
-
// Extract and normalize tags
|
|
52
|
-
const tags = extractTags(test);
|
|
53
51
|
const fullTestTitle = getTestContextName(test);
|
|
54
52
|
let logs = '';
|
|
55
53
|
if (result.stderr.length || result.stdout.length) {
|
|
@@ -57,6 +55,7 @@ class PlaywrightReporter {
|
|
|
57
55
|
}
|
|
58
56
|
const manuallyAttachedArtifacts = index_js_1.services.artifacts.get(fullTestTitle);
|
|
59
57
|
const testMeta = index_js_1.services.keyValues.get(fullTestTitle);
|
|
58
|
+
const links = index_js_1.services.links.get(fullTestTitle);
|
|
60
59
|
const rid = test.id || test.testId || (0, uuid_1.v4)();
|
|
61
60
|
/**
|
|
62
61
|
* @type {{
|
|
@@ -87,13 +86,13 @@ class PlaywrightReporter {
|
|
|
87
86
|
const reportTestPromise = this.client.addTestRun(checkStatus(status), {
|
|
88
87
|
rid: `${rid}-${project.name}`,
|
|
89
88
|
error,
|
|
90
|
-
test_id: (0, utils_js_1.getTestomatIdFromTestTitle)(`${title} ${tags
|
|
89
|
+
test_id: (0, utils_js_1.getTestomatIdFromTestTitle)(`${title} ${test.tags?.join(' ')}`),
|
|
91
90
|
suite_title,
|
|
92
91
|
title,
|
|
93
|
-
tags,
|
|
94
92
|
steps: steps.length ? steps : undefined,
|
|
95
93
|
time: duration,
|
|
96
94
|
logs,
|
|
95
|
+
links,
|
|
97
96
|
manuallyAttachedArtifacts,
|
|
98
97
|
meta: {
|
|
99
98
|
browser: project.browser,
|
|
@@ -219,29 +218,6 @@ function generateTmpFilepath(filename = '') {
|
|
|
219
218
|
const tmpdir = os_1.default.tmpdir();
|
|
220
219
|
return path_1.default.join(tmpdir, filename);
|
|
221
220
|
}
|
|
222
|
-
/**
|
|
223
|
-
* Extracts and normalizes tags from test title, test options, and suite level
|
|
224
|
-
* @param {*} test - testInfo object from Playwright
|
|
225
|
-
* @returns {string[]} - array of normalized tags
|
|
226
|
-
*/
|
|
227
|
-
function extractTags(test) {
|
|
228
|
-
const tagsSet = new Set();
|
|
229
|
-
// Extract tags from test title (@tag format)
|
|
230
|
-
const titleTagsMatch = test.title.match(/@\w+/g);
|
|
231
|
-
if (titleTagsMatch) {
|
|
232
|
-
titleTagsMatch.forEach(tag => {
|
|
233
|
-
tagsSet.add(tag.replace('@', '').toLowerCase());
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
// Extract tags from test.tags (Playwright built-in tags)
|
|
237
|
-
if (test.tags && Array.isArray(test.tags)) {
|
|
238
|
-
test.tags.forEach(tag => {
|
|
239
|
-
const normalizedTag = typeof tag === 'string' ? tag.replace('@', '').toLowerCase() : String(tag).toLowerCase();
|
|
240
|
-
tagsSet.add(normalizedTag);
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
return Array.from(tagsSet);
|
|
244
|
-
}
|
|
245
221
|
/**
|
|
246
222
|
* Returns filename + test title
|
|
247
223
|
* @param {*} test - testInfo object from Playwright
|
package/lib/adapter/webdriver.js
CHANGED
|
@@ -41,7 +41,6 @@ const client_js_1 = __importDefault(require("../client.js"));
|
|
|
41
41
|
const utils_js_1 = require("../utils/utils.js");
|
|
42
42
|
const index_js_1 = require("../services/index.js");
|
|
43
43
|
const constants_js_1 = require("../constants.js");
|
|
44
|
-
const data_storage_js_1 = require("../data-storage.js");
|
|
45
44
|
class WebdriverReporter extends reporter_1.default {
|
|
46
45
|
constructor(options) {
|
|
47
46
|
super(options);
|
|
@@ -78,12 +77,10 @@ class WebdriverReporter extends reporter_1.default {
|
|
|
78
77
|
onTestEnd(test) {
|
|
79
78
|
test.suite = test.parent;
|
|
80
79
|
const logs = getTestLogs(test.fullTitle);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
test.artifacts = index_js_1.services.artifacts.get(test.fullTitle);
|
|
81
|
+
test.meta = index_js_1.services.keyValues.get(test.fullTitle);
|
|
82
|
+
test.links = index_js_1.services.links.get(test.fullTitle);
|
|
84
83
|
test.logs = logs;
|
|
85
|
-
test.artifacts = artifacts;
|
|
86
|
-
test.meta = keyValues;
|
|
87
84
|
this._addTestPromises.push(this.addTest(test));
|
|
88
85
|
}
|
|
89
86
|
// wdio-cucumber does not trigger onTestEnd hook, thus, using this one
|
|
@@ -95,19 +92,19 @@ class WebdriverReporter extends reporter_1.default {
|
|
|
95
92
|
async addTest(test) {
|
|
96
93
|
if (!this.client)
|
|
97
94
|
return;
|
|
98
|
-
const { title, _duration: duration, state, error, output } = test;
|
|
95
|
+
const { title, _duration: duration, state, error, output, links, artifacts, meta, logs } = test;
|
|
99
96
|
const testId = (0, utils_js_1.getTestomatIdFromTestTitle)(title);
|
|
100
97
|
const screenshotEndpoint = '/session/:sessionId/screenshot';
|
|
101
98
|
const screenshotsBuffers = output
|
|
102
99
|
.filter(el => el.endpoint === screenshotEndpoint && el.result && el.result.value)
|
|
103
100
|
.map(el => Buffer.from(el.result.value, 'base64'));
|
|
104
|
-
const rid = (0, data_storage_js_1.stringToMD5Hash)(test.fullTitle);
|
|
105
101
|
await this.client.addTestRun(state, {
|
|
106
|
-
rid,
|
|
107
|
-
manuallyAttachedArtifacts:
|
|
102
|
+
rid: test.uid || '',
|
|
103
|
+
manuallyAttachedArtifacts: artifacts,
|
|
108
104
|
error,
|
|
109
|
-
logs
|
|
110
|
-
meta
|
|
105
|
+
logs,
|
|
106
|
+
meta,
|
|
107
|
+
links,
|
|
111
108
|
title,
|
|
112
109
|
test_id: testId,
|
|
113
110
|
time: duration,
|
package/lib/bin/cli.js
CHANGED
|
@@ -78,7 +78,7 @@ program
|
|
|
78
78
|
console.log(constants_js_1.APP_PREFIX, `No command provided. Use -c option to launch a test runner.`);
|
|
79
79
|
return process.exit(255);
|
|
80
80
|
}
|
|
81
|
-
const client = new client_js_1.default({ apiKey, title });
|
|
81
|
+
const client = new client_js_1.default({ apiKey, title, parallel: true });
|
|
82
82
|
if (opts.filter) {
|
|
83
83
|
const [pipe, ...optsArray] = opts.filter.split(':');
|
|
84
84
|
const pipeOptions = optsArray.join(':');
|
|
@@ -95,16 +95,13 @@ program
|
|
|
95
95
|
console.log(constants_js_1.APP_PREFIX, `🚀 Running`, picocolors_1.default.green(command));
|
|
96
96
|
const runTests = async () => {
|
|
97
97
|
const testCmds = command.split(' ');
|
|
98
|
-
const cmd = (0, cross_spawn_1.spawn)(testCmds[0], testCmds.slice(1), {
|
|
99
|
-
stdio: 'inherit',
|
|
100
|
-
env: { ...process.env, TESTOMATIO_PROCEED: 'true', runId: client.runId },
|
|
101
|
-
});
|
|
98
|
+
const cmd = (0, cross_spawn_1.spawn)(testCmds[0], testCmds.slice(1), { stdio: 'inherit' });
|
|
102
99
|
cmd.on('close', async (code) => {
|
|
103
100
|
const emoji = code === 0 ? '🟢' : '🔴';
|
|
104
101
|
console.log(constants_js_1.APP_PREFIX, emoji, `Runner exited with ${picocolors_1.default.bold(code)}`);
|
|
105
102
|
if (apiKey) {
|
|
106
103
|
const status = code === 0 ? 'passed' : 'failed';
|
|
107
|
-
await client.updateRunStatus(status);
|
|
104
|
+
await client.updateRunStatus(status, true);
|
|
108
105
|
}
|
|
109
106
|
process.exit(code);
|
|
110
107
|
});
|
|
@@ -260,13 +257,13 @@ program
|
|
|
260
257
|
const replayService = new replay_js_1.default({
|
|
261
258
|
apiKey: config_js_1.config.TESTOMATIO,
|
|
262
259
|
dryRun: opts.dryRun,
|
|
263
|
-
onLog: message => console.log(constants_js_1.APP_PREFIX, message),
|
|
264
|
-
onError: message => console.error(constants_js_1.APP_PREFIX, '⚠️ ', message),
|
|
260
|
+
onLog: (message) => console.log(constants_js_1.APP_PREFIX, message),
|
|
261
|
+
onError: (message) => console.error(constants_js_1.APP_PREFIX, '⚠️ ', message),
|
|
265
262
|
onProgress: ({ current, total }) => {
|
|
266
263
|
if (current % 10 === 0 || current === total) {
|
|
267
264
|
console.log(constants_js_1.APP_PREFIX, `📊 Progress: ${current}/${total} tests processed`);
|
|
268
265
|
}
|
|
269
|
-
}
|
|
266
|
+
}
|
|
270
267
|
});
|
|
271
268
|
const result = await replayService.replay(debugFile);
|
|
272
269
|
if (result.dryRun) {
|
package/lib/client.d.ts
CHANGED
|
@@ -58,9 +58,10 @@ export class Client {
|
|
|
58
58
|
* Updates the status of the current test run and finishes the run.
|
|
59
59
|
* @param {'passed' | 'failed' | 'skipped' | 'finished'} status - The status of the current test run.
|
|
60
60
|
* Must be one of "passed", "failed", or "finished"
|
|
61
|
+
* @param {boolean} [isParallel] - Whether the current test run was executed in parallel with other tests.
|
|
61
62
|
* @returns {Promise<any>} - A Promise that resolves when finishes the run.
|
|
62
63
|
*/
|
|
63
|
-
updateRunStatus(status: "passed" | "failed" | "skipped" | "finished"): Promise<any>;
|
|
64
|
+
updateRunStatus(status: "passed" | "failed" | "skipped" | "finished", isParallel?: boolean): Promise<any>;
|
|
64
65
|
/**
|
|
65
66
|
* Returns the formatted stack including the stack trace, steps, and logs.
|
|
66
67
|
* @returns {string}
|
package/lib/client.js
CHANGED
|
@@ -50,7 +50,6 @@ const path_1 = __importStar(require("path"));
|
|
|
50
50
|
const node_url_1 = require("node:url");
|
|
51
51
|
const uploader_js_1 = require("./uploader.js");
|
|
52
52
|
const utils_js_1 = require("./utils/utils.js");
|
|
53
|
-
const links_js_1 = require("./services/links.js");
|
|
54
53
|
const filesize_1 = require("filesize");
|
|
55
54
|
const debug = (0, debug_1.default)('@testomatio/reporter:client');
|
|
56
55
|
// removed __dirname usage, because:
|
|
@@ -182,40 +181,11 @@ class Client {
|
|
|
182
181
|
/**
|
|
183
182
|
* @type {TestData}
|
|
184
183
|
*/
|
|
185
|
-
const { rid, error = null, time = 0, example = null, files = [], filesBuffers = [], steps, code = null, title, file, suite_title, suite_id, test_id, timestamp, manuallyAttachedArtifacts, overwrite,
|
|
184
|
+
const { rid, error = null, time = 0, example = null, files = [], filesBuffers = [], steps, code = null, title, file, suite_title, suite_id, test_id, timestamp, links, manuallyAttachedArtifacts, overwrite, } = testData;
|
|
186
185
|
let { message = '', meta = {} } = testData;
|
|
187
186
|
// stringify meta values and limit keys and values length to 255
|
|
188
187
|
meta = Object.entries(meta)
|
|
189
188
|
.filter(([, value]) => value !== null && value !== undefined)
|
|
190
|
-
.map(([key, value]) => {
|
|
191
|
-
try {
|
|
192
|
-
if (typeof value === 'object') {
|
|
193
|
-
value = JSON.stringify(value);
|
|
194
|
-
}
|
|
195
|
-
else if (typeof value !== 'string') {
|
|
196
|
-
try {
|
|
197
|
-
value = value.toString();
|
|
198
|
-
}
|
|
199
|
-
catch (err) {
|
|
200
|
-
console.warn(constants_js_1.APP_PREFIX, `Can't convert meta value to string`, err);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
if (value?.length > 255) {
|
|
204
|
-
value = value.substring(0, 255);
|
|
205
|
-
debug(constants_js_1.APP_PREFIX, `Meta info value "${value}" is too long, trimmed to 255 characters`);
|
|
206
|
-
}
|
|
207
|
-
if (key?.length > 255) {
|
|
208
|
-
const newKey = key.substring(0, 255);
|
|
209
|
-
debug(constants_js_1.APP_PREFIX, `Meta info key "${key}" is too long, trimmed to 255 characters`);
|
|
210
|
-
return [newKey, value];
|
|
211
|
-
}
|
|
212
|
-
return [key, value];
|
|
213
|
-
}
|
|
214
|
-
catch (err) {
|
|
215
|
-
debug(constants_js_1.APP_PREFIX, `Error while processing meta info key ${key}`, err);
|
|
216
|
-
return [null, null];
|
|
217
|
-
}
|
|
218
|
-
})
|
|
219
189
|
.reduce((acc, [key, value]) => {
|
|
220
190
|
if (key)
|
|
221
191
|
acc[key] = value;
|
|
@@ -223,7 +193,6 @@ class Client {
|
|
|
223
193
|
}, {});
|
|
224
194
|
// Get links from storage using the test context
|
|
225
195
|
const testContext = suite_title ? `${suite_title} ${title}` : title;
|
|
226
|
-
const links = links_js_1.linkStorage.get(testContext) || [];
|
|
227
196
|
let errorFormatted = '';
|
|
228
197
|
if (error) {
|
|
229
198
|
errorFormatted += this.formatError(error) || '';
|
|
@@ -273,7 +242,6 @@ class Client {
|
|
|
273
242
|
meta,
|
|
274
243
|
links,
|
|
275
244
|
overwrite,
|
|
276
|
-
tags,
|
|
277
245
|
...(rootSuiteId && { root_suite_id: rootSuiteId }),
|
|
278
246
|
};
|
|
279
247
|
// debug('Adding test run...', data);
|
|
@@ -295,16 +263,17 @@ class Client {
|
|
|
295
263
|
* Updates the status of the current test run and finishes the run.
|
|
296
264
|
* @param {'passed' | 'failed' | 'skipped' | 'finished'} status - The status of the current test run.
|
|
297
265
|
* Must be one of "passed", "failed", or "finished"
|
|
266
|
+
* @param {boolean} [isParallel] - Whether the current test run was executed in parallel with other tests.
|
|
298
267
|
* @returns {Promise<any>} - A Promise that resolves when finishes the run.
|
|
299
268
|
*/
|
|
300
|
-
async updateRunStatus(status) {
|
|
269
|
+
async updateRunStatus(status, isParallel = false) {
|
|
301
270
|
this.pipes ||= await (0, index_js_1.pipesFactory)(this.paramsForPipesFactory || {}, this.pipeStore);
|
|
302
271
|
this.runId ||= (0, utils_js_1.readLatestRunId)();
|
|
303
272
|
debug('Updating run status...');
|
|
304
273
|
// all pipes disabled, skipping
|
|
305
274
|
if (!this.pipes?.filter(p => p.isEnabled).length)
|
|
306
275
|
return Promise.resolve();
|
|
307
|
-
const runParams = { status };
|
|
276
|
+
const runParams = { status, parallel: isParallel };
|
|
308
277
|
this.queue = this.queue
|
|
309
278
|
.then(() => Promise.all(this.pipes.map(p => p.finishRun(runParams))))
|
|
310
279
|
.then(() => {
|
package/lib/data-storage.js
CHANGED
|
@@ -143,7 +143,7 @@ class DataStorage {
|
|
|
143
143
|
if (global?.testomatioDataStore[dataType]) {
|
|
144
144
|
const testData = global.testomatioDataStore[dataType][context];
|
|
145
145
|
if (testData)
|
|
146
|
-
debug(
|
|
146
|
+
debug('<=', dataType, 'global', context, testData);
|
|
147
147
|
return testData || [];
|
|
148
148
|
}
|
|
149
149
|
// debug(`No ${this.dataType} data for context ${context} in <global> storage`);
|
|
@@ -165,7 +165,7 @@ class DataStorage {
|
|
|
165
165
|
if (fs_1.default.existsSync(filepath)) {
|
|
166
166
|
const testDataAsText = fs_1.default.readFileSync(filepath, 'utf-8');
|
|
167
167
|
if (testDataAsText)
|
|
168
|
-
debug(
|
|
168
|
+
debug('<=', dataType, 'file', context, testDataAsText);
|
|
169
169
|
const testDataArr = testDataAsText?.split(os_1.default.EOL) || [];
|
|
170
170
|
return testDataArr;
|
|
171
171
|
}
|
|
@@ -184,7 +184,7 @@ class DataStorage {
|
|
|
184
184
|
* @param {*} context
|
|
185
185
|
*/
|
|
186
186
|
#putDataToGlobalVar(dataType, data, context) {
|
|
187
|
-
debug('
|
|
187
|
+
debug('=>', dataType, 'global', context, data);
|
|
188
188
|
if (!global.testomatioDataStore)
|
|
189
189
|
global.testomatioDataStore = {};
|
|
190
190
|
if (!global.testomatioDataStore?.[dataType])
|
|
@@ -208,7 +208,7 @@ class DataStorage {
|
|
|
208
208
|
const filepath = (0, path_1.join)(dataDirPath, filename);
|
|
209
209
|
if (!fs_1.default.existsSync(dataDirPath))
|
|
210
210
|
utils_js_1.fileSystem.createDir(dataDirPath);
|
|
211
|
-
debug(
|
|
211
|
+
debug('=>', dataType, 'file', context, data);
|
|
212
212
|
// append new line if file already exists (in this case its definitely includes some data)
|
|
213
213
|
if (fs_1.default.existsSync(filepath)) {
|
|
214
214
|
fs_1.default.appendFileSync(filepath, os_1.default.EOL + data, 'utf-8');
|
|
@@ -222,7 +222,7 @@ function stringToMD5Hash(str) {
|
|
|
222
222
|
const md5 = crypto_1.default.createHash('md5');
|
|
223
223
|
md5.update(str);
|
|
224
224
|
const hash = md5.digest('hex');
|
|
225
|
-
return hash
|
|
225
|
+
return `${process.env.runId || 'run'}_${hash}`;
|
|
226
226
|
}
|
|
227
227
|
exports.dataStorage = DataStorage.getInstance();
|
|
228
228
|
// TODO: consider using fs promises instead of writeSync/appendFileSync to
|
package/lib/pipe/testomatio.d.ts
CHANGED
package/lib/pipe/testomatio.js
CHANGED
|
@@ -43,6 +43,7 @@ class TestomatioPipe {
|
|
|
43
43
|
debug('Testomatio Pipe: Enabled');
|
|
44
44
|
const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY;
|
|
45
45
|
const proxy = proxyUrl ? new URL(proxyUrl) : null;
|
|
46
|
+
this.parallel = params.parallel;
|
|
46
47
|
this.store = store || {};
|
|
47
48
|
this.title = params.title || process.env.TESTOMATIO_TITLE;
|
|
48
49
|
this.sharedRun = !!process.env.TESTOMATIO_SHARED_RUN;
|
|
@@ -153,6 +154,7 @@ class TestomatioPipe {
|
|
|
153
154
|
const accessEvent = process.env.TESTOMATIO_PUBLISH ? 'publish' : null;
|
|
154
155
|
const runParams = Object.fromEntries(Object.entries({
|
|
155
156
|
ci_build_url: buildUrl,
|
|
157
|
+
parallel: this.parallel,
|
|
156
158
|
api_key: this.apiKey.trim(),
|
|
157
159
|
group_title: this.groupTitle,
|
|
158
160
|
access_event: accessEvent,
|
|
@@ -375,7 +377,7 @@ class TestomatioPipe {
|
|
|
375
377
|
const errorMessage = picocolors_1.default.red(`⚠️ Due to request failures, ${this.notReportedTestsCount} test(s) were not reported to Testomat.io`);
|
|
376
378
|
console.warn(`${constants_js_1.APP_PREFIX} ${errorMessage}`);
|
|
377
379
|
}
|
|
378
|
-
const { status } = params;
|
|
380
|
+
const { status, parallel } = params;
|
|
379
381
|
let status_event;
|
|
380
382
|
if (status === constants_js_1.STATUS.FINISHED)
|
|
381
383
|
status_event = 'finish';
|
|
@@ -383,6 +385,8 @@ class TestomatioPipe {
|
|
|
383
385
|
status_event = 'pass';
|
|
384
386
|
if (status === constants_js_1.STATUS.FAILED)
|
|
385
387
|
status_event = 'fail';
|
|
388
|
+
if (parallel)
|
|
389
|
+
status_event += '_parallel';
|
|
386
390
|
try {
|
|
387
391
|
if (this.runId && !this.proceed) {
|
|
388
392
|
await this.client.request({
|
package/lib/replay.js
CHANGED
|
@@ -238,7 +238,7 @@ class Replay {
|
|
|
238
238
|
});
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
|
-
await client.updateRunStatus(finishParams.status || constants_js_1.STATUS.FINISHED);
|
|
241
|
+
await client.updateRunStatus(finishParams.status || constants_js_1.STATUS.FINISHED, finishParams.parallel || false);
|
|
242
242
|
const result = {
|
|
243
243
|
success: true,
|
|
244
244
|
testsCount: tests.length,
|
|
@@ -5,6 +5,7 @@ declare namespace _default {
|
|
|
5
5
|
export { setKeyValue as keyValue };
|
|
6
6
|
export { setLabel as label };
|
|
7
7
|
export { linkTest };
|
|
8
|
+
export { linkJira };
|
|
8
9
|
}
|
|
9
10
|
export default _default;
|
|
10
11
|
/**
|
|
@@ -40,15 +41,21 @@ declare function setKeyValue(keyValue: {
|
|
|
40
41
|
[key: string]: string;
|
|
41
42
|
} | string, value?: string | null): void;
|
|
42
43
|
/**
|
|
43
|
-
* Add label
|
|
44
|
+
* Add a single label to the test report
|
|
44
45
|
* @param {string} key - label key (e.g. 'severity', 'feature', or just 'smoke' for labels without values)
|
|
45
|
-
* @param {string|
|
|
46
|
+
* @param {string|null} [value=null] - optional label value (e.g. 'high', 'login')
|
|
46
47
|
* @returns {void}
|
|
47
48
|
*/
|
|
48
|
-
declare function setLabel(key: string, value?: string |
|
|
49
|
+
declare function setLabel(key: string, value?: string | null): void;
|
|
49
50
|
/**
|
|
50
51
|
* Add link(s) to the test report
|
|
51
52
|
* @param {...string} testIds - test IDs to link
|
|
52
53
|
* @returns {void}
|
|
53
54
|
*/
|
|
54
55
|
declare function linkTest(...testIds: string[]): void;
|
|
56
|
+
/**
|
|
57
|
+
* Add JIRA issue link(s) to the test report
|
|
58
|
+
* @param {...string} jiraIds - JIRA issue IDs to link
|
|
59
|
+
* @returns {void}
|
|
60
|
+
*/
|
|
61
|
+
declare function linkJira(...jiraIds: string[]): void;
|
|
@@ -50,15 +50,14 @@ function setKeyValue(keyValue, value = null) {
|
|
|
50
50
|
index_js_1.services.keyValues.put(keyValue);
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
53
|
-
* Add label
|
|
53
|
+
* Add a single label to the test report
|
|
54
54
|
* @param {string} key - label key (e.g. 'severity', 'feature', or just 'smoke' for labels without values)
|
|
55
|
-
* @param {string|
|
|
55
|
+
* @param {string|null} [value=null] - optional label value (e.g. 'high', 'login')
|
|
56
56
|
* @returns {void}
|
|
57
57
|
*/
|
|
58
58
|
function setLabel(key, value = null) {
|
|
59
59
|
if (Array.isArray(value)) {
|
|
60
|
-
value.forEach(
|
|
61
|
-
return;
|
|
60
|
+
return value.forEach(label => setLabel(key, label));
|
|
62
61
|
}
|
|
63
62
|
const labelObject = value !== null && value !== undefined && value !== ''
|
|
64
63
|
? { label: `${key}:${value}` }
|
|
@@ -74,6 +73,15 @@ function linkTest(...testIds) {
|
|
|
74
73
|
const links = testIds.map(testId => ({ test: testId }));
|
|
75
74
|
index_js_1.services.links.put(links);
|
|
76
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Add JIRA issue link(s) to the test report
|
|
78
|
+
* @param {...string} jiraIds - JIRA issue IDs to link
|
|
79
|
+
* @returns {void}
|
|
80
|
+
*/
|
|
81
|
+
function linkJira(...jiraIds) {
|
|
82
|
+
const links = jiraIds.map(jiraId => ({ jira: jiraId }));
|
|
83
|
+
index_js_1.services.links.put(links);
|
|
84
|
+
}
|
|
77
85
|
module.exports = {
|
|
78
86
|
artifact: saveArtifact,
|
|
79
87
|
log: logMessage,
|
|
@@ -81,4 +89,5 @@ module.exports = {
|
|
|
81
89
|
keyValue: setKeyValue,
|
|
82
90
|
label: setLabel,
|
|
83
91
|
linkTest,
|
|
92
|
+
linkJira,
|
|
84
93
|
};
|
package/lib/reporter.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export const artifact: (data: string | {
|
|
|
5
5
|
}, context?: any) => void;
|
|
6
6
|
export const log: (...args: any[]) => void;
|
|
7
7
|
export const logger: {
|
|
8
|
-
"__#
|
|
8
|
+
"__#13@#originalUserLogger": {
|
|
9
9
|
assert(condition?: boolean, ...data: any[]): void;
|
|
10
10
|
assert(value: any, message?: string, ...optionalParams: any[]): void;
|
|
11
11
|
clear(): void;
|
|
@@ -50,13 +50,13 @@ export const logger: {
|
|
|
50
50
|
profile(label?: string): void;
|
|
51
51
|
profileEnd(label?: string): void;
|
|
52
52
|
};
|
|
53
|
-
"__#
|
|
53
|
+
"__#13@#userLoggerWithOverridenMethods": any;
|
|
54
54
|
logLevel: string;
|
|
55
55
|
step(strings: any, ...values: any[]): void;
|
|
56
56
|
getLogs(context: string): string[];
|
|
57
|
-
"__#
|
|
57
|
+
"__#13@#stringifyLogs"(...args: any[]): string;
|
|
58
58
|
_templateLiteralLog(strings: any, ...args: any[]): void;
|
|
59
|
-
"__#
|
|
59
|
+
"__#13@#logWrapper"(argsArray: any, level: any): void;
|
|
60
60
|
assert(...args: any[]): void;
|
|
61
61
|
debug(...args: any[]): void;
|
|
62
62
|
error(...args: any[]): void;
|
|
@@ -76,11 +76,12 @@ export const meta: (keyValue: {
|
|
|
76
76
|
[key: string]: string;
|
|
77
77
|
} | string, value?: string | null) => void;
|
|
78
78
|
export const step: (message: string) => void;
|
|
79
|
-
export const label: (key: string, value?: string |
|
|
79
|
+
export const label: (key: string, value?: string | null) => void;
|
|
80
80
|
export const linkTest: (...testIds: string[]) => void;
|
|
81
|
+
export const linkJira: (...jiraIds: string[]) => void;
|
|
81
82
|
declare namespace _default {
|
|
82
83
|
let testomatioLogger: {
|
|
83
|
-
"__#
|
|
84
|
+
"__#13@#originalUserLogger": {
|
|
84
85
|
assert(condition?: boolean, ...data: any[]): void;
|
|
85
86
|
assert(value: any, message?: string, ...optionalParams: any[]): void;
|
|
86
87
|
clear(): void;
|
|
@@ -125,13 +126,13 @@ declare namespace _default {
|
|
|
125
126
|
profile(label?: string): void;
|
|
126
127
|
profileEnd(label?: string): void;
|
|
127
128
|
};
|
|
128
|
-
"__#
|
|
129
|
+
"__#13@#userLoggerWithOverridenMethods": any;
|
|
129
130
|
logLevel: string;
|
|
130
131
|
step(strings: any, ...values: any[]): void;
|
|
131
132
|
getLogs(context: string): string[];
|
|
132
|
-
"__#
|
|
133
|
+
"__#13@#stringifyLogs"(...args: any[]): string;
|
|
133
134
|
_templateLiteralLog(strings: any, ...args: any[]): void;
|
|
134
|
-
"__#
|
|
135
|
+
"__#13@#logWrapper"(argsArray: any, level: any): void;
|
|
135
136
|
assert(...args: any[]): void;
|
|
136
137
|
debug(...args: any[]): void;
|
|
137
138
|
error(...args: any[]): void;
|
|
@@ -154,7 +155,7 @@ declare namespace _default {
|
|
|
154
155
|
}, context?: any) => void;
|
|
155
156
|
let log: (...args: any[]) => void;
|
|
156
157
|
let logger: {
|
|
157
|
-
"__#
|
|
158
|
+
"__#13@#originalUserLogger": {
|
|
158
159
|
assert(condition?: boolean, ...data: any[]): void;
|
|
159
160
|
assert(value: any, message?: string, ...optionalParams: any[]): void;
|
|
160
161
|
clear(): void;
|
|
@@ -199,13 +200,13 @@ declare namespace _default {
|
|
|
199
200
|
profile(label?: string): void;
|
|
200
201
|
profileEnd(label?: string): void;
|
|
201
202
|
};
|
|
202
|
-
"__#
|
|
203
|
+
"__#13@#userLoggerWithOverridenMethods": any;
|
|
203
204
|
logLevel: string;
|
|
204
205
|
step(strings: any, ...values: any[]): void;
|
|
205
206
|
getLogs(context: string): string[];
|
|
206
|
-
"__#
|
|
207
|
+
"__#13@#stringifyLogs"(...args: any[]): string;
|
|
207
208
|
_templateLiteralLog(strings: any, ...args: any[]): void;
|
|
208
|
-
"__#
|
|
209
|
+
"__#13@#logWrapper"(argsArray: any, level: any): void;
|
|
209
210
|
assert(...args: any[]): void;
|
|
210
211
|
debug(...args: any[]): void;
|
|
211
212
|
error(...args: any[]): void;
|
|
@@ -225,8 +226,9 @@ declare namespace _default {
|
|
|
225
226
|
[key: string]: string;
|
|
226
227
|
} | string, value?: string | null) => void;
|
|
227
228
|
let step: (message: string) => void;
|
|
228
|
-
let label: (key: string, value?: string |
|
|
229
|
+
let label: (key: string, value?: string | null) => void;
|
|
229
230
|
let linkTest: (...testIds: string[]) => void;
|
|
231
|
+
let linkJira: (...jiraIds: string[]) => void;
|
|
230
232
|
}
|
|
231
233
|
export default _default;
|
|
232
234
|
export type ArtifactFunction = typeof import("./reporter-functions.js").default.artifact;
|
package/lib/reporter.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.linkTest = exports.label = exports.step = exports.meta = exports.logger = exports.log = exports.artifact = void 0;
|
|
6
|
+
exports.linkJira = exports.linkTest = exports.label = exports.step = exports.meta = exports.logger = exports.log = exports.artifact = void 0;
|
|
7
7
|
// import TestomatClient from './client.js';
|
|
8
8
|
// import * as TRConstants from './constants.js';
|
|
9
9
|
const index_js_1 = require("./services/index.js");
|
|
@@ -15,6 +15,7 @@ exports.meta = reporter_functions_js_1.default.keyValue;
|
|
|
15
15
|
exports.step = reporter_functions_js_1.default.step;
|
|
16
16
|
exports.label = reporter_functions_js_1.default.label;
|
|
17
17
|
exports.linkTest = reporter_functions_js_1.default.linkTest;
|
|
18
|
+
exports.linkJira = reporter_functions_js_1.default.linkJira;
|
|
18
19
|
/**
|
|
19
20
|
* @typedef {typeof import('./reporter-functions.js').default.artifact} ArtifactFunction
|
|
20
21
|
* @typedef {typeof import('./reporter-functions.js').default.log} LogFunction
|
|
@@ -35,6 +36,7 @@ module.exports = {
|
|
|
35
36
|
step: reporter_functions_js_1.default.step,
|
|
36
37
|
label: reporter_functions_js_1.default.label,
|
|
37
38
|
linkTest: reporter_functions_js_1.default.linkTest,
|
|
39
|
+
linkJira: reporter_functions_js_1.default.linkJira,
|
|
38
40
|
// TestomatClient,
|
|
39
41
|
// TRConstants,
|
|
40
42
|
};
|
package/lib/services/labels.d.ts
CHANGED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
export const labelStorage: LabelStorage;
|
|
2
|
-
declare class LabelStorage {
|
|
3
|
-
static "__#19@#instance": any;
|
|
4
|
-
/**
|
|
5
|
-
*
|
|
6
|
-
* @returns {LabelStorage}
|
|
7
|
-
*/
|
|
8
|
-
static getInstance(): LabelStorage;
|
|
9
|
-
/**
|
|
10
|
-
* Stores labels array and passes it to reporter
|
|
11
|
-
* @param {string[]} labels - array of label strings
|
|
12
|
-
* @param {*} context - full test title
|
|
13
|
-
*/
|
|
14
|
-
put(labels: string[], context?: any): void;
|
|
15
|
-
/**
|
|
16
|
-
* Returns labels array for the test
|
|
17
|
-
* @param {*} context testId or test context from test runner
|
|
18
|
-
* @returns {string[]} labels array, e.g. ['smoke', 'severity:high', 'feature:user_account']
|
|
19
|
-
*/
|
|
20
|
-
get(context?: any): string[];
|
|
21
|
-
}
|
|
22
|
-
export {};
|
package/lib/services/labels.js
CHANGED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.labelStorage = void 0;
|
|
7
|
-
const debug_1 = __importDefault(require("debug"));
|
|
8
|
-
const data_storage_js_1 = require("../data-storage.js");
|
|
9
|
-
const debug = (0, debug_1.default)('@testomatio/reporter:services-labels');
|
|
10
|
-
class LabelStorage {
|
|
11
|
-
static #instance;
|
|
12
|
-
/**
|
|
13
|
-
*
|
|
14
|
-
* @returns {LabelStorage}
|
|
15
|
-
*/
|
|
16
|
-
static getInstance() {
|
|
17
|
-
if (!this.#instance) {
|
|
18
|
-
this.#instance = new LabelStorage();
|
|
19
|
-
}
|
|
20
|
-
return this.#instance;
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Stores labels array and passes it to reporter
|
|
24
|
-
* @param {string[]} labels - array of label strings
|
|
25
|
-
* @param {*} context - full test title
|
|
26
|
-
*/
|
|
27
|
-
put(labels, context = null) {
|
|
28
|
-
if (!labels || !Array.isArray(labels))
|
|
29
|
-
return;
|
|
30
|
-
data_storage_js_1.dataStorage.putData('links', labels, context);
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Returns labels array for the test
|
|
34
|
-
* @param {*} context testId or test context from test runner
|
|
35
|
-
* @returns {string[]} labels array, e.g. ['smoke', 'severity:high', 'feature:user_account']
|
|
36
|
-
*/
|
|
37
|
-
get(context = null) {
|
|
38
|
-
const labelsList = data_storage_js_1.dataStorage.getData('links', context);
|
|
39
|
-
if (!labelsList || !labelsList?.length)
|
|
40
|
-
return [];
|
|
41
|
-
const allLabels = [];
|
|
42
|
-
for (const labels of labelsList) {
|
|
43
|
-
if (Array.isArray(labels)) {
|
|
44
|
-
allLabels.push(...labels);
|
|
45
|
-
}
|
|
46
|
-
else if (typeof labels === 'string') {
|
|
47
|
-
try {
|
|
48
|
-
const parsedLabels = JSON.parse(labels);
|
|
49
|
-
if (Array.isArray(parsedLabels)) {
|
|
50
|
-
allLabels.push(...parsedLabels);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
catch (e) {
|
|
54
|
-
debug(`Error parsing labels for test ${context}`, labels);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
// Remove duplicates
|
|
59
|
-
return [...new Set(allLabels)];
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
exports.labelStorage = LabelStorage.getInstance();
|
package/lib/services/links.d.ts
CHANGED
package/lib/services/logger.d.ts
CHANGED
package/package.json
CHANGED
package/src/adapter/codecept.js
CHANGED
|
@@ -25,6 +25,9 @@ const HOOK_EXECUTION_ORDER = {
|
|
|
25
25
|
POST_TEST: ['AfterHook', 'AfterSuiteHook']
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
// codeceptjs workers are self-contained
|
|
29
|
+
dataStorage.isFileStorage = false;
|
|
30
|
+
|
|
28
31
|
const DATA_REGEXP = /[|\s]+?(\{".*\}|\[.*\])/;
|
|
29
32
|
|
|
30
33
|
if (MAJOR_VERSION < 3) {
|
|
@@ -464,10 +467,10 @@ function formatHookStep(step) {
|
|
|
464
467
|
// For hook steps, construct title from available properties
|
|
465
468
|
let title = step.name;
|
|
466
469
|
if (step.actor && step.name) {
|
|
467
|
-
title = `${step.actor}
|
|
470
|
+
title = `${step.actor}.${step.name}`;
|
|
468
471
|
if (step.args && step.args.length > 0) {
|
|
469
472
|
const argsStr = step.args.map(arg => JSON.stringify(arg)).join(', ');
|
|
470
|
-
title += `
|
|
473
|
+
title += `(${argsStr})`;
|
|
471
474
|
}
|
|
472
475
|
}
|
|
473
476
|
|
|
@@ -113,6 +113,7 @@ class CucumberReporter extends Formatter {
|
|
|
113
113
|
const logs = services.logger.getLogs(testTitle).join('\n');
|
|
114
114
|
const artifacts = services.artifacts.get(testTitle);
|
|
115
115
|
const keyValues = services.keyValues.get(testTitle);
|
|
116
|
+
const links = services.links.get(testTitle);
|
|
116
117
|
|
|
117
118
|
this.client.addTestRun(status, {
|
|
118
119
|
// error: testCaseAttempt.worstTestStepResult.message,
|
|
@@ -123,6 +124,7 @@ class CucumberReporter extends Formatter {
|
|
|
123
124
|
.trim(),
|
|
124
125
|
example: { ...example },
|
|
125
126
|
logs,
|
|
127
|
+
links,
|
|
126
128
|
manuallyAttachedArtifacts: artifacts,
|
|
127
129
|
meta: keyValues,
|
|
128
130
|
title: scenario,
|
package/src/adapter/jest.js
CHANGED
|
@@ -59,6 +59,7 @@ export class JestReporter {
|
|
|
59
59
|
const logs = getTestLogs(result);
|
|
60
60
|
const artifacts = services.artifacts.get(result.fullName);
|
|
61
61
|
const keyValues = services.keyValues.get(result.fullName);
|
|
62
|
+
const links = services.links.get(result.fullName);
|
|
62
63
|
|
|
63
64
|
const deducedStatus = status === 'pending' ? 'skipped' : status;
|
|
64
65
|
// In jest if test is not matched with test name pattern it is considered as skipped.
|
|
@@ -72,6 +73,7 @@ export class JestReporter {
|
|
|
72
73
|
title,
|
|
73
74
|
time: duration,
|
|
74
75
|
logs,
|
|
76
|
+
links,
|
|
75
77
|
manuallyAttachedArtifacts: artifacts,
|
|
76
78
|
meta: keyValues,
|
|
77
79
|
});
|
|
@@ -51,9 +51,6 @@ class PlaywrightReporter {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
// Extract and normalize tags
|
|
55
|
-
const tags = extractTags(test);
|
|
56
|
-
|
|
57
54
|
const fullTestTitle = getTestContextName(test);
|
|
58
55
|
let logs = '';
|
|
59
56
|
if (result.stderr.length || result.stdout.length) {
|
|
@@ -61,6 +58,7 @@ class PlaywrightReporter {
|
|
|
61
58
|
}
|
|
62
59
|
const manuallyAttachedArtifacts = services.artifacts.get(fullTestTitle);
|
|
63
60
|
const testMeta = services.keyValues.get(fullTestTitle);
|
|
61
|
+
const links = services.links.get(fullTestTitle);
|
|
64
62
|
const rid = test.id || test.testId || uuidv4();
|
|
65
63
|
|
|
66
64
|
/**
|
|
@@ -92,13 +90,13 @@ class PlaywrightReporter {
|
|
|
92
90
|
const reportTestPromise = this.client.addTestRun(checkStatus(status), {
|
|
93
91
|
rid: `${rid}-${project.name}`,
|
|
94
92
|
error,
|
|
95
|
-
test_id: getTestomatIdFromTestTitle(`${title} ${tags
|
|
93
|
+
test_id: getTestomatIdFromTestTitle(`${title} ${test.tags?.join(' ')}`),
|
|
96
94
|
suite_title,
|
|
97
95
|
title,
|
|
98
|
-
tags,
|
|
99
96
|
steps: steps.length ? steps : undefined,
|
|
100
97
|
time: duration,
|
|
101
98
|
logs,
|
|
99
|
+
links,
|
|
102
100
|
manuallyAttachedArtifacts,
|
|
103
101
|
meta: {
|
|
104
102
|
browser: project.browser,
|
|
@@ -245,32 +243,6 @@ function generateTmpFilepath(filename = '') {
|
|
|
245
243
|
return path.join(tmpdir, filename);
|
|
246
244
|
}
|
|
247
245
|
|
|
248
|
-
/**
|
|
249
|
-
* Extracts and normalizes tags from test title, test options, and suite level
|
|
250
|
-
* @param {*} test - testInfo object from Playwright
|
|
251
|
-
* @returns {string[]} - array of normalized tags
|
|
252
|
-
*/
|
|
253
|
-
function extractTags(test) {
|
|
254
|
-
const tagsSet = new Set();
|
|
255
|
-
|
|
256
|
-
// Extract tags from test title (@tag format)
|
|
257
|
-
const titleTagsMatch = test.title.match(/@\w+/g);
|
|
258
|
-
if (titleTagsMatch) {
|
|
259
|
-
titleTagsMatch.forEach(tag => {
|
|
260
|
-
tagsSet.add(tag.replace('@', '').toLowerCase());
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Extract tags from test.tags (Playwright built-in tags)
|
|
265
|
-
if (test.tags && Array.isArray(test.tags)) {
|
|
266
|
-
test.tags.forEach(tag => {
|
|
267
|
-
const normalizedTag = typeof tag === 'string' ? tag.replace('@', '').toLowerCase() : String(tag).toLowerCase();
|
|
268
|
-
tagsSet.add(normalizedTag);
|
|
269
|
-
});
|
|
270
|
-
}
|
|
271
|
-
return Array.from(tagsSet);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
246
|
/**
|
|
275
247
|
* Returns filename + test title
|
|
276
248
|
* @param {*} test - testInfo object from Playwright
|
package/src/adapter/webdriver.js
CHANGED
|
@@ -3,7 +3,6 @@ import TestomatClient from '../client.js';
|
|
|
3
3
|
import { getTestomatIdFromTestTitle, fileSystem } from '../utils/utils.js';
|
|
4
4
|
import { services } from '../services/index.js';
|
|
5
5
|
import { TESTOMAT_TMP_STORAGE_DIR } from '../constants.js';
|
|
6
|
-
import { stringToMD5Hash } from '../data-storage.js';
|
|
7
6
|
|
|
8
7
|
class WebdriverReporter extends WDIOReporter {
|
|
9
8
|
constructor(options) {
|
|
@@ -53,12 +52,10 @@ class WebdriverReporter extends WDIOReporter {
|
|
|
53
52
|
onTestEnd(test) {
|
|
54
53
|
test.suite = test.parent;
|
|
55
54
|
const logs = getTestLogs(test.fullTitle);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
test.artifacts = services.artifacts.get(test.fullTitle);
|
|
56
|
+
test.meta = services.keyValues.get(test.fullTitle);
|
|
57
|
+
test.links = services.links.get(test.fullTitle);
|
|
59
58
|
test.logs = logs;
|
|
60
|
-
test.artifacts = artifacts;
|
|
61
|
-
test.meta = keyValues;
|
|
62
59
|
|
|
63
60
|
this._addTestPromises.push(this.addTest(test));
|
|
64
61
|
}
|
|
@@ -73,7 +70,7 @@ class WebdriverReporter extends WDIOReporter {
|
|
|
73
70
|
async addTest(test) {
|
|
74
71
|
if (!this.client) return;
|
|
75
72
|
|
|
76
|
-
const { title, _duration: duration, state, error, output } = test;
|
|
73
|
+
const { title, _duration: duration, state, error, output, links, artifacts, meta, logs } = test;
|
|
77
74
|
|
|
78
75
|
const testId = getTestomatIdFromTestTitle(title);
|
|
79
76
|
|
|
@@ -82,14 +79,13 @@ class WebdriverReporter extends WDIOReporter {
|
|
|
82
79
|
.filter(el => el.endpoint === screenshotEndpoint && el.result && el.result.value)
|
|
83
80
|
.map(el => Buffer.from(el.result.value, 'base64'));
|
|
84
81
|
|
|
85
|
-
const rid = stringToMD5Hash(test.fullTitle);
|
|
86
|
-
|
|
87
82
|
await this.client.addTestRun(state, {
|
|
88
|
-
rid,
|
|
89
|
-
manuallyAttachedArtifacts:
|
|
83
|
+
rid: test.uid || '',
|
|
84
|
+
manuallyAttachedArtifacts: artifacts,
|
|
90
85
|
error,
|
|
91
|
-
logs
|
|
92
|
-
meta
|
|
86
|
+
logs,
|
|
87
|
+
meta,
|
|
88
|
+
links,
|
|
93
89
|
title,
|
|
94
90
|
test_id: testId,
|
|
95
91
|
time: duration,
|
package/src/bin/cli.js
CHANGED
|
@@ -85,7 +85,7 @@ program
|
|
|
85
85
|
return process.exit(255);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
const client = new TestomatClient({ apiKey, title });
|
|
88
|
+
const client = new TestomatClient({ apiKey, title, parallel: true });
|
|
89
89
|
|
|
90
90
|
if (opts.filter) {
|
|
91
91
|
const [pipe, ...optsArray] = opts.filter.split(':');
|
|
@@ -105,17 +105,14 @@ program
|
|
|
105
105
|
|
|
106
106
|
const runTests = async () => {
|
|
107
107
|
const testCmds = command.split(' ');
|
|
108
|
-
const cmd = spawn(testCmds[0], testCmds.slice(1), {
|
|
109
|
-
stdio: 'inherit',
|
|
110
|
-
env: { ...process.env, TESTOMATIO_PROCEED: 'true', runId: client.runId },
|
|
111
|
-
});
|
|
108
|
+
const cmd = spawn(testCmds[0], testCmds.slice(1), { stdio: 'inherit' });
|
|
112
109
|
|
|
113
110
|
cmd.on('close', async code => {
|
|
114
111
|
const emoji = code === 0 ? '🟢' : '🔴';
|
|
115
112
|
console.log(APP_PREFIX, emoji, `Runner exited with ${pc.bold(code)}`);
|
|
116
113
|
if (apiKey) {
|
|
117
114
|
const status = code === 0 ? 'passed' : 'failed';
|
|
118
|
-
await client.updateRunStatus(status);
|
|
115
|
+
await client.updateRunStatus(status, true);
|
|
119
116
|
}
|
|
120
117
|
process.exit(code);
|
|
121
118
|
});
|
|
@@ -313,13 +310,13 @@ program
|
|
|
313
310
|
const replayService = new Replay({
|
|
314
311
|
apiKey: config.TESTOMATIO,
|
|
315
312
|
dryRun: opts.dryRun,
|
|
316
|
-
onLog: message => console.log(APP_PREFIX, message),
|
|
317
|
-
onError: message => console.error(APP_PREFIX, '⚠️ ', message),
|
|
313
|
+
onLog: (message) => console.log(APP_PREFIX, message),
|
|
314
|
+
onError: (message) => console.error(APP_PREFIX, '⚠️ ', message),
|
|
318
315
|
onProgress: ({ current, total }) => {
|
|
319
316
|
if (current % 10 === 0 || current === total) {
|
|
320
317
|
console.log(APP_PREFIX, `📊 Progress: ${current}/${total} tests processed`);
|
|
321
318
|
}
|
|
322
|
-
}
|
|
319
|
+
}
|
|
323
320
|
});
|
|
324
321
|
|
|
325
322
|
const result = await replayService.replay(debugFile);
|
package/src/client.js
CHANGED
|
@@ -11,7 +11,6 @@ import path, { sep } from 'path';
|
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { S3Uploader } from './uploader.js';
|
|
13
13
|
import { formatStep, readLatestRunId, storeRunId, validateSuiteId } from './utils/utils.js';
|
|
14
|
-
import { linkStorage } from './services/links.js';
|
|
15
14
|
import { filesize as prettyBytes } from 'filesize';
|
|
16
15
|
|
|
17
16
|
const debug = createDebugMessages('@testomatio/reporter:client');
|
|
@@ -182,44 +181,15 @@ class Client {
|
|
|
182
181
|
suite_id,
|
|
183
182
|
test_id,
|
|
184
183
|
timestamp,
|
|
184
|
+
links,
|
|
185
185
|
manuallyAttachedArtifacts,
|
|
186
186
|
overwrite,
|
|
187
|
-
tags,
|
|
188
187
|
} = testData;
|
|
189
188
|
let { message = '', meta = {} } = testData;
|
|
190
189
|
|
|
191
190
|
// stringify meta values and limit keys and values length to 255
|
|
192
191
|
meta = Object.entries(meta)
|
|
193
192
|
.filter(([, value]) => value !== null && value !== undefined)
|
|
194
|
-
.map(([key, value]) => {
|
|
195
|
-
try {
|
|
196
|
-
if (typeof value === 'object') {
|
|
197
|
-
value = JSON.stringify(value);
|
|
198
|
-
} else if (typeof value !== 'string') {
|
|
199
|
-
try {
|
|
200
|
-
value = value.toString();
|
|
201
|
-
} catch (err) {
|
|
202
|
-
console.warn(APP_PREFIX, `Can't convert meta value to string`, err);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (value?.length > 255) {
|
|
207
|
-
value = value.substring(0, 255);
|
|
208
|
-
debug(APP_PREFIX, `Meta info value "${value}" is too long, trimmed to 255 characters`);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (key?.length > 255) {
|
|
212
|
-
const newKey = key.substring(0, 255);
|
|
213
|
-
debug(APP_PREFIX, `Meta info key "${key}" is too long, trimmed to 255 characters`);
|
|
214
|
-
return [newKey, value];
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
return [key, value];
|
|
218
|
-
} catch (err) {
|
|
219
|
-
debug(APP_PREFIX, `Error while processing meta info key ${key}`, err);
|
|
220
|
-
return [null, null];
|
|
221
|
-
}
|
|
222
|
-
})
|
|
223
193
|
.reduce((acc, [key, value]) => {
|
|
224
194
|
if (key) acc[key] = value;
|
|
225
195
|
return acc;
|
|
@@ -227,7 +197,6 @@ class Client {
|
|
|
227
197
|
|
|
228
198
|
// Get links from storage using the test context
|
|
229
199
|
const testContext = suite_title ? `${suite_title} ${title}` : title;
|
|
230
|
-
const links = linkStorage.get(testContext) || [];
|
|
231
200
|
|
|
232
201
|
let errorFormatted = '';
|
|
233
202
|
if (error) {
|
|
@@ -285,7 +254,6 @@ class Client {
|
|
|
285
254
|
meta,
|
|
286
255
|
links,
|
|
287
256
|
overwrite,
|
|
288
|
-
tags,
|
|
289
257
|
...(rootSuiteId && { root_suite_id: rootSuiteId }),
|
|
290
258
|
};
|
|
291
259
|
|
|
@@ -314,9 +282,10 @@ class Client {
|
|
|
314
282
|
* Updates the status of the current test run and finishes the run.
|
|
315
283
|
* @param {'passed' | 'failed' | 'skipped' | 'finished'} status - The status of the current test run.
|
|
316
284
|
* Must be one of "passed", "failed", or "finished"
|
|
285
|
+
* @param {boolean} [isParallel] - Whether the current test run was executed in parallel with other tests.
|
|
317
286
|
* @returns {Promise<any>} - A Promise that resolves when finishes the run.
|
|
318
287
|
*/
|
|
319
|
-
async updateRunStatus(status) {
|
|
288
|
+
async updateRunStatus(status, isParallel = false) {
|
|
320
289
|
this.pipes ||= await pipesFactory(this.paramsForPipesFactory || {}, this.pipeStore);
|
|
321
290
|
this.runId ||= readLatestRunId();
|
|
322
291
|
|
|
@@ -324,7 +293,7 @@ class Client {
|
|
|
324
293
|
// all pipes disabled, skipping
|
|
325
294
|
if (!this.pipes?.filter(p => p.isEnabled).length) return Promise.resolve();
|
|
326
295
|
|
|
327
|
-
const runParams = { status };
|
|
296
|
+
const runParams = { status, parallel: isParallel };
|
|
328
297
|
|
|
329
298
|
this.queue = this.queue
|
|
330
299
|
.then(() => Promise.all(this.pipes.map(p => p.finishRun(runParams))))
|
package/src/data-storage.js
CHANGED
|
@@ -116,7 +116,7 @@ class DataStorage {
|
|
|
116
116
|
try {
|
|
117
117
|
if (global?.testomatioDataStore[dataType]) {
|
|
118
118
|
const testData = global.testomatioDataStore[dataType][context];
|
|
119
|
-
if (testData) debug(
|
|
119
|
+
if (testData) debug('<=', dataType, 'global', context, testData);
|
|
120
120
|
return testData || [];
|
|
121
121
|
}
|
|
122
122
|
// debug(`No ${this.dataType} data for context ${context} in <global> storage`);
|
|
@@ -137,7 +137,7 @@ class DataStorage {
|
|
|
137
137
|
const filepath = join(dataDirPath, `${dataType}_${context}`);
|
|
138
138
|
if (fs.existsSync(filepath)) {
|
|
139
139
|
const testDataAsText = fs.readFileSync(filepath, 'utf-8');
|
|
140
|
-
if (testDataAsText) debug(
|
|
140
|
+
if (testDataAsText) debug('<=', dataType, 'file', context, testDataAsText);
|
|
141
141
|
const testDataArr = testDataAsText?.split(os.EOL) || [];
|
|
142
142
|
return testDataArr;
|
|
143
143
|
}
|
|
@@ -156,7 +156,7 @@ class DataStorage {
|
|
|
156
156
|
* @param {*} context
|
|
157
157
|
*/
|
|
158
158
|
#putDataToGlobalVar(dataType, data, context) {
|
|
159
|
-
debug('
|
|
159
|
+
debug('=>', dataType, 'global', context, data);
|
|
160
160
|
if (!global.testomatioDataStore) global.testomatioDataStore = {};
|
|
161
161
|
if (!global.testomatioDataStore?.[dataType]) global.testomatioDataStore[dataType] = {};
|
|
162
162
|
|
|
@@ -177,7 +177,7 @@ class DataStorage {
|
|
|
177
177
|
const filename = `${dataType}_${context}`;
|
|
178
178
|
const filepath = join(dataDirPath, filename);
|
|
179
179
|
if (!fs.existsSync(dataDirPath)) fileSystem.createDir(dataDirPath);
|
|
180
|
-
debug(
|
|
180
|
+
debug('=>', dataType, 'file', context, data);
|
|
181
181
|
|
|
182
182
|
// append new line if file already exists (in this case its definitely includes some data)
|
|
183
183
|
if (fs.existsSync(filepath)) {
|
|
@@ -192,8 +192,7 @@ function stringToMD5Hash(str) {
|
|
|
192
192
|
const md5 = crypto.createHash('md5');
|
|
193
193
|
md5.update(str);
|
|
194
194
|
const hash = md5.digest('hex');
|
|
195
|
-
|
|
196
|
-
return hash;
|
|
195
|
+
return `${process.env.runId || 'run'}_${hash}`;
|
|
197
196
|
}
|
|
198
197
|
|
|
199
198
|
export const dataStorage = DataStorage.getInstance();
|
package/src/pipe/testomatio.js
CHANGED
|
@@ -43,6 +43,7 @@ class TestomatioPipe {
|
|
|
43
43
|
const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY;
|
|
44
44
|
const proxy = proxyUrl ? new URL(proxyUrl) : null;
|
|
45
45
|
|
|
46
|
+
this.parallel = params.parallel;
|
|
46
47
|
this.store = store || {};
|
|
47
48
|
this.title = params.title || process.env.TESTOMATIO_TITLE;
|
|
48
49
|
this.sharedRun = !!process.env.TESTOMATIO_SHARED_RUN;
|
|
@@ -166,6 +167,7 @@ class TestomatioPipe {
|
|
|
166
167
|
const runParams = Object.fromEntries(
|
|
167
168
|
Object.entries({
|
|
168
169
|
ci_build_url: buildUrl,
|
|
170
|
+
parallel: this.parallel,
|
|
169
171
|
api_key: this.apiKey.trim(),
|
|
170
172
|
group_title: this.groupTitle,
|
|
171
173
|
access_event: accessEvent,
|
|
@@ -417,13 +419,14 @@ class TestomatioPipe {
|
|
|
417
419
|
console.warn(`${APP_PREFIX} ${errorMessage}`);
|
|
418
420
|
}
|
|
419
421
|
|
|
420
|
-
const { status } = params;
|
|
422
|
+
const { status, parallel } = params;
|
|
421
423
|
|
|
422
424
|
let status_event;
|
|
423
425
|
|
|
424
426
|
if (status === STATUS.FINISHED) status_event = 'finish';
|
|
425
427
|
if (status === STATUS.PASSED) status_event = 'pass';
|
|
426
428
|
if (status === STATUS.FAILED) status_event = 'fail';
|
|
429
|
+
if (parallel) status_event += '_parallel';
|
|
427
430
|
|
|
428
431
|
try {
|
|
429
432
|
if (this.runId && !this.proceed) {
|
package/src/replay.js
CHANGED
|
@@ -246,7 +246,7 @@ export class Replay {
|
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
-
await client.updateRunStatus(finishParams.status || STATUS.FINISHED);
|
|
249
|
+
await client.updateRunStatus(finishParams.status || STATUS.FINISHED, finishParams.parallel || false);
|
|
250
250
|
|
|
251
251
|
const result = {
|
|
252
252
|
success: true,
|
|
@@ -53,24 +53,21 @@ function setKeyValue(keyValue, value = null) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
* Add label
|
|
56
|
+
* Add a single label to the test report
|
|
57
57
|
* @param {string} key - label key (e.g. 'severity', 'feature', or just 'smoke' for labels without values)
|
|
58
|
-
* @param {string|
|
|
58
|
+
* @param {string|null} [value=null] - optional label value (e.g. 'high', 'login')
|
|
59
59
|
* @returns {void}
|
|
60
60
|
*/
|
|
61
61
|
function setLabel(key, value = null) {
|
|
62
62
|
if (Array.isArray(value)) {
|
|
63
|
-
value.forEach(
|
|
64
|
-
return;
|
|
63
|
+
return value.forEach(label => setLabel(key, label));
|
|
65
64
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
? { label: `${key}:${value}` }
|
|
65
|
+
const labelObject = value !== null && value !== undefined && value !== ''
|
|
66
|
+
? { label: `${key}:${value}` }
|
|
69
67
|
: { label: key };
|
|
70
68
|
services.links.put([labelObject]);
|
|
71
69
|
}
|
|
72
70
|
|
|
73
|
-
|
|
74
71
|
/**
|
|
75
72
|
* Add link(s) to the test report
|
|
76
73
|
* @param {...string} testIds - test IDs to link
|
|
@@ -81,6 +78,16 @@ function linkTest(...testIds) {
|
|
|
81
78
|
services.links.put(links);
|
|
82
79
|
}
|
|
83
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Add JIRA issue link(s) to the test report
|
|
83
|
+
* @param {...string} jiraIds - JIRA issue IDs to link
|
|
84
|
+
* @returns {void}
|
|
85
|
+
*/
|
|
86
|
+
function linkJira(...jiraIds) {
|
|
87
|
+
const links = jiraIds.map(jiraId => ({ jira: jiraId }));
|
|
88
|
+
services.links.put(links);
|
|
89
|
+
}
|
|
90
|
+
|
|
84
91
|
export default {
|
|
85
92
|
artifact: saveArtifact,
|
|
86
93
|
log: logMessage,
|
|
@@ -88,4 +95,5 @@ export default {
|
|
|
88
95
|
keyValue: setKeyValue,
|
|
89
96
|
label: setLabel,
|
|
90
97
|
linkTest,
|
|
98
|
+
linkJira,
|
|
91
99
|
};
|
package/src/reporter.js
CHANGED
|
@@ -10,6 +10,7 @@ export const meta = reporterFunctions.keyValue;
|
|
|
10
10
|
export const step = reporterFunctions.step;
|
|
11
11
|
export const label = reporterFunctions.label;
|
|
12
12
|
export const linkTest = reporterFunctions.linkTest;
|
|
13
|
+
export const linkJira = reporterFunctions.linkJira;
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* @typedef {typeof import('./reporter-functions.js').default.artifact} ArtifactFunction
|
|
@@ -32,6 +33,7 @@ export default {
|
|
|
32
33
|
step: reporterFunctions.step,
|
|
33
34
|
label: reporterFunctions.label,
|
|
34
35
|
linkTest: reporterFunctions.linkTest,
|
|
36
|
+
linkJira: reporterFunctions.linkJira,
|
|
35
37
|
|
|
36
38
|
// TestomatClient,
|
|
37
39
|
// TRConstants,
|
package/src/services/labels.js
CHANGED
|
@@ -1,59 +1 @@
|
|
|
1
|
-
import createDebugMessages from 'debug';
|
|
2
|
-
import { dataStorage } from '../data-storage.js';
|
|
3
1
|
|
|
4
|
-
const debug = createDebugMessages('@testomatio/reporter:services-labels');
|
|
5
|
-
class LabelStorage {
|
|
6
|
-
static #instance;
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
*
|
|
10
|
-
* @returns {LabelStorage}
|
|
11
|
-
*/
|
|
12
|
-
static getInstance() {
|
|
13
|
-
if (!this.#instance) {
|
|
14
|
-
this.#instance = new LabelStorage();
|
|
15
|
-
}
|
|
16
|
-
return this.#instance;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Stores labels array and passes it to reporter
|
|
21
|
-
* @param {string[]} labels - array of label strings
|
|
22
|
-
* @param {*} context - full test title
|
|
23
|
-
*/
|
|
24
|
-
put(labels, context = null) {
|
|
25
|
-
if (!labels || !Array.isArray(labels)) return;
|
|
26
|
-
dataStorage.putData('links', labels, context);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Returns labels array for the test
|
|
31
|
-
* @param {*} context testId or test context from test runner
|
|
32
|
-
* @returns {string[]} labels array, e.g. ['smoke', 'severity:high', 'feature:user_account']
|
|
33
|
-
*/
|
|
34
|
-
get(context = null) {
|
|
35
|
-
const labelsList = dataStorage.getData('links', context);
|
|
36
|
-
if (!labelsList || !labelsList?.length) return [];
|
|
37
|
-
|
|
38
|
-
const allLabels = [];
|
|
39
|
-
for (const labels of labelsList) {
|
|
40
|
-
if (Array.isArray(labels)) {
|
|
41
|
-
allLabels.push(...labels);
|
|
42
|
-
} else if (typeof labels === 'string') {
|
|
43
|
-
try {
|
|
44
|
-
const parsedLabels = JSON.parse(labels);
|
|
45
|
-
if (Array.isArray(parsedLabels)) {
|
|
46
|
-
allLabels.push(...parsedLabels);
|
|
47
|
-
}
|
|
48
|
-
} catch (e) {
|
|
49
|
-
debug(`Error parsing labels for test ${context}`, labels);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Remove duplicates
|
|
55
|
-
return [...new Set(allLabels)];
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export const labelStorage = LabelStorage.getInstance();
|