qase-javascript-commons 2.4.18 → 2.5.1

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.
@@ -8,5 +8,7 @@ var ModeEnum;
8
8
  (function (ModeEnum) {
9
9
  ModeEnum["report"] = "report";
10
10
  ModeEnum["testops"] = "testops";
11
+ /** Multi-project mode: send results to multiple Qase projects. */
12
+ ModeEnum["testops_multi"] = "testops_multi";
11
13
  ModeEnum["off"] = "off";
12
14
  })(ModeEnum || (exports.ModeEnum = ModeEnum = {}));
@@ -1,6 +1,6 @@
1
1
  import { ModeEnum } from './mode-enum';
2
2
  import { DriverEnum, FsWriterOptionsType } from '../writer';
3
- import { TestOpsOptionsType } from '../models/config/TestOpsOptionsType';
3
+ import { TestOpsOptionsType, TestOpsMultiConfigType } from '../models/config/TestOpsOptionsType';
4
4
  type RecursivePartial<T> = {
5
5
  [K in keyof T]?: RecursivePartial<T[K]> | undefined;
6
6
  };
@@ -28,6 +28,8 @@ export type OptionsType = {
28
28
  statusMapping?: Record<string, string> | undefined;
29
29
  logging?: RecursivePartial<LoggingOptionsType> | undefined;
30
30
  testops?: RecursivePartial<TestOpsOptionsType> | undefined;
31
+ /** Multi-project configuration (used when mode is testops_multi). */
32
+ testops_multi?: TestOpsMultiConfigType | undefined;
31
33
  report?: RecursivePartial<AdditionalReportOptionsType> | undefined;
32
34
  };
