@testomatio/reporter 2.20.0 → 2.21.0

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.
@@ -0,0 +1,149 @@
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.isPlaceholderCommand = isPlaceholderCommand;
7
+ exports.detectRunner = detectRunner;
8
+ exports.runnerHint = runnerHint;
9
+ exports.buildFilterWarning = buildFilterWarning;
10
+ const fs_1 = __importDefault(require("fs"));
11
+ const path_1 = __importDefault(require("path"));
12
+ const picocolors_1 = __importDefault(require("picocolors"));
13
+ const LAUNCHER_PREFIXES = ['npx', 'npm', 'yarn', 'pnpm', 'bun', 'node', 'bunx'];
14
+ const PLACEHOLDER_PHRASE_RE = /^<?-*(?:run|your|test)\s+(?:[\w-]+\s+)?(?:tests?|command|runner)>?$/i;
15
+ const RUNNERS = [
16
+ {
17
+ name: 'Playwright',
18
+ configs: ['playwright.config.js', 'playwright.config.ts', 'playwright.config.mjs', 'playwright.config.cjs'],
19
+ dependencies: ['@playwright/test'],
20
+ command: 'npx playwright test',
21
+ },
22
+ {
23
+ name: 'CodeceptJS',
24
+ configs: ['codecept.conf.js', 'codecept.conf.ts'],
25
+ dependencies: ['codeceptjs'],
26
+ command: 'npx codeceptjs run',
27
+ },
28
+ {
29
+ name: 'WebdriverIO',
30
+ configs: ['wdio.conf.js', 'wdio.conf.ts'],
31
+ dependencies: ['@wdio/cli'],
32
+ dependencyPrefix: '@wdio/',
33
+ command: 'npx wdio run ./wdio.conf.js',
34
+ },
35
+ {
36
+ name: 'Cypress',
37
+ configs: ['cypress.config.js', 'cypress.config.ts'],
38
+ dependencies: ['cypress'],
39
+ command: 'npx cypress run',
40
+ },
41
+ {
42
+ name: 'Jest',
43
+ configs: ['jest.config.js', 'jest.config.ts', 'jest.config.mjs', 'jest.config.cjs', 'jest.config.json'],
44
+ dependencies: ['jest'],
45
+ command: 'npx jest',
46
+ },
47
+ {
48
+ name: 'Vitest',
49
+ configs: ['vitest.config.js', 'vitest.config.ts', 'vitest.config.mjs'],
50
+ dependencies: ['vitest'],
51
+ command: 'npx vitest run',
52
+ },
53
+ {
54
+ name: 'Mocha',
55
+ configs: ['.mocharc.js', '.mocharc.cjs', '.mocharc.json', '.mocharc.yml', '.mocharc.yaml'],
56
+ dependencies: ['mocha'],
57
+ command: 'npx mocha',
58
+ },
59
+ {
60
+ name: 'Cucumber',
61
+ configs: ['cucumber.js', 'cucumber.cjs'],
62
+ dependencies: ['@cucumber/cucumber'],
63
+ command: 'npx cucumber-js',
64
+ },
65
+ ];
66
+ /**
67
+ * @param {string} command
68
+ * @returns {boolean}
69
+ */
70
+ function isPlaceholderCommand(command) {
71
+ const cmd = (command || '').trim();
72
+ if (!cmd)
73
+ return false;
74
+ if (cmd.includes('<') || cmd.includes('>'))
75
+ return true;
76
+ const firstToken = cmd.split(/\s+/)[0].toLowerCase();
77
+ if (LAUNCHER_PREFIXES.includes(firstToken))
78
+ return false;
79
+ return PLACEHOLDER_PHRASE_RE.test(cmd);
80
+ }
81
+ /**
82
+ * @param {string} cwd
83
+ * @returns {Record<string, string>}
84
+ */
85
+ function readPackageDependencies(cwd) {
86
+ try {
87
+ const pkg = JSON.parse(fs_1.default.readFileSync(path_1.default.join(cwd, 'package.json'), 'utf8'));
88
+ return { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
89
+ }
90
+ catch {
91
+ return {};
92
+ }
93
+ }
94
+ /**
95
+ * @param {string} [cwd]
96
+ * @returns {{ name: string, command: string } | null}
97
+ */
98
+ function detectRunner(cwd = process.cwd()) {
99
+ const dependencies = readPackageDependencies(cwd);
100
+ for (const runner of RUNNERS) {
101
+ const config = runner.configs.find(file => fs_1.default.existsSync(path_1.default.join(cwd, file)));
102
+ const hasDependency = runner.dependencies?.some(dep => dependencies[dep])
103
+ || (runner.dependencyPrefix && Object.keys(dependencies).some(dep => dep.startsWith(runner.dependencyPrefix)));
104
+ if (!config && !hasDependency)
105
+ continue;
106
+ let command = runner.command;
107
+ if (runner.name === 'WebdriverIO') {
108
+ command = `npx wdio run ./${config || 'wdio.conf.js'}`;
109
+ }
110
+ return { name: runner.name, command };
111
+ }
112
+ return null;
113
+ }
114
+ /**
115
+ * @param {string} [cwd]
116
+ * @returns {string}
117
+ */
118
+ function runnerHint(cwd = process.cwd()) {
119
+ const runner = detectRunner(cwd);
120
+ if (runner) {
121
+ return `💡 Looks like this project uses ${runner.name}. Try: ${picocolors_1.default.green(runner.command)}`;
122
+ }
123
+ return `💡 Replace the placeholder with your test runner command, e.g. ${picocolors_1.default.green('npx playwright test')}`;
124
+ }
125
+ /**
126
+ * @param {string} command
127
+ * @returns {string[]}
128
+ */
129
+ function buildFilterWarning(command) {
130
+ return [
131
+ '⚠️ The runner exited with a non-zero code while a Testomat.io filter was applied.',
132
+ 'Your command was modified to run only tests matching Testomat IDs:',
133
+ ` ${picocolors_1.default.bold(command)}`,
134
+ 'This usually means no local tests matched the filter:',
135
+ ' - tests are not annotated with the matching @Txxxxxx IDs in their titles',
136
+ ' - the filter selected tests from another repository or branch',
137
+ ' - the test runner does not support the appended --grep / --testNamePattern option',
138
+ '💡 Hint: run the printed command without the appended filter option to see all your tests,',
139
+ 'then annotate them with Testomat IDs.',
140
+ ];
141
+ }
142
+
143
+ module.exports.isPlaceholderCommand = isPlaceholderCommand;
144
+
145
+ module.exports.detectRunner = detectRunner;
146
+
147
+ module.exports.runnerHint = runnerHint;
148
+
149
+ module.exports.buildFilterWarning = buildFilterWarning;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.20.0",
3
+ "version": "2.21.0",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -102,7 +102,7 @@
102
102
  "jasmine": "^5.2.0",
103
103
  "jest": "^27.4.7",
104
104
  "jsdom": "^22.1.0",
105
- "mocha": "^9.2.0",
105
+ "mocha": "^12.0.3",
106
106
  "mock-http-server": "^1.4.5",
107
107
  "pino": "^8.15.0",
108
108
  "prettier": "^3.2.5",
@@ -20,7 +20,7 @@ if (!global.codeceptjs) {
20
20
  }
21
21
 
22
22
  // @ts-ignore
23
- const { event, recorder, codecept, output } = global.codeceptjs;
23
+ const { event, recorder, codecept, output, container } = global.codeceptjs;
24
24
 
25
25
  const [, MAJOR_VERSION, MINOR_VERSION] = codecept
26
26
  .version()
@@ -59,6 +59,7 @@ function CodeceptReporter(config) {
59
59
  let isRunFinalized = false;
60
60
 
61
61
  const testTimeMap = {};
62
+ const failedUrls = new Map();
62
63
  const clientConfig = buildCodeceptClientConfig(config);
63
64
  const client = new TestomatClient(clientConfig);
64
65
 
@@ -243,6 +244,22 @@ function CodeceptReporter(config) {
243
244
  }, true);
244
245
  });
