@testomatio/reporter 2.7.4-beta.allure-1 → 2.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,6 +58,13 @@ pnpm install @testomatio/reporter --save-dev
58
58
  yarn add @testomatio/reporter --dev
59
59
  ```
60
60
 
61
+ For Yarn 4 (Berry), use CLI wrapper package:
62
+
63
+ ```
64
+ yarn add testomatio-reporter-cli --dev
65
+ npx testomatio-reporter <command> [options]
66
+ ```
67
+
61
68
  ## Getting Started
62
69
 
63
70
  ### 1️⃣ Attach Reporter to the Test Runner
@@ -69,7 +76,6 @@ yarn add @testomatio/reporter --dev
69
76
  | [TestCafe](./docs/frameworks.md#testcafe) | [Detox](./docs/frameworks.md#detox) | [Codeception](https://github.com/testomatio/php-reporter) |
70
77
  | [Newman (Postman)](./docs/frameworks.md#newman) | [JUnit](./docs/junit.md#junit) | [NUnit](./docs/junit.md#nunit) |
71
78
  | [PyTest](./docs/junit.md#pytest) | [PHPUnit](./docs/junit.md#phpunit) | [Protractor](./docs/frameworks.md#protractor) |
72
- | [Allure](./docs/allure.md) | | |
73
79
 
74
80
  or **any [other via JUnit](./docs/junit.md)** report....
75
81
 
@@ -130,10 +136,9 @@ Bring this reporter on CI and never lose test results again!
130
136
  - [CSV](./docs/pipes/csv.md)
131
137
  - [HTML report](./docs/pipes/html.md)
132
138
  - [Bitbucket](./docs/pipes/bitbucket.md)
133
- - 📓 [JUnit Reports](./docs/junit.md)
134
- - 🗄️ [Artifacts](./docs/artifacts.md)
135
- - 🔬 [Allure Reports](./docs/allure.md)
136
139
  - 🔗 [Linking Tests](./docs/linking-tests.md)
140
+ - 📓 [JUnit](./docs/junit.md)
141
+ - 🗄️ [Artifacts](./docs/artifacts.md)
137
142
  - 🔂 [Workflows](./docs/workflows.md)
138
143
  - 🖊️ [Logger](./docs/logger.md)
139
144
  - 🪲 [Debug File Format](./docs/debug-file-format.md)
@@ -18,15 +18,22 @@ export type TestData = import("../../types/types.js").TestData;
18
18
  export class VitestReporter {
19
19
  constructor(config?: {});
20
20
  client: TestomatioClient;
21
- /**
22
- * @type {(TestData & {status: string})[]} tests
23
- */
21
+ /** @type {(TestData & {status: string, _reportKey?: string | null})[]} tests */
24
22
  tests: (TestData & {
25
23
  status: string;
24
+ _reportKey?: string | null;
26
25
  })[];
27
26
  _finalized: boolean;
28
27
  _finalizing: boolean;
28
+ _runStartedAtMs: number;
29
+ _runStartedAtMicros: number;
30
+ _reportedTestKeys: Set<any>;
31
+ _liveQueue: Promise<void>;
29
32
  onInit(): void;
33
+ /**
34
+ * Vitest 3/4 callback fired when test run starts.
35
+ */
36
+ onTestRunStart(): void;
30
37
  /**
31
38
  * @param {VitestTestFile[] | undefined} files // array with results;
32
39
  * @param {unknown[] | undefined} errors // errors does not contain errors from tests; probably its testrunner errors
@@ -39,6 +46,18 @@ export class VitestReporter {
39
46
  * @param {unknown[] | undefined} errors
40
47
  */
41
48
  onTestRunEnd(testModules: Array<unknown> | undefined, errors: unknown[] | undefined): Promise<void>;
49
+ /**
50
+ * Vitest 4 callback fired when single test case is finished.
51
+ *
52
+ * @param {unknown} testCase
53
+ */
54
+ onTestCaseResult(testCase: unknown): Promise<void>;
55
+ /**
56
+ * Vitest 3 fallback callback with task updates.
57
+ *
58
+ * @param {unknown[] | undefined} packs
59
+ */
60
+ onTaskUpdate(packs: unknown[] | undefined): Promise<void>;
42
61
  #private;
43
62
  }
44
63
  import { Client as TestomatioClient } from '../client.js';
@@ -22,19 +22,34 @@ const debug = (0, debug_1.default)('@testomatio/reporter:adapter-jest');
22
22
  class VitestReporter {
23
23
  constructor(config = {}) {
24
24
  this.client = new client_js_1.Client({ apiKey: config?.apiKey });
25
- /**
26
- * @type {(TestData & {status: string})[]} tests
27
- */
25
+ /** @type {(TestData & {status: string, _reportKey?: string | null})[]} tests */
28
26
  this.tests = [];
29
27
  this._finalized = false;
30
28
  this._finalizing = false;
29
+ this._runStartedAtMs = null;
30
+ this._runStartedAtMicros = null;
31
+ this._reportedTestKeys = new Set();
32
+ this._liveQueue = Promise.resolve();
31
33
  }
32
34
  // on run start
33
35
  onInit() {
36
+ const now = Date.now();
34
37
  this._finalized = false;
35
38
  this._finalizing = false;
39
+ this._runStartedAtMs = now;
40
+ this._runStartedAtMicros = now * 1000;
41
+ this._reportedTestKeys = new Set();
42
+ this._liveQueue = Promise.resolve();
36
43
  this.client.createRun();
37
44
  }
45
+ /**
46
+ * Vitest 3/4 callback fired when test run starts.
47
+ */
48
+ onTestRunStart() {
49
+ const now = Date.now();
50
+ this._runStartedAtMs = now;
51
+ this._runStartedAtMicros = now * 1000;
52
+ }
38
53
  /**
39
54
  * @param {VitestTestFile[] | undefined} files // array with results;
40
55
  * @param {unknown[] | undefined} errors // errors does not contain errors from tests; probably its testrunner errors
@@ -68,12 +83,19 @@ class VitestReporter {
68
83
  debug(this.tests.length, 'tests collected');
69
84
  // send tests to Testomat.io
70
85
  for (const test of this.tests) {
86
+ if (test._reportKey && this._reportedTestKeys.has(test._reportKey))
87
+ continue;
88
+ if (test._reportKey)
89
+ this._reportedTestKeys.add(test._reportKey);
71
90
  await this.client.addTestRun(test.status, test);
72
91
  }
92
+ await this._liveQueue;
73
93
  console.log('finished');
74
94
  if (errors.length)
75
95
  console.error('Vitest adapter errors:', errors);
76
- await this.client.updateRunStatus(getRunStatusFromResults(files));
96
+ const startedAtMs = this._runStartedAtMs || getEarliestTestStartMs(files) || Date.now();
97
+ const duration = Math.max(0, (Date.now() - startedAtMs) / 1000);
98
+ await this.client.updateRunStatus(getRunStatusFromResults(files), { duration });
77
99
  this._finalized = true;
78
100
  }
79
101
  finally {
@@ -92,6 +114,28 @@ class VitestReporter {
92
114
  .filter(Boolean);
93
115
  await this.onFinished(files, errors);
94
116
  }
117
+ /**
118
+ * Vitest 4 callback fired when single test case is finished.
119
+ *
120
+ * @param {unknown} testCase
121
+ */
122
+ async onTestCaseResult(testCase) {
123
+ await this.#reportLive(testCase);
124
+ }
125
+ /**
126
+ * Vitest 3 fallback callback with task updates.
127
+ *
128
+ * @param {unknown[] | undefined} packs
129
+ */
130
+ async onTaskUpdate(packs) {
131
+ if (!Array.isArray(packs) || !packs.length)
132
+ return;
133
+ for (const pack of packs) {
134
+ const test = getTestFromTaskUpdatePack(pack);
135
+ if (test)
136
+ await this.#reportLive(test);
137
+ }
138
+ }
95
139
  /* non-used listeners
96
140
  onUserConsoleLog(log) {}
97
141
  onPathsCollected(paths) {} // paths array to files with tests
@@ -126,25 +170,50 @@ class VitestReporter {
126
170
  /**
127
171
  * Processes task and returns test data ready to be sent to Testomat.io
128
172
  *
129
- * @param {VitestTest} test
173
+ * @param {any} test
130
174
  *
131
- * @returns {TestData & {status: 'passed' | 'failed' | 'skipped'}}
175
+ * @returns {TestData & {status: 'passed' | 'failed' | 'skipped', _reportKey?: string | null}}
132
176
  */
133
177
  #getDataFromTest(test) {
178
+ const normalized = normalizeVitestTest(test);
179
+ const reportKey = getReportKey(test, normalized);
180
+ const startMicros = typeof normalized.startTime === 'number'
181
+ ? Math.floor(normalized.startTime * 1000)
182
+ : this._runStartedAtMicros || undefined;
134
183
  return {
135
- error: test.result?.errors ? test.result.errors[0] : undefined,
136
- file: test.file?.name || test.file?.filepath || '',
137
- logs: test.logs ? transformLogsToString(test.logs) : '',
138
- meta: test.meta,
184
+ _reportKey: reportKey,
185
+ error: normalized.error,
186
+ file: normalized.file,
187
+ logs: normalized.logs,
188
+ meta: normalized.meta,
139
189
  // @ts-ignore - STATUS values are string literals but type system sees them as string
140
- status: getTestStatus(test),
141
- suite_title: test.suite?.name || test.file?.name || test.file?.filepath,
142
- test_id: (0, utils_js_1.getTestomatIdFromTestTitle)(test.name),
143
- time: test.result?.duration || 0,
144
- title: test.name,
190
+ status: getTestStatus(normalized.state, normalized.mode),
191
+ suite_title: normalized.suiteTitle,
192
+ test_id: (0, utils_js_1.getTestomatIdFromTestTitle)(normalized.name),
193
+ time: normalized.duration,
194
+ timestamp: startMicros,
195
+ title: normalized.name,
145
196
  // testomatio functions (artifacts, logs, steps, meta) are not supported
146
197
  };
147
198
  }
199
+ /**
200
+ * @param {unknown} testCase
201
+ */
202
+ async #reportLive(testCase) {
203
+ if (this._finalized || this._finalizing)
204
+ return;
205
+ const normalized = normalizeVitestTest(testCase);
206
+ if (!isLiveReportableState(normalized.state, normalized.mode))
207
+ return;
208
+ const data = this.#getDataFromTest(testCase);
209
+ if (!data._reportKey || this._reportedTestKeys.has(data._reportKey))
210
+ return;
211
+ this._reportedTestKeys.add(data._reportKey);
212
+ this._liveQueue = this._liveQueue
213
+ .then(() => this.client.addTestRun(data.status, data))
214
+ .catch(() => undefined);
215
+ await this._liveQueue;
216
+ }
148
217
  }
149
218
  exports.VitestReporter = VitestReporter;
150
219
  /**
@@ -159,16 +228,15 @@ function getRunStatusFromResults(files) {
159
228
  */
160
229
  let status = 'finished'; // default status (if no failed or passed tests)
161
230
  files.forEach(file => {
162
- // search for failed tests
163
- file.tasks.forEach(taskOrSuite => {
164
- if (taskOrSuite.result?.state === 'fail') {
231
+ getTasks(file).forEach(taskOrSuite => {
232
+ if (isFailedState(taskOrSuite?.result?.state)) {
165
233
  status = 'failed'; // set status to failed if any test failed
166
234
  }
167
235
  });
168
236
  // if there are no failed tests > search for passed tests
169
237
  if (status !== 'failed') {
170
- file.tasks.forEach(taskOrSuite => {
171
- if (taskOrSuite.result?.state === 'pass') {
238
+ getTasks(file).forEach(taskOrSuite => {
239
+ if (isPassedState(taskOrSuite?.result?.state)) {
172
240
  status = 'passed'; // set status to passed if any test passed (and there are no failed tests)
173
241
  }
174
242
  });
@@ -179,17 +247,18 @@ function getRunStatusFromResults(files) {
179
247
  /**
180
248
  * Returns test status in Testomat.io format
181
249
  *
182
- * @param {VitestTest} test
250
+ * @param {string | undefined} state
251
+ * @param {string | undefined} mode
183
252
  * @returns 'passed' | 'failed' | 'skipped'
184
253
  */
185
- function getTestStatus(test) {
186
- if (test.result?.state === 'fail')
254
+ function getTestStatus(state, mode) {
255
+ if (isFailedState(state))
187
256
  return constants_js_1.STATUS.FAILED;
188
- if (test.result?.state === 'pass')
257
+ if (isPassedState(state))
189
258
  return constants_js_1.STATUS.PASSED;
190
- if (test.result?.state === 'skip' || (!test.result && test.mode === 'skip'))
259
+ if (isSkippedState(state) || (!state && mode === 'skip'))
191
260
  return constants_js_1.STATUS.SKIPPED;
192
- console.error(picocolors_1.default.red('Unprocessed case for defining test status. Contact dev team. Test:'), test);
261
+ console.error(picocolors_1.default.red('Unprocessed case for defining test status. Contact dev team. State:'), state);
193
262
  return constants_js_1.STATUS.SKIPPED;
194
263
  }
195
264
  /**
@@ -221,10 +290,165 @@ function getTasks(node) {
221
290
  return node.tasks;
222
291
  if (Array.isArray(node.children))
223
292
  return node.children;
293
+ if (node.children && typeof node.children[Symbol.iterator] === 'function')
294
+ return Array.from(node.children);
224
295
  if (node.task)
225
296
  return [node.task];
226
297
  return [];
227
298
  }
299
+ /**
300
+ * @param {string | undefined} state
301
+ * @returns {boolean}
302
+ */
303
+ function isFailedState(state) {
304
+ return state === 'fail' || state === 'failed';
305
+ }
306
+ /**
307
+ * @param {string | undefined} state
308
+ * @returns {boolean}
309
+ */
310
+ function isPassedState(state) {
311
+ return state === 'pass' || state === 'passed';
312
+ }
313
+ /**
314
+ * @param {string | undefined} state
315
+ * @returns {boolean}
316
+ */
317
+ function isSkippedState(state) {
318
+ return state === 'skip' || state === 'skipped' || state === 'todo';
319
+ }
320
+ /**
321
+ * Accept only completed test states for live upload to avoid reporting
322
+ * intermediate task updates as skipped.
323
+ *
324
+ * @param {string | undefined} state
325
+ * @param {string | undefined} mode
326
+ * @returns {boolean}
327
+ */
328
+ function isLiveReportableState(state, mode) {
329
+ if (isFailedState(state) || isPassedState(state) || isSkippedState(state))
330
+ return true;
331
+ if (!state && mode === 'skip')
332
+ return true;
333
+ return false;
334
+ }
335
+ /**
336
+ * @param {VitestTestFile[] | undefined} files
337
+ * @returns {number | null}
338
+ */
339
+ function getEarliestTestStartMs(files) {
340
+ let earliest = null;
341
+ const walk = node => {
342
+ if (!node)
343
+ return;
344
+ const startTime = node?.result?.startTime;
345
+ if (typeof startTime === 'number' && !Number.isNaN(startTime)) {
346
+ if (earliest == null || startTime < earliest)
347
+ earliest = startTime;
348
+ }
349
+ getTasks(node).forEach(walk);
350
+ };
351
+ (files || []).forEach(walk);
352
+ return earliest;
353
+ }
354
+ /**
355
+ * @param {any} test
356
+ * @returns {{
357
+ * name: string,
358
+ * state: string | undefined,
359
+ * mode: string | undefined,
360
+ * duration: number,
361
+ * startTime: number | undefined,
362
+ * error: any,
363
+ * file: string,
364
+ * suiteTitle: string,
365
+ * logs: string,
366
+ * meta: any
367
+ * }}
368
+ */
369
+ function normalizeVitestTest(test) {
370
+ if (test && typeof test.result === 'function') {
371
+ const result = test.result();
372
+ const diagnostic = typeof test.diagnostic === 'function' ? test.diagnostic() : undefined;
373
+ const state = result?.state;
374
+ const duration = diagnostic?.duration || 0;
375
+ const startTime = diagnostic?.startTime;
376
+ const error = Array.isArray(result?.errors) ? result.errors[0] : undefined;
377
+ const file = test.module?.relativeModuleId ||
378
+ test.module?.moduleId ||
379
+ test.task?.file?.name ||
380
+ test.task?.file?.filepath ||
381
+ '';
382
+ const suiteTitle = (test.parent?.type === 'suite' ? test.parent?.name : null) ||
383
+ test.task?.suite?.name ||
384
+ test.task?.file?.name ||
385
+ file;
386
+ return {
387
+ name: test.name || test.task?.name || '',
388
+ state,
389
+ mode: test.options?.mode || test.task?.mode,
390
+ duration,
391
+ startTime,
392
+ error,
393
+ file,
394
+ suiteTitle,
395
+ logs: '',
396
+ meta: typeof test.meta === 'function' ? test.meta() : {},
397
+ };
398
+ }
399
+ return {
400
+ name: test?.name || '',
401
+ state: test?.result?.state,
402
+ mode: test?.mode,
403
+ duration: test?.result?.duration || 0,
404
+ startTime: test?.result?.startTime,
405
+ error: test?.result?.errors ? test.result.errors[0] : undefined,
406
+ file: test?.file?.name || test?.file?.filepath || '',
407
+ suiteTitle: test?.suite?.name || test?.file?.name || test?.file?.filepath || '',
408
+ logs: test?.logs ? transformLogsToString(test.logs) : '',
409
+ meta: test?.meta,
410
+ };
411
+ }
412
+ /**
413
+ * @param {any} test
414
+ * @param {{file: string, suiteTitle: string, name: string, startTime?: number}} normalized
415
+ * @returns {string | null}
416
+ */
417
+ function getReportKey(test, normalized) {
418
+ if (test?.id)
419
+ return String(test.id);
420
+ if (test?.task?.id)
421
+ return String(test.task.id);
422
+ if (!normalized?.name)
423
+ return null;
424
+ const loc = test?.location || test?.task?.location;
425
+ const locationKey = loc ? `${loc.line || ''}:${loc.column || ''}` : '';
426
+ const startKey = typeof normalized.startTime === 'number' && !Number.isNaN(normalized.startTime) ? String(normalized.startTime) : '';
427
+ return `${normalized.file}::${normalized.suiteTitle}::${normalized.name}::${locationKey}::${startKey}`;
428
+ }
429
+ /**
430
+ * Vitest can pass task updates as tuples. Try to extract a test-like object.
431
+ *
432
+ * @param {unknown} pack
433
+ * @returns {any | null}
434
+ */
435
+ function getTestFromTaskUpdatePack(pack) {
436
+ if (!pack)
437
+ return null;
438
+ if (Array.isArray(pack)) {
439
+ if (pack[2]?.type === 'test')
440
+ return pack[2];
441
+ if (pack[1]?.type === 'test')
442
+ return pack[1];
443
+ if (pack[0]?.type === 'test')
444
+ return pack[0];
445
+ return null;
446
+ }
447
+ const objectPack = /** @type {any} */ (pack);
448
+ if (typeof objectPack === 'object' && objectPack?.type === 'test')
449
+ return objectPack;
450
+ return null;
451
+ }
228
452
  module.exports = VitestReporter;
229
453
 
230
454
  module.exports.VitestReporter = VitestReporter;
package/lib/bin/cli.js CHANGED
@@ -10,7 +10,6 @@ const glob_1 = require("glob");
10
10
  const debug_1 = __importDefault(require("debug"));
11
11
  const client_js_1 = __importDefault(require("../client.js"));
12
12
  const xmlReader_js_1 = __importDefault(require("../xmlReader.js"));
13
- const allureReader_js_1 = __importDefault(require("../allureReader.js"));
14
13
  const constants_js_1 = require("../constants.js");
15
14
  const utils_js_1 = require("../utils/utils.js");
16
15
  const config_js_1 = require("../config.js");
@@ -238,33 +237,6 @@ program
238
237
  if (timeoutTimer)
239
238
  clearTimeout(timeoutTimer);
240
239
  });
241
- program
242
- .command('allure')
243
- .description('Parse Allure result files and upload to Testomat.io')
244
- .argument('<pattern>', 'Allure result directory pattern')
245
- .option('-d, --dir <dir>', 'Project directory')
246
- .option('--timelimit <time>', 'default time limit in seconds to kill a stuck process')
247
- .option('--with-package', 'Keep full package path in file names (default: strip package prefix)')
248
- .action(async (pattern, opts) => {
249
- const runReader = new allureReader_js_1.default({ withPackage: opts.withPackage });
250
- let timeoutTimer;
251
- if (opts.timelimit) {
252
- timeoutTimer = setTimeout(() => {
253
- console.log(`⚠️ Reached timeout of ${opts.timelimit}s. Exiting... (Exit code is 0 to not fail the pipeline)`);
254
- process.exit(0);
255
- }, parseInt(opts.timelimit, 10) * 1000);
256
- }
257
- try {
258
- await runReader.parse(pattern);
259
- await runReader.createRun();
260
- await runReader.uploadData();
261
- }
262
- catch (err) {
263
- console.log(constants_js_1.APP_PREFIX, 'Error uploading Allure results:', err);
264
- }
265
- if (timeoutTimer)
266
- clearTimeout(timeoutTimer);
267
- });
268
240
  program
269
241
  .command('upload-artifacts')
270
242
  .description('Upload artifacts to Testomat.io')
package/lib/client.d.ts CHANGED
@@ -65,9 +65,10 @@ export class Client {
65
65
  *
66
66
  * Updates the status of the current test run and finishes the run.
67
67
  * @param {'passed' | 'failed' | 'skipped' | 'finished'} status - The status of the current test run.
68
+ * @param {Partial<import('../types/types.js').RunData>} [params] - Additional run params (e.g. duration).
68
69
  * Must be one of "passed", "failed", or "finished"
69
70
  * @returns {Promise<any>} - A Promise that resolves when finishes the run.
70
71
  */
71
- updateRunStatus(status: "passed" | "failed" | "skipped" | "finished"): Promise<any>;
72
+ updateRunStatus(status: "passed" | "failed" | "skipped" | "finished", params?: Partial<import("../types/types.js").RunData>): Promise<any>;
72
73
  }
73
74
  import { S3Uploader } from './uploader.js';
package/lib/client.js CHANGED
@@ -308,17 +308,18 @@ class Client {
308
308
  *
309
309
  * Updates the status of the current test run and finishes the run.
310
310
  * @param {'passed' | 'failed' | 'skipped' | 'finished'} status - The status of the current test run.
311
+ * @param {Partial<import('../types/types.js').RunData>} [params] - Additional run params (e.g. duration).
311
312
  * Must be one of "passed", "failed", or "finished"
312
313
  * @returns {Promise<any>} - A Promise that resolves when finishes the run.
313
314
  */
314
- async updateRunStatus(status) {
315
+ async updateRunStatus(status, params = {}) {
315
316
  this.pipes ||= await (0, index_js_1.pipesFactory)(this.paramsForPipesFactory || {}, this.pipeStore);
316
317
  this.runId ||= (0, utils_js_1.readLatestRunId)();
317
318
  debug('Updating run status...');
318
319
  // all pipes disabled, skipping
319
320
  if (!this.pipes?.filter(p => p.isEnabled).length)
320
321
  return Promise.resolve();
321
- const runParams = { status };
322
+ const runParams = { ...params, status };
322
323
  this.queue = this.queue
323
324
  .then(() => Promise.all(this.pipes.map(p => p.finishRun(runParams))))
324
325
  .then(() => {
@@ -9,7 +9,6 @@ const java_js_1 = __importDefault(require("./java.js"));
9
9
  const python_js_1 = __importDefault(require("./python.js"));
10
10
  const ruby_js_1 = __importDefault(require("./ruby.js"));
11
11
  const csharp_js_1 = __importDefault(require("./csharp.js"));
12
- const kotlin_js_1 = __importDefault(require("./kotlin.js"));
13
12
  function AdapterFactory(lang, opts) {
14
13
  if (lang === 'java') {
15
14
  return new java_js_1.default(opts);
@@ -26,9 +25,6 @@ function AdapterFactory(lang, opts) {
26
25
  if (lang === 'c#' || lang === 'csharp') {
27
26
  return new csharp_js_1.default(opts);
28
27
  }
29
- if (lang === 'kotlin') {
30
- return new kotlin_js_1.default(opts);
31
- }
32
28
  return new adapter_js_1.default(opts);
33
29
  }
34
30
  module.exports = AdapterFactory;
@@ -101,6 +101,14 @@ class BitbucketPipe {
101
101
  async finishRun(runParams) {
102
102
  if (!this.isEnabled)
103
103
  return;
104
+ if (!this.ENV.BITBUCKET_PR_ID) {
105
+ log_js_1.log.warn(picocolors_1.default.yellow('Bitbucket'), 'Skipping PR comment: BITBUCKET_PR_ID is not set. Run this pipe in a Bitbucket pull-requests pipeline.');
106
+ return;
107
+ }
108
+ if (!this.ENV.BITBUCKET_WORKSPACE || !this.ENV.BITBUCKET_REPO_SLUG) {
109
+ log_js_1.log.warn(picocolors_1.default.yellow('Bitbucket'), 'Skipping PR comment: BITBUCKET_WORKSPACE or BITBUCKET_REPO_SLUG is missing.');
110
+ return;
111
+ }
104
112
  if (runParams.tests)
105
113
  runParams.tests.forEach(t => this.addTest(t));
106
114
  // Clean up the logs from ANSI codes
@@ -194,9 +202,16 @@ class BitbucketPipe {
194
202
  log_js_1.log.info(picocolors_1.default.yellow('Bitbucket'), `Report created: ${picocolors_1.default.magenta(commentURL)}`);
195
203
  }
196
204
  catch (err) {
205
+ const isForbiddenError = `${err}`.includes('Forbidden') || `${err}`.includes('403');
206
+ const scopeHint = isForbiddenError
207
+ ? '\nHint: use a token that can write PR comments '
208
+ + '(recommended: Repository Access Token with Pull requests: Write '
209
+ + 'and Repository: Read) and run inside a pull-requests pipeline '
210
+ + 'where BITBUCKET_PR_ID is available.'
211
+ : '';
197
212
  console.error(constants_js_1.APP_PREFIX, picocolors_1.default.yellow('Bitbucket'), `Couldn't create Bitbucket report\n${err}.