33
35
  export type FrameworkOptionsType<F extends string, O> = {
package/dist/qase.js CHANGED
@@ -97,7 +97,10 @@ class QaseReporter {
97
97
  this.logger.logDebug(`Config: ${JSON.stringify(this.sanitizeOptions(composedOptions))}`);
98
98
  const effectiveMode = composedOptions.mode || options_1.ModeEnum.off;
99
99
  const effectiveFallback = composedOptions.fallback || options_1.ModeEnum.off;
100
- const needsHostData = effectiveMode === options_1.ModeEnum.testops || effectiveFallback === options_1.ModeEnum.testops;
100
+ const needsHostData = effectiveMode === options_1.ModeEnum.testops ||
101
+ effectiveMode === options_1.ModeEnum.testops_multi ||
102
+ effectiveFallback === options_1.ModeEnum.testops ||
103
+ effectiveFallback === options_1.ModeEnum.testops_multi;
101
104
  this.hostData = needsHostData
102
105
  ? (0, hostData_1.getHostInfo)(options.frameworkPackage, options.reporterName)
103
106
  : (0, hostData_1.getMinimalHostData)();
@@ -453,6 +456,21 @@ class QaseReporter {
453
456
  const apiClient = new clientV2_1.ClientV2(this.logger, options.testops, options.environment, options.rootSuite, this.hostData, options.reporterName, options.frameworkPackage);
454
457
  return new reporters_1.TestOpsReporter(this.logger, apiClient, this.withState, options.testops.project, options.testops.api.host, options.testops.batch?.size, options.testops.run?.id, options.testops.showPublicReportLink);
455
458
  }
459
+ case options_1.ModeEnum.testops_multi: {
460
+ if (!options.testops?.api?.token) {
461
+ throw new Error(`Either "testops.api.token" parameter or "${env_1.EnvApiEnum.token}" environment variable is required in "testops_multi" mode`);
462
+ }
463
+ const multi = options.testops_multi;
464
+ if (!multi?.projects?.length) {
465
+ throw new Error('"testops_multi.projects" must contain at least one project with a "code" field');
466
+ }
467
+ for (const p of multi.projects) {
468
+ if (!p?.code) {
469
+ throw new Error('Each project in "testops_multi.projects" must have a "code" field');
470
+ }
471
+ }
472
+ return new reporters_1.TestOpsMultiReporter(this.logger, options.testops, multi, this.withState, this.hostData, options.reporterName, options.frameworkPackage, options.environment, options.testops.api?.host, options.testops.batch?.size, options.testops.showPublicReportLink);
473
+ }
456
474
  case options_1.ModeEnum.report: {
457
475
  const localOptions = options.report?.connections?.[writer_1.DriverEnum.local];
458
476
  const writer = new writer_1.FsWriter(localOptions);
@@ -64,5 +64,5 @@ export declare abstract class AbstractReporter implements InternalReporterInterf
64
64
  * @param {TestResultType[]} results
65
65
  */
66
66
  setTestResults(results: TestResultType[]): void;
67
- private removeAnsiEscapeCodes;
67
+ protected removeAnsiEscapeCodes(str: string): string;
68
68
  }
@@ -1,3 +1,4 @@
1
1
  export { AbstractReporter, type InternalReporterInterface, } from './abstract-reporter';
2
2
  export { ReportReporter } from './report-reporter';
3
3
  export { TestOpsReporter } from './testops-reporter';
4
+ export { TestOpsMultiReporter } from './testops-multi-reporter';
@@ -1,9 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.TestOpsReporter = exports.ReportReporter = exports.AbstractReporter = void 0;
3
+ exports.TestOpsMultiReporter = exports.TestOpsReporter = exports.ReportReporter = exports.AbstractReporter = void 0;
4
4
  var abstract_reporter_1 = require("./abstract-reporter");
5
5
  Object.defineProperty(exports, "AbstractReporter", { enumerable: true, get: function () { return abstract_reporter_1.AbstractReporter; } });
6
6
  var report_reporter_1 = require("./report-reporter");
7
7
  Object.defineProperty(exports, "ReportReporter", { enumerable: true, get: function () { return report_reporter_1.ReportReporter; } });
8
8
  var testops_reporter_1 = require("./testops-reporter");
9
9
  Object.defineProperty(exports, "TestOpsReporter", { enumerable: true, get: function () { return testops_reporter_1.TestOpsReporter; } });
10
+ var testops_multi_reporter_1 = require("./testops-multi-reporter");
11
+ Object.defineProperty(exports, "TestOpsMultiReporter", { enumerable: true, get: function () { return testops_multi_reporter_1.TestOpsMultiReporter; } });
@@ -0,0 +1,50 @@
1
+ import { AbstractReporter } from './abstract-reporter';
2
+ import { Attachment, TestResultType } from '../models';
3
+ import type { TestOpsOptionsType, TestOpsMultiConfigType } from '../models/config/TestOpsOptionsType';
4
+ import { LoggerInterface } from '../utils/logger';
5
+ import { HostData } from '../models/host-data';
6
+ /**
7
+ * Multi-project TestOps reporter. Sends test results to multiple Qase projects
8
+ * with different test case IDs per project. Each project gets its own run.
9
+ */
10
+ export declare class TestOpsMultiReporter extends AbstractReporter {
11
+ private readonly baseUrl;
12
+ private readonly batchSize;
13
+ /** Project code -> run ID */
14
+ private readonly runIds;
15
+ /** Project code -> API client for that project */
16
+ private readonly clients;
17
+ /** Project code -> queue of results to send */
18
+ private readonly projectQueues;
19
+ /** Project code -> first unsent index (for batch tracking) */
20
+ private readonly firstIndexByProject;
21
+ private isTestRunReady;
22
+ private readonly mutex;
23
+ private readonly defaultProject;
24
+ private readonly projectCodes;
25
+ private readonly showPublicReportLink;
26
+ constructor(logger: LoggerInterface, testopsOptions: TestOpsOptionsType, multiConfig: TestOpsMultiConfigType, withState: boolean, hostData: HostData, reporterName: string | undefined, frameworkPackage: string | undefined, environment?: string, baseUrl?: string, batchSize?: number, showPublicReportLink?: boolean);
27
+ private buildProjectOptions;
28
+ startTestRun(): Promise<void>;
29
+ addTestResult(result: TestResultType): Promise<void>;
30
+ /**
31
+ * Get list of (projectCode, ids) for a result (multi-project mapping or legacy testops_id).
32
+ * Caller must hold mutex when using projectQueues.
33
+ */
34
+ private getProjectsToUseForResult;
35
+ /**
36
+ * Push a result into project queues (by project / case IDs). Used by addTestResult and by
37
+ * sendResults() when results were set via setTestResults() (e.g. Cypress hooks in another process).
38
+ * Caller must hold mutex.
39
+ */
40
+ private distributeResultToProjectQueues;
41
+ private copyResultForProject;
42
+ private checkOrCreateTestRuns;
43
+ private sendResultsForProject;
44
+ sendResults(): Promise<void>;
45
+ publish(): Promise<void>;
46
+ complete(): Promise<void>;
47
+ uploadAttachment(attachment: Attachment): Promise<string>;
48
+ private getBaseUrl;
49
+ private showLink;
50
+ }
@@ -0,0 +1,299 @@
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.TestOpsMultiReporter = void 0;
7
+ const chalk_1 = __importDefault(require("chalk"));
8
+ const uuid_1 = require("uuid");
9
+ const abstract_reporter_1 = require("./abstract-reporter");
10
+ const models_1 = require("../models");
11
+ const async_mutex_1 = require("async-mutex");
12
+ const clientV2_1 = require("../client/clientV2");
13
+ const defaultChunkSize = 200;
14
+ /**
15
+ * Multi-project TestOps reporter. Sends test results to multiple Qase projects
16
+ * with different test case IDs per project. Each project gets its own run.
17
+ */
18
+ class TestOpsMultiReporter extends abstract_reporter_1.AbstractReporter {
19
+ baseUrl;
20
+ batchSize;
21
+ /** Project code -> run ID */
22
+ runIds = new Map();
23
+ /** Project code -> API client for that project */
24
+ clients = new Map();
25
+ /** Project code -> queue of results to send */
26
+ projectQueues = new Map();
27
+ /** Project code -> first unsent index (for batch tracking) */
28
+ firstIndexByProject = new Map();
29
+ isTestRunReady = false;
30
+ mutex = new async_mutex_1.Mutex();
31
+ defaultProject;
32
+ projectCodes;
33
+ showPublicReportLink;
34
+ constructor(logger, testopsOptions, multiConfig, withState, hostData, reporterName, frameworkPackage, environment, baseUrl, batchSize, showPublicReportLink) {
35
+ super(logger);
36
+ this.baseUrl = this.getBaseUrl(baseUrl ?? testopsOptions.api?.host);
37
+ this.batchSize = batchSize ?? testopsOptions.batch?.size ?? defaultChunkSize;
38
+ this.showPublicReportLink = showPublicReportLink ?? testopsOptions.showPublicReportLink;
39
+ this.defaultProject =
40
+ multiConfig.default_project ??
41
+ (multiConfig.projects.length > 0 ? multiConfig.projects[0].code : '');
42
+ this.projectCodes = multiConfig.projects
43
+ .filter((p) => Boolean(p?.code))
44
+ .map((p) => p.code);
45
+ void withState; // reserved for future StateManager integration
46
+ for (const proj of multiConfig.projects) {
47
+ if (!proj?.code)
48
+ continue;
49
+ const projectOptions = this.buildProjectOptions(testopsOptions, proj);
50
+ const env = proj.environment ?? environment;
51
+ const client = new clientV2_1.ClientV2(logger, projectOptions, env, undefined, hostData, reporterName, frameworkPackage);
52
+ this.clients.set(proj.code, client);
53
+ this.projectQueues.set(proj.code, []);
54
+ this.firstIndexByProject.set(proj.code, 0);
55
+ }
56
+ }
57
+ buildProjectOptions(global, proj) {
58
+ const opts = {
59
+ project: proj.code,
60
+ api: global.api,
61
+ run: proj.run ?? global.run ?? {},
62
+ plan: proj.plan ?? global.plan ?? {},
63
+ uploadAttachments: global.uploadAttachments,
64
+ defect: global.defect,
65
+ configurations: global.configurations,
66
+ statusFilter: global.statusFilter,
67
+ showPublicReportLink: this.showPublicReportLink,
68
+ };
69
+ if (global.batch !== undefined) {
70
+ opts.batch = global.batch;
71
+ }
72
+ return opts;
73
+ }
74
+ async startTestRun() {
75
+ await this.checkOrCreateTestRuns();
76
+ }
77
+ async addTestResult(result) {
78
+ if (result.execution.status === models_1.TestStatusEnum.failed) {
79
+ const mapping = result.getTestopsProjectMapping?.() ?? result.testops_project_mapping ?? null;
80
+ if (mapping) {
81
+ for (const [projectCode, ids] of Object.entries(mapping)) {
82
+ for (const id of ids) {
83
+ this.showLink(projectCode, id, result.title);
84
+ }
85
+ }
86
+ }
87
+ else {
88
+ const ids = Array.isArray(result.testops_id) ? result.testops_id : [result.testops_id];
89
+ for (const id of ids) {
90
+ if (id != null) {
91
+ this.showLink(this.defaultProject, id, result.title);
92
+ }
93
+ }
94
+ }
95
+ }
96
+ const release = await this.mutex.acquire();
97
+ try {
98
+ // Keep original in this.results for getTestResults / fallback
99
+ if (result.execution.stacktrace) {
100
+ result.execution.stacktrace = this.removeAnsiEscapeCodes(result.execution.stacktrace);
101
+ }
102
+ if (result.message) {
103
+ result.message = this.removeAnsiEscapeCodes(result.message);
104
+ }
105
+ this.results.push(result);
106
+ if (!this.isTestRunReady) {
107
+ return;
108
+ }
109
+ for (const { code, ids } of this.getProjectsToUseForResult(result)) {
110
+ const copy = this.copyResultForProject(result, code, ids);
111
+ const queue = this.projectQueues.get(code);
112
+ queue.push(copy);
113
+ const first = this.firstIndexByProject.get(code) ?? 0;
114
+ if (queue.length >= first + this.batchSize) {
115
+ await this.sendResultsForProject(code);
116
+ }
117
+ }
118
+ }
119
+ finally {
120
+ release();
121
+ }
122
+ }
123
+ /**
124
+ * Get list of (projectCode, ids) for a result (multi-project mapping or legacy testops_id).
125
+ * Caller must hold mutex when using projectQueues.
126
+ */
127
+ getProjectsToUseForResult(result) {
128
+ const mapping = result.getTestopsProjectMapping?.() ?? result.testops_project_mapping ?? null;
129
+ const projectsToUse = [];
130
+ if (mapping && Object.keys(mapping).length > 0) {
131
+ for (const [code, ids] of Object.entries(mapping)) {
132
+ if (this.projectCodes.includes(code) && ids.length > 0) {
133
+ projectsToUse.push({ code, ids });
134
+ }
135
+ }
136
+ }
137
+ else {
138
+ // Backward compatibility: use default project + testops_id, or send without case ID to default project
139
+ const ids = Array.isArray(result.testops_id)
140
+ ? result.testops_id
141
+ : result.testops_id != null
142
+ ? [result.testops_id]
143
+ : [];
144
+ if (this.defaultProject) {
145
+ projectsToUse.push({ code: this.defaultProject, ids });
146
+ }
147
+ }
148
+ return projectsToUse;
149
+ }
150
+ /**
151
+ * Push a result into project queues (by project / case IDs). Used by addTestResult and by
152
+ * sendResults() when results were set via setTestResults() (e.g. Cypress hooks in another process).
153
+ * Caller must hold mutex.
154
+ */
155
+ distributeResultToProjectQueues(result) {
156
+ for (const { code, ids } of this.getProjectsToUseForResult(result)) {
157
+ const copy = this.copyResultForProject(result, code, ids);
158
+ const queue = this.projectQueues.get(code);
159
+ queue.push(copy);
160
+ }
161
+ }
162
+ copyResultForProject(result, _projectCode, ids) {
163
+ const copy = { ...result };
164
+ copy.id = (0, uuid_1.v4)();
165
+ copy.testops_id = ids.length === 0 ? null : ids.length === 1 ? ids[0] : ids;
166
+ copy.testops_project_mapping = null;
167
+ return copy;
168
+ }
169
+ async checkOrCreateTestRuns() {
170
+ for (const code of this.projectCodes) {
171
+ const client = this.clients.get(code);
172
+ const runId = await client.createRun();
173
+ this.runIds.set(code, runId);
174
+ this.logger.logDebug(`[${code}] Run ID: ${runId}`);
175
+ }
176
+ this.isTestRunReady = true;
177
+ }
178
+ async sendResultsForProject(projectCode) {
179
+ const queue = this.projectQueues.get(projectCode);
180
+ const first = this.firstIndexByProject.get(projectCode) ?? 0;
181
+ const client = this.clients.get(projectCode);
182
+ const runId = this.runIds.get(projectCode);
183
+ if (!queue || !client || runId === undefined) {
184
+ return false;
185
+ }
186
+ const toSend = queue.slice(first, first + this.batchSize);
187
+ if (toSend.length === 0) {
188
+ return false;
189
+ }
190
+ try {
191
+ await client.uploadResults(runId, toSend);
192
+ this.firstIndexByProject.set(projectCode, first + toSend.length);
193
+ this.logger.logDebug(`[${projectCode}] Sent ${toSend.length} results to Qase`);
194
+ return true;
195
+ }
196
+ catch (error) {
197
+ this.logger.logError(`[${projectCode}] Error sending results:`, error);
198
+ return false;
199
+ }
200
+ }
201
+ async sendResults() {
202
+ const release = await this.mutex.acquire();
203
+ try {
204
+ // Only flush this.results when projectQueues are empty (e.g. Cypress: results set via
205
+ // setTestResults() in another process). When addTestResult() already queued results
206
+ // (same process, e.g. Cucumber), do not flush to avoid sending each result twice.
207
+ const queuesEmpty = this.projectCodes.every((code) => (this.projectQueues.get(code)?.length ?? 0) === 0);
208
+ if (this.results.length > 0 && this.isTestRunReady && queuesEmpty) {
209
+ for (const result of this.results) {
210
+ this.distributeResultToProjectQueues(result);
211
+ }
212
+ this.results = [];
213
+ }
214
+ for (const code of this.projectCodes) {
215
+ let sent;
216
+ do {
217
+ sent = await this.sendResultsForProject(code);
218
+ } while (sent);
219
+ }
220
+ for (const code of this.projectCodes) {
221
+ this.projectQueues.set(code, []);
222
+ this.firstIndexByProject.set(code, 0);
223
+ }
224
+ }
225
+ finally {
226
+ release();
227
+ }
228
+ }
229
+ async publish() {
230
+ // Do not hold mutex here: sendResults() and complete() acquire it themselves.
231
+ // Holding mutex would deadlock when sendResults() tries to acquire the same mutex.
232
+ await this.sendResults();
233
+ await this.complete();
234
+ }
235
+ async complete() {
236
+ const release = await this.mutex.acquire();
237
+ try {
238
+ // Send any remaining results per project
239
+ for (const code of this.projectCodes) {
240
+ let sent;
241
+ do {
242
+ sent = await this.sendResultsForProject(code);
243
+ } while (sent);
244
+ }
245
+ for (const code of this.projectCodes) {
246
+ this.projectQueues.set(code, []);
247
+ this.firstIndexByProject.set(code, 0);
248
+ }
249
+ }
250
+ finally {
251
+ release();
252
+ }
253
+ const completePromises = this.projectCodes.map(async (code) => {
254
+ const client = this.clients.get(code);
255
+ const runId = this.runIds.get(code);
256
+ if (client && runId !== undefined) {
257
+ try {
258
+ await client.completeRun(runId);
259
+ if (this.showPublicReportLink) {
260
+ try {
261
+ await client.enablePublicReport(runId);
262
+ }
263
+ catch {
264
+ // Error already logged in enablePublicReport
265
+ }
266
+ }
267
+ this.logger.log((0, chalk_1.default) `{green [${code}] Run ${runId} completed}`);
268
+ }
269
+ catch (error) {
270
+ this.logger.logError(`[${code}] Error completing run:`, error);
271
+ }
272
+ }
273
+ });
274
+ await Promise.all(completePromises);
275
+ }
276
+ async uploadAttachment(attachment) {
277
+ // Attachments are uploaded per project when results are sent; use default project's client
278
+ const client = this.clients.get(this.defaultProject);
279
+ if (client) {
280
+ return await client.uploadAttachment(attachment);
281
+ }
282
+ return '';
283
+ }
284
+ getBaseUrl(url) {
285
+ if (!url || url === 'qase.io') {
286
+ return 'https://app.qase.io';
287
+ }
288
+ return `https://${url.replace('api', 'app')}`;
289
+ }
290
+ showLink(projectCode, id, title) {
291
+ const runId = this.runIds.get(projectCode);
292
+ if (runId === undefined)
293
+ return;
294
+ const baseLink = `${this.baseUrl}/run/${projectCode}/dashboard/${runId}?source=logs&search=`;
295
+ const link = id != null ? `${baseLink}${projectCode}-${id}` : `${baseLink}${encodeURI(title)}`;
296
+ this.logger.log((0, chalk_1.default) `{blue See why this test failed: ${link}}`);
297
+ }
298
+ }
299
+ exports.TestOpsMultiReporter = TestOpsMultiReporter;
@@ -0,0 +1,46 @@
1
+ import type { TestopsProjectMapping } from '../models';
2
+ /**
3
+ * Result of parsing project/ID markers from a test title.
4
+ * - legacyIds: from "(Qase ID: 123)" or "(Qase ID: 123,124)" — single-project mode.
5
+ * - projectMapping: from "(Qase PROJ1: 123)" etc. — multi-project mode (project code -> IDs).
6
+ * - cleanedTitle: title with all such patterns removed.
7
+ */
8
+ export interface ParsedProjectMapping {
9
+ legacyIds: number[];
10
+ projectMapping: TestopsProjectMapping;
11
+ cleanedTitle: string;
12
+ }
13
+ /**
14
+ * Result of parsing @qaseid / @qaseid.PROJ tags.
15
+ * - legacyIds: from @qaseid(123) or @qaseid(123,124).
16
+ * - projectMapping: from @qaseid.PROJ1(123,124).
17
+ */
18
+ export interface ParsedTagsProjectMapping {
19
+ legacyIds: number[];
20
+ projectMapping: TestopsProjectMapping;
21
+ }
22
+ /**
23
+ * Parse @qaseid and @qaseid.PROJECT tags into legacy IDs and project mapping.
24
+ *
25
+ * @param tags — e.g. ["@qaseid(1,2)", "@qaseid.PROJ2(3)"]
26
+ * @returns legacyIds from @qaseid(...), projectMapping from @qaseid.PROJ(...)
27
+ */
28
+ export declare function parseProjectMappingFromTags(tags: string[]): ParsedTagsProjectMapping;
29
+ /**
30
+ * Parse multi-project and legacy Qase ID markers from a test title.
31
+ * - "(Qase ID: 123)" or "(Qase ID: 123,124)" → legacyIds, single-project.
32
+ * - "(Qase PROJ1: 123)" or "(Qase PROJ2: 456)" → projectMapping for testops_multi.
33
+ *
34
+ * @param title — test title that may contain "(Qase ID: …)" or "(Qase PROJECT_CODE: …)".
35
+ * @returns legacyIds, projectMapping, and cleanedTitle with all markers stripped.
36
+ */
37
+ export declare function parseProjectMappingFromTitle(title: string): ParsedProjectMapping;
38
+ /**
39
+ * Build a test title with multi-project markers for use in test names.
40
+ * Use this (or framework-specific qase.projects()) so the reporter can parse project and IDs.
41
+ *
42
+ * @param title — base test title (e.g. "Login flow").
43
+ * @param mapping — project code → list of test case IDs (e.g. { PROJ1: [1, 2], PROJ2: [3] }).
44
+ * @returns title with appended markers, e.g. "Login flow (Qase PROJ1: 1,2) (Qase PROJ2: 3)".
45
+ */
46
+ export declare function formatTitleWithProjectMapping(title: string, mapping: TestopsProjectMapping): string;
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseProjectMappingFromTags = parseProjectMappingFromTags;
4
+ exports.parseProjectMappingFromTitle = parseProjectMappingFromTitle;
5
+ exports.formatTitleWithProjectMapping = formatTitleWithProjectMapping;
6
+ /** Matches @qaseid(123) or @qaseid.PROJ1(123,124). */
7
+ const QASEID_TAG_REGEXP = /@qaseid(?:\.([A-Za-z0-9_]+))?\(([\d,]+)\)/gi;
8
+ /**
9
+ * Parse @qaseid and @qaseid.PROJECT tags into legacy IDs and project mapping.
10
+ *
11
+ * @param tags — e.g. ["@qaseid(1,2)", "@qaseid.PROJ2(3)"]
12
+ * @returns legacyIds from @qaseid(...), projectMapping from @qaseid.PROJ(...)
13
+ */
14
+ function parseProjectMappingFromTags(tags) {
15
+ const legacyIds = [];
16
+ const projectMapping = {};
17
+ for (const tag of tags) {
18
+ let m;
19
+ const re = new RegExp(QASEID_TAG_REGEXP.source, 'gi');
20
+ while ((m = re.exec(tag)) !== null) {
21
+ const projectCode = m[1]; // undefined for @qaseid(1)
22
+ const idsStr = m[2] ?? '';
23
+ const ids = idsStr.split(',').map((s) => parseInt(s, 10)).filter((n) => !Number.isNaN(n));
24
+ if (!projectCode || projectCode.toUpperCase() === 'ID') {
25
+ legacyIds.push(...ids);
26
+ }
27
+ else if (ids.length > 0) {
28
+ const existing = projectMapping[projectCode] ?? [];
29
+ projectMapping[projectCode] = [...existing, ...ids];
30
+ }
31
+ }
32
+ }
33
+ return { legacyIds, projectMapping };
34
+ }
35
+ /** Matches "(Qase ID: 123)" or "(Qase PROJ1: 123,124)" — optional space after "Qase". */
36
+ const QASE_MARKER_REGEXP = /\(Qase\s+([A-Za-z0-9_]+):\s*([\d,]+)\)/gi;
37
+ /**
38
+ * Parse multi-project and legacy Qase ID markers from a test title.
39
+ * - "(Qase ID: 123)" or "(Qase ID: 123,124)" → legacyIds, single-project.
40
+ * - "(Qase PROJ1: 123)" or "(Qase PROJ2: 456)" → projectMapping for testops_multi.
41
+ *
42
+ * @param title — test title that may contain "(Qase ID: …)" or "(Qase PROJECT_CODE: …)".
43
+ * @returns legacyIds, projectMapping, and cleanedTitle with all markers stripped.
44
+ */
45
+ function parseProjectMappingFromTitle(title) {
46
+ const legacyIds = [];
47
+ const projectMapping = {};
48
+ let cleanedTitle = title;
49
+ let m;
50
+ const re = new RegExp(QASE_MARKER_REGEXP.source, 'gi');
51
+ while ((m = re.exec(title)) !== null) {
52
+ const projectCode = m[1] ?? '';
53
+ const idsStr = m[2] ?? '';
54
+ const ids = idsStr.split(',').map((s) => parseInt(s, 10)).filter((n) => !Number.isNaN(n));
55
+ if (projectCode.toUpperCase() === 'ID') {
56
+ legacyIds.push(...ids);
57
+ }
58
+ else if (projectCode && ids.length > 0) {
59
+ const existing = projectMapping[projectCode] ?? [];
60
+ projectMapping[projectCode] = [...existing, ...ids];
61
+ }
62
+ cleanedTitle = cleanedTitle.replace(m[0], '');
63
+ }
64
+ cleanedTitle = cleanedTitle.replace(/\s+/g, ' ').trim();
65
+ return { legacyIds, projectMapping, cleanedTitle };
66
+ }
67
+ /**
68
+ * Build a test title with multi-project markers for use in test names.
69
+ * Use this (or framework-specific qase.projects()) so the reporter can parse project and IDs.
70
+ *
71
+ * @param title — base test title (e.g. "Login flow").
72
+ * @param mapping — project code → list of test case IDs (e.g. { PROJ1: [1, 2], PROJ2: [3] }).
73
+ * @returns title with appended markers, e.g. "Login flow (Qase PROJ1: 1,2) (Qase PROJ2: 3)".
74
+ */
75
+ function formatTitleWithProjectMapping(title, mapping) {
76
+ if (!title || typeof title !== 'string') {
77
+ return title;
78
+ }
79
+ const parts = Object.entries(mapping)
80
+ .filter(([, ids]) => Array.isArray(ids) && ids.length > 0)
81
+ .map(([code, ids]) => `(Qase ${code}: ${ids.join(',')})`);
82
+ if (parts.length === 0) {
83
+ return title.trim();
84
+ }
85
+ return `${title.trim()} ${parts.join(' ')}`;
86
+ }
@@ -14,7 +14,7 @@ function determineTestStatus(error, originalStatus) {
14
14
  return mapOriginalStatus(originalStatus);
15
15
  }
16
16
  // Check if it's an assertion error
17
- if (isAssertionError(error)) {
17
+ if (isAssertionError(error, originalStatus)) {
18
18
  return test_execution_1.TestStatusEnum.failed;
19
19
  }
20
20
  // For all other errors, return invalid
@@ -23,16 +23,19 @@ function determineTestStatus(error, originalStatus) {
23
23
  /**
24
24
  * Checks if error is an assertion error
25
25
  * @param error - Error object
26
+ * @param originalStatus - Original test status from test runner
26
27
  * @returns boolean - true if assertion error
27
28
  */
28
- function isAssertionError(error) {
29
+ function isAssertionError(error, originalStatus) {
29
30
  const errorMessage = error.message.toLowerCase();
30
31
  const errorStack = error.stack?.toLowerCase() || '';
32
+ const normalizedOriginalStatus = originalStatus.toLowerCase();
31
33
  // Common assertion error patterns
32
34
  const assertionPatterns = [
33
35
  'expect',
34
36
  'assert',
35
37
  'matcher',
38
+ 'objectcontaining',
36
39
  'assertion',
37
40
  'expected',
38
41
  'actual',
@@ -77,6 +80,14 @@ function isAssertionError(error) {
77
80
  const nonAssertionPatternsWithoutTimeout = nonAssertionPatterns.filter(pattern => pattern !== 'timeout');
78
81
  const hasNonAssertionPattern = nonAssertionPatternsWithoutTimeout.some(pattern => errorMessage.includes(pattern) || errorStack.includes(pattern));
79
82
  if (hasNonAssertionPattern) {
83
+ const hasAssertionContext = assertionPatterns.some(pattern => errorMessage.includes(pattern) || errorStack.includes(pattern));
84
+ const isRunnerFailed = normalizedOriginalStatus === 'failed' || normalizedOriginalStatus === 'timedout' || normalizedOriginalStatus === 'interrupted';
85
+ const isSyntaxError = errorMessage.includes('syntaxerror') || errorStack.includes('syntaxerror');
86
+ // When runner reported failure and error has assertion context (expect, ObjectContaining, diff),
87
+ // treat as Failed. Exception: SyntaxError (e.g. "Unexpected token") contains "expected" as false positive.
88
+ if (isRunnerFailed && hasAssertionContext && !isSyntaxError) {
89
+ return true;
90
+ }
80
91
  return false;
81
92
  }
82
93
  // For timeout errors without expect, treat as invalid
@@ -99,6 +110,7 @@ function isAssertionError(error) {
99
110
  * @returns TestStatusEnum
100
111
  */
101
112
  function mapOriginalStatus(originalStatus) {
113
+ // Keys must be lowercase to match normalizedStatus (case-insensitive matching)
102
114
  const statusMap = {
103
115
  'passed': test_execution_1.TestStatusEnum.passed,
104
116
  'failed': test_execution_1.TestStatusEnum.failed,
@@ -107,10 +119,9 @@ function mapOriginalStatus(originalStatus) {
107
119
  'pending': test_execution_1.TestStatusEnum.skipped,
108
120
  'todo': test_execution_1.TestStatusEnum.disabled,
109
121
  'focused': test_execution_1.TestStatusEnum.passed,
110
- 'timedOut': test_execution_1.TestStatusEnum.failed,
122
+ 'timedout': test_execution_1.TestStatusEnum.failed,
111
123
  'interrupted': test_execution_1.TestStatusEnum.failed,
112
124
  };
113
- // Convert to lowercase for case-insensitive matching
114
125
  const normalizedStatus = originalStatus.toLowerCase();
115
126
  return statusMap[normalizedStatus] || test_execution_1.TestStatusEnum.skipped;
116
127
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qase-javascript-commons",
3
- "version": "2.4.18",
3
+ "version": "2.5.1",
4
4
  "description": "Qase JS Reporters",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",