245
246
 
247
+ event.dispatcher.on(event.test.failed, (test, _err, hookName) => {
248
+ // no browser in suite hooks
249
+ if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') return;
250
+ const helper = Object.values(container.helpers()).find(h => typeof h.grabCurrentUrl === 'function');
251
+ if (!helper) return;
252
+
253
+ recorder.add('url of failed test', async () => {
254
+ try {
255
+ const url = await helper.grabCurrentUrl();
256
+ if (url) failedUrls.set(test.uid, url);
257
+ } catch (err) {
258
+ debug('Failed to grab url of failed test', err?.message || err);
259
+ }
260
+ }, true);
261
+ });
262
+
246
263
  event.dispatcher.on(event.test.skipped, test => {
247
264
  const { uid, tags, title } = test.simplify();
248
265
 
@@ -274,6 +291,9 @@ function CodeceptReporter(config) {
274
291
  const keyValues = services.keyValues.get(test.fullTitle());
275
292
  const links = services.links.get(test.fullTitle());
276
293
  const screenshotOnFailPath = artifacts.screenshot || null;
294
+ const meta = { ...keyValues, ...test.meta };
295
+ if (test.state === STATUS.FAILED && failedUrls.has(uid)) meta.failedUrl = failedUrls.get(uid);
296
+ failedUrls.delete(uid);
277
297
 
278
298
  // Build step hierarchy with screenshot from screenshotOnFail
279
299
  const stepHierarchy = buildUnifiedStepHierarchy(
@@ -297,7 +317,7 @@ function CodeceptReporter(config) {
297
317
  logs,
298
318
  links,
299
319
  manuallyAttachedArtifacts,
300
- meta: { ...keyValues, ...test.meta },
320
+ meta,
301
321
  });
302
322
  reportTestPromises.push(reportTestPromise);
303
323
 
@@ -94,6 +94,8 @@ export function toTestData(result, groups, artifactsRoot) {
94
94
  if (result.repeat) meta.repeat = result.repeat;
95
95
  if (attempts.length > 1) meta.retryCount = attempts.length - 1;
96
96
  if (result.status === 'flaky') meta.isFlaky = true;
97
+ const status = testStatus(result.status);
98
+ if (status === STATUS.FAILED && final?.failure?.url) meta.failedUrl = final.failure.url;
97
99
 
98
100
  const filesById = new Map();
99
101
  for (const artifact of final?.artifacts || []) {
@@ -128,7 +130,7 @@ export function toTestData(result, groups, artifactsRoot) {
128
130
  meta,
129
131
  };
130
132
 
131
- return { status: testStatus(result.status), data };
133
+ return { status, data };
132
134
  }
133
135
 
134
136
  /** Attempts of a result; a serial member keeps its part of the group's attempts. */
@@ -140,6 +142,7 @@ function attemptsOf(result, groups) {
140
142
  return {
141
143
  status: member?.status || attempt.status,
142
144
  error: member?.error || attempt.error,
145
+ failure: member?.failure || attempt.failure,
143
146
  steps: member?.steps || [],
144
147
  appLog: member?.appLog || [],
145
148
  skip: member?.skip,
@@ -110,7 +110,11 @@ class PlaywrightReporter {
110
110
  if (result.status === 'passed') status = 'failed';
111
111
  }
112
112
 
113
- const reportTestPromise = this.client.addTestRun(checkStatus(status), {
113
+ const testStatus = checkStatus(status);
114
+ let failedUrl;
115
+ if (testStatus === Status.FAILED) failedUrl = lastNavigatedUrl(result.steps, test.parent.project().use.baseURL);
116
+
117
+ const reportTestPromise = this.client.addTestRun(testStatus, {
114
118
  rid: `${rid}-${project.name}`,
115
119
  error,
116
120
  test_id: getTestomatIdFromTestTitle(`${title} ${tags.join(' ')}`),
@@ -128,6 +132,7 @@ class PlaywrightReporter {
128
132
  isMobile: project.isMobile,
129
133
  project: project.name,
130
134
  projectDependencies: project.dependencies?.length ? project.dependencies : null,
135
+ failedUrl,
131
136
  ...testMeta,
132
137
  ...meta,
133
138
  ...project.metadata, // metadata has any type (in playwright), but we will stringify it in client.js
@@ -244,6 +249,31 @@ function isScreenshotArtifact(attachment) {
244
249
  return attachment?.contentType === 'image/png' && attachment?.name === 'screenshot';
245
250
  }
246
251
 
252
+ /**
253
+ * Last url the test navigated to; the reporter has no access to the page itself.
254
+ * @returns {string|undefined}
255
+ */
256
+ function lastNavigatedUrl(steps, baseURL) {
257
+ const url = findLastNavigation(steps);
258
+ if (!url) return;
259
+ try {
260
+ return new URL(url, baseURL).href;
261
+ } catch {
262
+ return url;
263
+ }
264
+ }
265
+
266
+ function findLastNavigation(steps = []) {
267
+ let url;
268
+ for (const step of steps) {
269
+ // 'Navigate to "url"' since Playwright 1.53, 'page.goto(url)' before
270
+ const match = step.title.match(/^Navigate to "(.+)"$/) || step.title.match(/^page\.goto\((.+)\)$/);
271
+ if (step.category === 'pw:api' && match) url = match[1];
272
+ url = findLastNavigation(step.steps) || url;
273
+ }
274
+ return url;
275
+ }
276
+
247
277
  function appendStep(step, shift = 0) {
248
278
  // nesting too deep, ignore those steps
249
279
  if (shift >= 10) return;
@@ -398,7 +398,10 @@ function normalizeVitestTest(test) {
398
398
  file,
399
399
  suiteTitle,
400
400
  logs: '',
401
- meta: typeof test.meta === 'function' ? test.meta() : {},
401
+ meta: {
402
+ ...(test.task?.meta || {}),
403
+ ...(typeof test.meta === 'function' ? test.meta() : test.meta || {}),
404
+ },
402
405
  };
403
406
  }
404
407
 
@@ -4,6 +4,9 @@ import { getTestomatIdFromTestTitle, fileSystem } from '../utils/utils.js';
4
4
  import { services } from '../services/index.js';
5
5
  import { TESTOMAT_TMP_STORAGE_DIR } from '../constants.js';
6
6
  import { stringToMD5Hash } from '../data-storage.js';
7
+ import createDebugMessages from 'debug';
8
+
9
+ const debug = createDebugMessages('@testomatio/reporter:adapter:webdriver');
7
10
 
8
11
  global.TESTOMATIO_ADAPTER = 'webdriverio';
9
12
 
@@ -122,7 +125,8 @@ class WebdriverReporter extends WDIOReporter {
122
125
  async addTest(test) {
123
126
  if (!this.client) return;
124
127
 
125
- const { title, _duration: duration, state, error, output, links, artifacts, meta, logs } = test;
128
+ const { title, _duration: duration, state, error, output, links, artifacts, logs } = test;
129
+ let { meta } = test;
126
130
 
127
131
  const testId = getTestomatIdFromTestTitle(title);
128
132
 
@@ -133,6 +137,11 @@ class WebdriverReporter extends WDIOReporter {
133
137
 
134
138
  const rid = stringToMD5Hash(test.fullTitle);
135
139
 
140
+ if (state === 'failed') {
141
+ const failedUrl = (await grabCurrentUrl()) || lastNavigatedUrl(output);
142
+ if (failedUrl) meta = { ...meta, failedUrl };
143
+ }
144
+
136
145
  await this.client.addTestRun(state, {
137
146
  rid,
138
147
  manuallyAttachedArtifacts: test.artifacts,
@@ -150,7 +159,7 @@ class WebdriverReporter extends WDIOReporter {
150
159
  /**
151
160
  * @param {import('../../types/types.js').WebdriverIOScenario} scenario
152
161
  */
153
- addBddScenario(scenario) {
162
+ async addBddScenario(scenario) {
154
163
  if (!this.client) return;
155
164
 
156
165
  const { title, _duration: duration } = scenario;
@@ -169,6 +178,12 @@ class WebdriverReporter extends WDIOReporter {
169
178
 
170
179
  const tags = scenario.tags.map(tag => tag.name);
171
180
 
181
+ let meta;
182
+ if (scenarioState === 'failed') {
183
+ const failedUrl = await grabCurrentUrl();
184
+ if (failedUrl) meta = { failedUrl };
185
+ }
186
+
172
187
  return this.client.addTestRun(scenarioState, {
173
188
  error: error ? Error(error) : null,
174
189
  title,
@@ -176,11 +191,38 @@ class WebdriverReporter extends WDIOReporter {
176
191
  time: duration,
177
192
  tags,
178
193
  file: scenario.file,
194
+ meta,
179
195
  // filesBuffers: screenshotsBuffers,
180
196
  });
181
197
  }
182
198
  }
183
199
 
200
+ /**
201
+ * Current url of the browser in this worker; reporters run in the worker process.
202
+ * @returns {Promise<string|undefined>}
203
+ */
204
+ async function grabCurrentUrl() {
205
+ // @ts-ignore
206
+ const browser = globalThis.browser;
207
+ if (typeof browser?.getUrl !== 'function') return;
208
+ try {
209
+ const url = await browser.getUrl();
210
+ // multiremote returns a url per browser
211
+ if (typeof url === 'string') return url;
212
+ } catch (err) {
213
+ debug('Failed to grab url of failed test', err?.message || err);
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Last url opened by the test, taken from its command log.
219
+ * @returns {string|undefined}
220
+ */
221
+ function lastNavigatedUrl(output = []) {
222
+ const navigations = output.filter(el => el.endpoint === '/session/:sessionId/url' && el.body?.url);
223
+ return navigations.at(-1)?.body.url;
224
+ }
225
+
184
226
  /**
185
227
  *
186
228
  * @param {*} fullTestTitle