198
213
  Request URL: ${commentsRequestURL}
199
- Request data: ${body}`);
214
+ Request data: ${body}${scopeHint}`);
200
215
  }
201
216
  }
202
217
  async sync() {
package/lib/pipe/html.js CHANGED
@@ -180,8 +180,12 @@ class HtmlPipe {
180
180
  ...(test.meta?.attachments || []),
181
181
  ];
182
182
  test.artifactsUploaded = allPossibleArtifacts.some(artifact => {
183
- const link = artifact?.link || artifact?.path;
184
- return link && (link.startsWith('http://') || link.startsWith('https://')) && !link.startsWith('file://');
183
+ let link = artifact?.link || artifact?.path;
184
+ if (!link)
185
+ return false;
186
+ if (typeof link !== 'string')
187
+ link = String(link);
188
+ return link.startsWith('http://') || link.startsWith('https://');
185
189
  });
186
190
  normalizeRetries(test);
187
191
  if (test.traces) {
@@ -932,6 +936,16 @@ function normalizeArtifacts(test) {
932
936
  return allArtifacts
933
937
  .map(artifact => {
934
938
  if (typeof artifact === 'string') {
939
+ if (/^https?:\/\//i.test(artifact)) {
940
+ const base = path_1.default.basename(new URL(artifact).pathname) || artifact;
941
+ return {
942
+ name: base,
943
+ title: base,
944
+ path: artifact,
945
+ fsPath: null,
946
+ relativePath: artifact,
947
+ };
948
+ }
935
949
  const abs = path_1.default.isAbsolute(artifact) ? artifact : path_1.default.resolve(process.cwd(), artifact);
936
950
  const href = artifact.startsWith('file://') ? artifact : (0, file_url_1.default)(abs, { resolve: true });
937
951
  const base = path_1.default.basename(abs);
@@ -946,9 +960,14 @@ function normalizeArtifacts(test) {
946
960
  if (artifact?.path) {
947
961
  const raw = String(artifact.path);
948
962
  const isFileUrl = raw.startsWith('file://');
949
- const abs = isFileUrl ? null : path_1.default.isAbsolute(raw) ? raw : path_1.default.resolve(process.cwd(), raw);
950
- const href = isFileUrl ? raw : (0, file_url_1.default)(abs, { resolve: true });
951
- const base = abs ? path_1.default.basename(abs) : artifact.name || artifact.title || 'attachment';
963
+ const isHttpUrl = /^https?:\/\//i.test(raw);
964
+ const abs = isFileUrl || isHttpUrl ? null : path_1.default.isAbsolute(raw) ? raw : path_1.default.resolve(process.cwd(), raw);
965
+ const href = isFileUrl || isHttpUrl ? raw : (0, file_url_1.default)(abs, { resolve: true });
966
+ const base = abs
967
+ ? path_1.default.basename(abs)
968
+ : isHttpUrl
969
+ ? path_1.default.basename(new URL(raw).pathname) || artifact.name || artifact.title || 'attachment'
970
+ : artifact.name || artifact.title || 'attachment';
952
971
  return {
953
972
  ...artifact,
954
973
  name: artifact.name || artifact.title || base,