@testomatio/reporter 2.3.0-beta.1-links → 2.3.0-beta.3-playwright-tags

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.
@@ -26,8 +26,6 @@ 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;
31
29
  const DATA_REGEXP = /[|\s]+?(\{".*\}|\[.*\])/;
32
30
  if (MAJOR_VERSION < 3) {
33
31
  console.log('🔴 This reporter works with CodeceptJS 3+, please update your tests');
@@ -105,7 +105,6 @@ 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);
109
108
  this.client.addTestRun(status, {
110
109
  // error: testCaseAttempt.worstTestStepResult.message,
111
110
  message,
@@ -115,7 +114,6 @@ class CucumberReporter extends cucumber_1.Formatter {
115
114
  .trim(),
116
115
  example: { ...example },
117
116
  logs,
118
- links,
119
117
  manuallyAttachedArtifacts: artifacts,
120
118
  meta: keyValues,
121
119
  title: scenario,
@@ -57,7 +57,6 @@ 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);
61
60
  const deducedStatus = status === 'pending' ? 'skipped' : status;
62
61
  // In jest if test is not matched with test name pattern it is considered as skipped.
63
62
  // So adding a check if it is skipped for real or because of test pattern
@@ -70,7 +69,6 @@ class JestReporter {
70
69
  title,
71
70
  time: duration,
72
71
  logs,
73
- links,
74
72
  manuallyAttachedArtifacts: artifacts,
75
73
  meta: keyValues,
76
74
  });
@@ -48,6 +48,8 @@ class PlaywrightReporter {
48
48
  steps.push(appendedStep);
49
49
  }
50
50
  }
51
+ // Extract and normalize tags
52
+ const tags = extractTags(test);
51
53
  const fullTestTitle = getTestContextName(test);
52
54
  let logs = '';
53
55
  if (result.stderr.length || result.stdout.length) {
@@ -55,7 +57,6 @@ class PlaywrightReporter {
55
57
  }
56
58
  const manuallyAttachedArtifacts = index_js_1.services.artifacts.get(fullTestTitle);
57
59
  const testMeta = index_js_1.services.keyValues.get(fullTestTitle);
58
- const links = index_js_1.services.links.get(fullTestTitle);
59
60
  const rid = test.id || test.testId || (0, uuid_1.v4)();
60
61
  /**
61
62
  * @type {{
@@ -86,13 +87,13 @@ class PlaywrightReporter {
86
87
  const reportTestPromise = this.client.addTestRun(checkStatus(status), {
87
88
  rid: `${rid}-${project.name}`,
88
89
  error,
89
- test_id: (0, utils_js_1.getTestomatIdFromTestTitle)(`${title} ${test.tags?.join(' ')}`),
90
+ test_id: (0, utils_js_1.getTestomatIdFromTestTitle)(`${title} ${tags.join(' ')}`),
90
91
  suite_title,
91
92
  title,
93
+ tags,
92
94
  steps: steps.length ? steps : undefined,
93
95
  time: duration,
94
96
  logs,
95
- links,
96
97
  manuallyAttachedArtifacts,
97
98
  meta: {
98
99
  browser: project.browser,
@@ -218,6 +219,40 @@ function generateTmpFilepath(filename = '') {
218
219
  const tmpdir = os_1.default.tmpdir();
219
220
  return path_1.default.join(tmpdir, filename);
220
221
  }
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
+ // Extract tags from suite/describe level (inherited tags)
244
+ let parent = test.parent;
245
+ while (parent) {
246
+ if (parent.tags && Array.isArray(parent.tags)) {
247
+ parent.tags.forEach(tag => {
248
+ const normalizedTag = typeof tag === 'string' ? tag.replace('@', '').toLowerCase() : String(tag).toLowerCase();
249
+ tagsSet.add(normalizedTag);
250
+ });
251
+ }
252
+ parent = parent.parent;
253
+ }
254
+ return Array.from(tagsSet);
255
+ }
221
256
  /**
222
257
  * Returns filename + test title
223
258
  * @param {*} test - testInfo object from Playwright
@@ -77,10 +77,14 @@ class WebdriverReporter extends reporter_1.default {
77
77
  onTestEnd(test) {
78
78
  test.suite = test.parent;
79
79
  const logs = getTestLogs(test.fullTitle);
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);
80
+ // TODO: FIX: artifacts for some reason leads to empty report on Testomat.io
81
+ // ^ not reproduced anymore (Jul 2025)
82
+ // but still be under investigation
83
+ const artifacts = index_js_1.services.artifacts.get(test.fullTitle);
84
+ const keyValues = index_js_1.services.keyValues.get(test.fullTitle);
83
85
  test.logs = logs;
86
+ test.artifacts = artifacts;
87
+ test.meta = keyValues;
84
88
  this._addTestPromises.push(this.addTest(test));
85
89
  }
86
90
  // wdio-cucumber does not trigger onTestEnd hook, thus, using this one
@@ -92,7 +96,7 @@ class WebdriverReporter extends reporter_1.default {
92
96
  async addTest(test) {
93
97
  if (!this.client)
94
98
  return;
95
- const { title, _duration: duration, state, error, output, links, artifacts, meta, logs } = test;
99
+ const { title, _duration: duration, state, error, output } = test;
96
100
  const testId = (0, utils_js_1.getTestomatIdFromTestTitle)(title);
97
101
  const screenshotEndpoint = '/session/:sessionId/screenshot';
98
102
  const screenshotsBuffers = output
@@ -100,11 +104,10 @@ class WebdriverReporter extends reporter_1.default {
100
104
  .map(el => Buffer.from(el.result.value, 'base64'));
101
105
  await this.client.addTestRun(state, {
102
106
  rid: test.uid || '',
103
- manuallyAttachedArtifacts: artifacts,
107
+ manuallyAttachedArtifacts: test.artifacts,
104
108
  error,
105
- logs,
106
- meta,
107
- links,
109
+ logs: test.logs,
110
+ meta: test.meta,
108
111
  title,
109
112
  test_id: testId,
110
113
  time: duration,
package/lib/client.js CHANGED
@@ -50,6 +50,7 @@ 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");
53
54
  const filesize_1 = require("filesize");
54
55
  const debug = (0, debug_1.default)('@testomatio/reporter:client');
55
56
  // removed __dirname usage, because:
@@ -181,11 +182,40 @@ class Client {
181
182
  /**
182
183
  * @type {TestData}
183
184
  */
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;
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, } = testData;
185
186
  let { message = '', meta = {} } = testData;
186
187
  // stringify meta values and limit keys and values length to 255
187
188
  meta = Object.entries(meta)
188
189
  .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
+ })
189
219
  .reduce((acc, [key, value]) => {
190
220
  if (key)
191
221
  acc[key] = value;
@@ -193,6 +223,7 @@ class Client {
193
223
  }, {});
194
224
  // Get links from storage using the test context
195
225
  const testContext = suite_title ? `${suite_title} ${title}` : title;
226
+ const links = links_js_1.linkStorage.get(testContext) || [];
196
227
  let errorFormatted = '';
197
228
  if (error) {
198
229
  errorFormatted += this.formatError(error) || '';
@@ -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('<=', dataType, 'global', context, testData);
146
+ debug(`"${dataType}" data for constext "${context}":`, testData.join(', '));
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('<=', dataType, 'file', context, testDataAsText);
168
+ debug(`"${dataType}" data for context "${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('=>', dataType, 'global', context, data);
187
+ debug('Saving data to global variable for ', 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('=>', dataType, 'file', context, data);
211
+ debug(`Saving data to file for context "${context}" to ${filepath}. Data: ${JSON.stringify(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 `${process.env.runId || 'run'}_${hash}`;
225
+ return hash;
226
226
  }
227
227
  exports.dataStorage = DataStorage.getInstance();
228
228
  // TODO: consider using fs promises instead of writeSync/appendFileSync to
@@ -5,7 +5,6 @@ declare namespace _default {
5
5
  export { setKeyValue as keyValue };
6
6
  export { setLabel as label };
7
7
  export { linkTest };
8
- export { linkJira };
9
8
  }
10
9
  export default _default;
11
10
  /**
@@ -41,21 +40,15 @@ declare function setKeyValue(keyValue: {
41
40
  [key: string]: string;
42
41
  } | string, value?: string | null): void;
43
42
  /**
44
- * Add a single label to the test report
43
+ * Add label(s) to the test report
45
44
  * @param {string} key - label key (e.g. 'severity', 'feature', or just 'smoke' for labels without values)
46
- * @param {string|null} [value=null] - optional label value (e.g. 'high', 'login')
45
+ * @param {string|string[]|null} [value=null] - optional label value(s) (e.g. 'high', 'login') or array of values
47
46
  * @returns {void}
48
47
  */
49
- declare function setLabel(key: string, value?: string | null): void;
48
+ declare function setLabel(key: string, value?: string | string[] | null): void;
50
49
  /**
51
50
  * Add link(s) to the test report
52
51
  * @param {...string} testIds - test IDs to link
53
52
  * @returns {void}
54
53
  */
55
54
  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,14 +50,15 @@ function setKeyValue(keyValue, value = null) {
50
50
  index_js_1.services.keyValues.put(keyValue);
51
51
  }
52
52
  /**
53
- * Add a single label to the test report
53
+ * Add label(s) 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|null} [value=null] - optional label value (e.g. 'high', 'login')
55
+ * @param {string|string[]|null} [value=null] - optional label value(s) (e.g. 'high', 'login') or array of values
56
56
  * @returns {void}
57
57
  */
58
58
  function setLabel(key, value = null) {
59
59
  if (Array.isArray(value)) {
60
- return value.forEach(label => setLabel(key, label));
60
+ value.forEach(val => setLabel(key, val));
61
+ return;
61
62
  }
62
63
  const labelObject = value !== null && value !== undefined && value !== ''
63
64
  ? { label: `${key}:${value}` }
@@ -73,15 +74,6 @@ function linkTest(...testIds) {
73
74
  const links = testIds.map(testId => ({ test: testId }));
74
75
  index_js_1.services.links.put(links);
75
76
  }
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
- }
85
77
  module.exports = {
86
78
  artifact: saveArtifact,
87
79
  log: logMessage,
@@ -89,5 +81,4 @@ module.exports = {
89
81
  keyValue: setKeyValue,
90
82
  label: setLabel,
91
83
  linkTest,
92
- linkJira,
93
84
  };
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
- "__#13@#originalUserLogger": {
8
+ "__#14@#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
- "__#13@#userLoggerWithOverridenMethods": any;
53
+ "__#14@#userLoggerWithOverridenMethods": any;
54
54
  logLevel: string;
55
55
  step(strings: any, ...values: any[]): void;
56
56
  getLogs(context: string): string[];
57
- "__#13@#stringifyLogs"(...args: any[]): string;
57
+ "__#14@#stringifyLogs"(...args: any[]): string;
58
58
  _templateLiteralLog(strings: any, ...args: any[]): void;
59
- "__#13@#logWrapper"(argsArray: any, level: any): void;
59
+ "__#14@#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,11 @@ 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 | null) => void;
79
+ export const label: (key: string, value?: string | string[] | null) => void;
80
80
  export const linkTest: (...testIds: string[]) => void;
81
81
  declare namespace _default {
82
82
  let testomatioLogger: {
83
- "__#13@#originalUserLogger": {
83
+ "__#14@#originalUserLogger": {
84
84
  assert(condition?: boolean, ...data: any[]): void;
85
85
  assert(value: any, message?: string, ...optionalParams: any[]): void;
86
86
  clear(): void;
@@ -125,13 +125,13 @@ declare namespace _default {
125
125
  profile(label?: string): void;
126
126
  profileEnd(label?: string): void;
127
127
  };
128
- "__#13@#userLoggerWithOverridenMethods": any;
128
+ "__#14@#userLoggerWithOverridenMethods": any;
129
129
  logLevel: string;
130
130
  step(strings: any, ...values: any[]): void;
131
131
  getLogs(context: string): string[];
132
- "__#13@#stringifyLogs"(...args: any[]): string;
132
+ "__#14@#stringifyLogs"(...args: any[]): string;
133
133
  _templateLiteralLog(strings: any, ...args: any[]): void;
134
- "__#13@#logWrapper"(argsArray: any, level: any): void;
134
+ "__#14@#logWrapper"(argsArray: any, level: any): void;
135
135
  assert(...args: any[]): void;
136
136
  debug(...args: any[]): void;
137
137
  error(...args: any[]): void;
@@ -154,7 +154,7 @@ declare namespace _default {
154
154
  }, context?: any) => void;
155
155
  let log: (...args: any[]) => void;
156
156
  let logger: {
157
- "__#13@#originalUserLogger": {
157
+ "__#14@#originalUserLogger": {
158
158
  assert(condition?: boolean, ...data: any[]): void;
159
159
  assert(value: any, message?: string, ...optionalParams: any[]): void;
160
160
  clear(): void;
@@ -199,13 +199,13 @@ declare namespace _default {
199
199
  profile(label?: string): void;
200
200
  profileEnd(label?: string): void;
201
201
  };
202
- "__#13@#userLoggerWithOverridenMethods": any;
202
+ "__#14@#userLoggerWithOverridenMethods": any;
203
203
  logLevel: string;
204
204
  step(strings: any, ...values: any[]): void;
205
205
  getLogs(context: string): string[];
206
- "__#13@#stringifyLogs"(...args: any[]): string;
206
+ "__#14@#stringifyLogs"(...args: any[]): string;
207
207
  _templateLiteralLog(strings: any, ...args: any[]): void;
208
- "__#13@#logWrapper"(argsArray: any, level: any): void;
208
+ "__#14@#logWrapper"(argsArray: any, level: any): void;
209
209
  assert(...args: any[]): void;
210
210
  debug(...args: any[]): void;
211
211
  error(...args: any[]): void;
@@ -225,7 +225,7 @@ declare namespace _default {
225
225
  [key: string]: string;
226
226
  } | string, value?: string | null) => void;
227
227
  let step: (message: string) => void;
228
- let label: (key: string, value?: string | null) => void;
228
+ let label: (key: string, value?: string | string[] | null) => void;
229
229
  let linkTest: (...testIds: string[]) => void;
230
230
  }
231
231
  export default _default;
@@ -3,7 +3,7 @@ export const artifactStorage: ArtifactStorage;
3
3
  * Artifact storage is supposed to store file paths
4
4
  */
5
5
  declare class ArtifactStorage {
6
- static "__#14@#instance": any;
6
+ static "__#15@#instance": any;
7
7
  /**
8
8
  * Singleton
9
9
  * @returns {ArtifactStorage}
@@ -1,6 +1,6 @@
1
1
  export const keyValueStorage: KeyValueStorage;
2
2
  declare class KeyValueStorage {
3
- static "__#15@#instance": any;
3
+ static "__#16@#instance": any;
4
4
  /**
5
5
  *
6
6
  * @returns {KeyValueStorage}
@@ -0,0 +1,22 @@
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 {};
@@ -0,0 +1,62 @@
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();
@@ -1,6 +1,6 @@
1
1
  export const linkStorage: LinkStorage;
2
2
  declare class LinkStorage {
3
- static "__#16@#instance": any;
3
+ static "__#13@#instance": any;
4
4
  /**
5
5
  *
6
6
  * @returns {LinkStorage}
@@ -5,7 +5,7 @@ export const logger: Logger;
5
5
  * Supports different syntaxes to satisfy any user preferences.
6
6
  */
7
7
  declare class Logger {
8
- static "__#13@#instance": any;
8
+ static "__#14@#instance": any;
9
9
  /**
10
10
  *
11
11
  * @returns {Logger}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.3.0-beta.1-links",
3
+ "version": "2.3.0-beta.3-playwright-tags",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
@@ -25,9 +25,6 @@ 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
-
31
28
  const DATA_REGEXP = /[|\s]+?(\{".*\}|\[.*\])/;
32
29
 
33
30
  if (MAJOR_VERSION < 3) {
@@ -113,7 +113,6 @@ 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);
117
116
 
118
117
  this.client.addTestRun(status, {
119
118
  // error: testCaseAttempt.worstTestStepResult.message,
@@ -124,7 +123,6 @@ class CucumberReporter extends Formatter {
124
123
  .trim(),
125
124
  example: { ...example },
126
125
  logs,
127
- links,
128
126
  manuallyAttachedArtifacts: artifacts,
129
127
  meta: keyValues,
130
128
  title: scenario,
@@ -59,7 +59,6 @@ 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);
63
62
 
64
63
  const deducedStatus = status === 'pending' ? 'skipped' : status;
65
64
  // In jest if test is not matched with test name pattern it is considered as skipped.
@@ -73,7 +72,6 @@ export class JestReporter {
73
72
  title,
74
73
  time: duration,
75
74
  logs,
76
- links,
77
75
  manuallyAttachedArtifacts: artifacts,
78
76
  meta: keyValues,
79
77
  });
@@ -51,6 +51,9 @@ class PlaywrightReporter {
51
51
  }
52
52
  }
53
53
 
54
+ // Extract and normalize tags
55
+ const tags = extractTags(test);
56
+
54
57
  const fullTestTitle = getTestContextName(test);
55
58
  let logs = '';
56
59
  if (result.stderr.length || result.stdout.length) {
@@ -58,7 +61,6 @@ class PlaywrightReporter {
58
61
  }
59
62
  const manuallyAttachedArtifacts = services.artifacts.get(fullTestTitle);
60
63
  const testMeta = services.keyValues.get(fullTestTitle);
61
- const links = services.links.get(fullTestTitle);
62
64
  const rid = test.id || test.testId || uuidv4();
63
65
 
64
66
  /**
@@ -90,13 +92,13 @@ class PlaywrightReporter {
90
92
  const reportTestPromise = this.client.addTestRun(checkStatus(status), {
91
93
  rid: `${rid}-${project.name}`,
92
94
  error,
93
- test_id: getTestomatIdFromTestTitle(`${title} ${test.tags?.join(' ')}`),
95
+ test_id: getTestomatIdFromTestTitle(`${title} ${tags.join(' ')}`),
94
96
  suite_title,
95
97
  title,
98
+ tags,
96
99
  steps: steps.length ? steps : undefined,
97
100
  time: duration,
98
101
  logs,
99
- links,
100
102
  manuallyAttachedArtifacts,
101
103
  meta: {
102
104
  browser: project.browser,
@@ -243,6 +245,45 @@ function generateTmpFilepath(filename = '') {
243
245
  return path.join(tmpdir, filename);
244
246
  }
245
247
 
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
+
272
+ // Extract tags from suite/describe level (inherited tags)
273
+ let parent = test.parent;
274
+ while (parent) {
275
+ if (parent.tags && Array.isArray(parent.tags)) {
276
+ parent.tags.forEach(tag => {
277
+ const normalizedTag = typeof tag === 'string' ? tag.replace('@', '').toLowerCase() : String(tag).toLowerCase();
278
+ tagsSet.add(normalizedTag);
279
+ });
280
+ }
281
+ parent = parent.parent;
282
+ }
283
+
284
+ return Array.from(tagsSet);
285
+ }
286
+
246
287
  /**
247
288
  * Returns filename + test title
248
289
  * @param {*} test - testInfo object from Playwright
@@ -52,10 +52,14 @@ class WebdriverReporter extends WDIOReporter {
52
52
  onTestEnd(test) {
53
53
  test.suite = test.parent;
54
54
  const logs = getTestLogs(test.fullTitle);
55
- test.artifacts = services.artifacts.get(test.fullTitle);
56
- test.meta = services.keyValues.get(test.fullTitle);
57
- test.links = services.links.get(test.fullTitle);
55
+ // TODO: FIX: artifacts for some reason leads to empty report on Testomat.io
56
+ // ^ not reproduced anymore (Jul 2025)
57
+ // but still be under investigation
58
+ const artifacts = services.artifacts.get(test.fullTitle);
59
+ const keyValues = services.keyValues.get(test.fullTitle);
58
60
  test.logs = logs;
61
+ test.artifacts = artifacts;
62
+ test.meta = keyValues;
59
63
 
60
64
  this._addTestPromises.push(this.addTest(test));
61
65
  }
@@ -70,7 +74,7 @@ class WebdriverReporter extends WDIOReporter {
70
74
  async addTest(test) {
71
75
  if (!this.client) return;
72
76
 
73
- const { title, _duration: duration, state, error, output, links, artifacts, meta, logs } = test;
77
+ const { title, _duration: duration, state, error, output } = test;
74
78
 
75
79
  const testId = getTestomatIdFromTestTitle(title);
76
80
 
@@ -81,11 +85,10 @@ class WebdriverReporter extends WDIOReporter {
81
85
 
82
86
  await this.client.addTestRun(state, {
83
87
  rid: test.uid || '',
84
- manuallyAttachedArtifacts: artifacts,
88
+ manuallyAttachedArtifacts: test.artifacts,
85
89
  error,
86
- logs,
87
- meta,
88
- links,
90
+ logs: test.logs,
91
+ meta: test.meta,
89
92
  title,
90
93
  test_id: testId,
91
94
  time: duration,
package/src/client.js CHANGED
@@ -11,6 +11,7 @@ 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';
14
15
  import { filesize as prettyBytes } from 'filesize';
15
16
 
16
17
  const debug = createDebugMessages('@testomatio/reporter:client');
@@ -181,7 +182,6 @@ class Client {
181
182
  suite_id,
182
183
  test_id,
183
184
  timestamp,
184
- links,
185
185
  manuallyAttachedArtifacts,
186
186
  overwrite,
187
187
  } = testData;
@@ -190,6 +190,35 @@ class Client {
190
190
  // stringify meta values and limit keys and values length to 255
191
191
  meta = Object.entries(meta)
192
192
  .filter(([, value]) => value !== null && value !== undefined)
193
+ .map(([key, value]) => {
194
+ try {
195
+ if (typeof value === 'object') {
196
+ value = JSON.stringify(value);
197
+ } else if (typeof value !== 'string') {
198
+ try {
199
+ value = value.toString();
200
+ } catch (err) {
201
+ console.warn(APP_PREFIX, `Can't convert meta value to string`, err);
202
+ }
203
+ }
204
+
205
+ if (value?.length > 255) {
206
+ value = value.substring(0, 255);
207
+ debug(APP_PREFIX, `Meta info value "${value}" is too long, trimmed to 255 characters`);
208
+ }
209
+
210
+ if (key?.length > 255) {
211
+ const newKey = key.substring(0, 255);
212
+ debug(APP_PREFIX, `Meta info key "${key}" is too long, trimmed to 255 characters`);
213
+ return [newKey, value];
214
+ }
215
+
216
+ return [key, value];
217
+ } catch (err) {
218
+ debug(APP_PREFIX, `Error while processing meta info key ${key}`, err);
219
+ return [null, null];
220
+ }
221
+ })
193
222
  .reduce((acc, [key, value]) => {
194
223
  if (key) acc[key] = value;
195
224
  return acc;
@@ -197,6 +226,7 @@ class Client {
197
226
 
198
227
  // Get links from storage using the test context
199
228
  const testContext = suite_title ? `${suite_title} ${title}` : title;
229
+ const links = linkStorage.get(testContext) || [];
200
230
 
201
231
  let errorFormatted = '';
202
232
  if (error) {
@@ -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('<=', dataType, 'global', context, testData);
119
+ if (testData) debug(`"${dataType}" data for constext "${context}":`, testData.join(', '));
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('<=', dataType, 'file', context, testDataAsText);
140
+ if (testDataAsText) debug(`"${dataType}" data for context "${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('=>', dataType, 'global', context, data);
159
+ debug('Saving data to global variable for ', 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('=>', dataType, 'file', context, data);
180
+ debug(`Saving data to file for context "${context}" to ${filepath}. Data: ${JSON.stringify(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,7 +192,8 @@ function stringToMD5Hash(str) {
192
192
  const md5 = crypto.createHash('md5');
193
193
  md5.update(str);
194
194
  const hash = md5.digest('hex');
195
- return `${process.env.runId || 'run'}_${hash}`;
195
+
196
+ return hash;
196
197
  }
197
198
 
198
199
  export const dataStorage = DataStorage.getInstance();
@@ -53,21 +53,24 @@ function setKeyValue(keyValue, value = null) {
53
53
  }
54
54
 
55
55
  /**
56
- * Add a single label to the test report
56
+ * Add label(s) 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|null} [value=null] - optional label value (e.g. 'high', 'login')
58
+ * @param {string|string[]|null} [value=null] - optional label value(s) (e.g. 'high', 'login') or array of values
59
59
  * @returns {void}
60
60
  */
61
61
  function setLabel(key, value = null) {
62
62
  if (Array.isArray(value)) {
63
- return value.forEach(label => setLabel(key, label));
63
+ value.forEach(val => setLabel(key, val));
64
+ return;
64
65
  }
65
- const labelObject = value !== null && value !== undefined && value !== ''
66
- ? { label: `${key}:${value}` }
66
+
67
+ const labelObject = value !== null && value !== undefined && value !== ''
68
+ ? { label: `${key}:${value}` }
67
69
  : { label: key };
68
70
  services.links.put([labelObject]);
69
71
  }
70
72
 
73
+
71
74
  /**
72
75
  * Add link(s) to the test report
73
76
  * @param {...string} testIds - test IDs to link
@@ -78,16 +81,6 @@ function linkTest(...testIds) {
78
81
  services.links.put(links);
79
82
  }
80
83
 
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
-
91
84
  export default {
92
85
  artifact: saveArtifact,
93
86
  log: logMessage,
@@ -95,5 +88,4 @@ export default {
95
88
  keyValue: setKeyValue,
96
89
  label: setLabel,
97
90
  linkTest,
98
- linkJira,
99
91
  };
@@ -1 +1,59 @@
1
+ import createDebugMessages from 'debug';
2
+ import { dataStorage } from '../data-storage.js';
1
3
 
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();