@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.
@@ -23,7 +23,7 @@ if (!global.codeceptjs) {
23
23
  global.codeceptjs = codeceptjs_1.default;
24
24
  }
25
25
  // @ts-ignore
26
- const { event, recorder, codecept, output } = global.codeceptjs;
26
+ const { event, recorder, codecept, output, container } = global.codeceptjs;
27
27
  const [, MAJOR_VERSION, MINOR_VERSION] = codecept
28
28
  .version()
29
29
  .match(/(\d+)\.(\d+)/)
@@ -52,6 +52,7 @@ function CodeceptReporter(config) {
52
52
  const reportTestPromises = [];
53
53
  let isRunFinalized = false;
54
54
  const testTimeMap = {};
55
+ const failedUrls = new Map();
55
56
  const clientConfig = buildCodeceptClientConfig(config);
56
57
  const client = new client_js_1.default(clientConfig);
57
58
  // Store original output methods for fallback
@@ -212,6 +213,24 @@ function CodeceptReporter(config) {
212
213
  await finalizeRun('all.after');
213
214
  }, true);
214
215
  });
216
+ event.dispatcher.on(event.test.failed, (test, _err, hookName) => {
217
+ // no browser in suite hooks
218
+ if (hookName === 'BeforeSuite' || hookName === 'AfterSuite')
219
+ return;
220
+ const helper = Object.values(container.helpers()).find(h => typeof h.grabCurrentUrl === 'function');
221
+ if (!helper)
222
+ return;
223
+ recorder.add('url of failed test', async () => {
224
+ try {
225
+ const url = await helper.grabCurrentUrl();
226
+ if (url)
227
+ failedUrls.set(test.uid, url);
228
+ }
229
+ catch (err) {
230
+ debug('Failed to grab url of failed test', err?.message || err);
231
+ }
232
+ }, true);
233
+ });
215
234
  event.dispatcher.on(event.test.skipped, test => {
216
235
  const { uid, tags, title } = test.simplify();
217
236
  if (uid && reportedTestUids.has(uid))
@@ -240,6 +259,10 @@ function CodeceptReporter(config) {
240
259
  const keyValues = index_js_1.services.keyValues.get(test.fullTitle());
241
260
  const links = index_js_1.services.links.get(test.fullTitle());
242
261
  const screenshotOnFailPath = artifacts.screenshot || null;
262
+ const meta = { ...keyValues, ...test.meta };
263
+ if (test.state === constants_js_1.STATUS.FAILED && failedUrls.has(uid))
264
+ meta.failedUrl = failedUrls.get(uid);
265
+ failedUrls.delete(uid);
243
266
  // Build step hierarchy with screenshot from screenshotOnFail
244
267
  const stepHierarchy = buildUnifiedStepHierarchy(test.steps, hookSteps, screenshotOnFailPath);
245
268
  index_js_1.services.setContext(null);
@@ -256,7 +279,7 @@ function CodeceptReporter(config) {
256
279
  logs,
257
280
  links,
258
281
  manuallyAttachedArtifacts,
259
- meta: { ...keyValues, ...test.meta },
282
+ meta,
260
283
  });
261
284
  reportTestPromises.push(reportTestPromise);
262
285
  processArtifactsForUpload(artifacts, uid, title, videos, traces);
@@ -98,6 +98,9 @@ function toTestData(result, groups, artifactsRoot) {
98
98
  meta.retryCount = attempts.length - 1;
99
99
  if (result.status === 'flaky')
100
100
  meta.isFlaky = true;
101
+ const status = testStatus(result.status);
102
+ if (status === constants_js_1.STATUS.FAILED && final?.failure?.url)
103
+ meta.failedUrl = final.failure.url;
101
104
  const filesById = new Map();
102
105
  for (const artifact of final?.artifacts || []) {
103
106
  if (artifact.path) {
@@ -129,7 +132,7 @@ function toTestData(result, groups, artifactsRoot) {
129
132
  files: files.length ? files : undefined,
130
133
  meta,
131
134
  };
132
- return { status: testStatus(result.status), data };
135
+ return { status, data };
133
136
  }
134
137
  /** Attempts of a result; a serial member keeps its part of the group's attempts. */
135
138
  function attemptsOf(result, groups) {
@@ -141,6 +144,7 @@ function attemptsOf(result, groups) {
141
144
  return {
142
145
  status: member?.status || attempt.status,
143
146
  error: member?.error || attempt.error,
147
+ failure: member?.failure || attempt.failure,
144
148
  steps: member?.steps || [],
145
149
  appLog: member?.appLog || [],
146
150
  skip: member?.skip,
@@ -104,7 +104,11 @@ class PlaywrightReporter {
104
104
  if (result.status === 'passed')
105
105
  status = 'failed';
106
106
  }
107
- const reportTestPromise = this.client.addTestRun(checkStatus(status), {
107
+ const testStatus = checkStatus(status);
108
+ let failedUrl;
109
+ if (testStatus === constants_js_1.STATUS.FAILED)
110
+ failedUrl = lastNavigatedUrl(result.steps, test.parent.project().use.baseURL);
111
+ const reportTestPromise = this.client.addTestRun(testStatus, {
108
112
  rid: `${rid}-${project.name}`,
109
113
  error,
110
114
  test_id: (0, utils_js_1.getTestomatIdFromTestTitle)(`${title} ${tags.join(' ')}`),
@@ -122,6 +126,7 @@ class PlaywrightReporter {
122
126
  isMobile: project.isMobile,
123
127
  project: project.name,
124
128
  projectDependencies: project.dependencies?.length ? project.dependencies : null,
129
+ failedUrl,
125
130
  ...testMeta,
126
131
  ...meta,
127
132
  ...project.metadata, // metadata has any type (in playwright), but we will stringify it in client.js
@@ -222,6 +227,32 @@ function processArtifactsForUpload(attachments) {
222
227
  function isScreenshotArtifact(attachment) {
223
228
  return attachment?.contentType === 'image/png' && attachment?.name === 'screenshot';
224
229
  }
230
+ /**
231
+ * Last url the test navigated to; the reporter has no access to the page itself.
232
+ * @returns {string|undefined}
233
+ */
234
+ function lastNavigatedUrl(steps, baseURL) {
235
+ const url = findLastNavigation(steps);
236
+ if (!url)
237
+ return;
238
+ try {
239
+ return new URL(url, baseURL).href;
240
+ }
241
+ catch {
242
+ return url;
243
+ }
244
+ }
245
+ function findLastNavigation(steps = []) {
246
+ let url;
247
+ for (const step of steps) {
248
+ // 'Navigate to "url"' since Playwright 1.53, 'page.goto(url)' before
249
+ const match = step.title.match(/^Navigate to "(.+)"$/) || step.title.match(/^page\.goto\((.+)\)$/);
250
+ if (step.category === 'pw:api' && match)
251
+ url = match[1];
252
+ url = findLastNavigation(step.steps) || url;
253
+ }
254
+ return url;
255
+ }
225
256
  function appendStep(step, shift = 0) {
226
257
  // nesting too deep, ignore those steps
227
258
  if (shift >= 10)
@@ -394,7 +394,10 @@ function normalizeVitestTest(test) {
394
394
  file,
395
395
  suiteTitle,
396
396
  logs: '',
397
- meta: typeof test.meta === 'function' ? test.meta() : {},
397
+ meta: {
398
+ ...(test.task?.meta || {}),
399
+ ...(typeof test.meta === 'function' ? test.meta() : test.meta || {}),
400
+ },
398
401
  };
399
402
  }
400
403
  return {
@@ -42,6 +42,8 @@ const utils_js_1 = require("../utils/utils.js");
42
42
  const index_js_1 = require("../services/index.js");
43
43
  const constants_js_1 = require("../constants.js");
44
44
  const data_storage_js_1 = require("../data-storage.js");
45
+ const debug_1 = __importDefault(require("debug"));
46
+ const debug = (0, debug_1.default)('@testomatio/reporter:adapter:webdriver');
45
47
  global.TESTOMATIO_ADAPTER = 'webdriverio';
46
48
  class WebdriverReporter extends reporter_1.default {
47
49
  constructor(options) {
@@ -130,13 +132,19 @@ class WebdriverReporter extends reporter_1.default {
130
132
  async addTest(test) {
131
133
  if (!this.client)
132
134
  return;
133
- const { title, _duration: duration, state, error, output, links, artifacts, meta, logs } = test;
135
+ const { title, _duration: duration, state, error, output, links, artifacts, logs } = test;
136
+ let { meta } = test;
134
137
  const testId = (0, utils_js_1.getTestomatIdFromTestTitle)(title);
135
138
  const screenshotEndpoint = '/session/:sessionId/screenshot';
136
139
  const screenshotsBuffers = output
137
140
  .filter(el => el.endpoint === screenshotEndpoint && el.result && el.result.value)
138
141
  .map(el => Buffer.from(el.result.value, 'base64'));
139
142
  const rid = (0, data_storage_js_1.stringToMD5Hash)(test.fullTitle);
143
+ if (state === 'failed') {
144
+ const failedUrl = (await grabCurrentUrl()) || lastNavigatedUrl(output);
145
+ if (failedUrl)
146
+ meta = { ...meta, failedUrl };
147
+ }
140
148
  await this.client.addTestRun(state, {
141
149
  rid,
142
150
  manuallyAttachedArtifacts: test.artifacts,
@@ -153,7 +161,7 @@ class WebdriverReporter extends reporter_1.default {
153
161
  /**
154
162
  * @param {import('../../types/types.js').WebdriverIOScenario} scenario
155
163
  */
156
- addBddScenario(scenario) {
164
+ async addBddScenario(scenario) {
157
165
  if (!this.client)
158
166
  return;
159
167
  const { title, _duration: duration } = scenario;
@@ -168,6 +176,12 @@ class WebdriverReporter extends reporter_1.default {
168
176
  .filter(Boolean);
169
177
  const error = errors.join('\n');
170
178
  const tags = scenario.tags.map(tag => tag.name);
179
+ let meta;
180
+ if (scenarioState === 'failed') {
181
+ const failedUrl = await grabCurrentUrl();
182
+ if (failedUrl)
183
+ meta = { failedUrl };
184
+ }
171
185
  return this.client.addTestRun(scenarioState, {
172
186
  error: error ? Error(error) : null,
173
187
  title,
@@ -175,10 +189,38 @@ class WebdriverReporter extends reporter_1.default {
175
189
  time: duration,
176
190
  tags,
177
191
  file: scenario.file,
192
+ meta,
178
193
  // filesBuffers: screenshotsBuffers,
179
194
  });
180
195
  }
181
196
  }
197
+ /**
198
+ * Current url of the browser in this worker; reporters run in the worker process.
199
+ * @returns {Promise<string|undefined>}
200
+ */
201
+ async function grabCurrentUrl() {
202
+ // @ts-ignore
203
+ const browser = globalThis.browser;
204
+ if (typeof browser?.getUrl !== 'function')
205
+ return;
206
+ try {
207
+ const url = await browser.getUrl();
208
+ // multiremote returns a url per browser
209
+ if (typeof url === 'string')
210
+ return url;
211
+ }
212
+ catch (err) {
213
+ debug('Failed to grab url of failed test', err?.message || err);
214
+ }
215
+ }
216
+ /**
217
+ * Last url opened by the test, taken from its command log.
218
+ * @returns {string|undefined}
219
+ */
220
+ function lastNavigatedUrl(output = []) {
221
+ const navigations = output.filter(el => el.endpoint === '/session/:sessionId/url' && el.body?.url);
222
+ return navigations.at(-1)?.body.url;
223
+ }
182
224
  /**
183
225
  *
184
226
  * @param {*} fullTestTitle
package/lib/bin/cli.js CHANGED
@@ -13,6 +13,7 @@ const xmlReader_js_1 = __importDefault(require("../xmlReader.js"));
13
13
  const allureReader_js_1 = __importDefault(require("../allureReader.js"));
14
14
  const constants_js_1 = require("../constants.js");
15
15
  const utils_js_1 = require("../utils/utils.js");
16
+ const detectRunner_js_1 = require("../utils/detectRunner.js");
16
17
  const config_js_1 = require("../config.js");
17
18
  const utils_js_2 = require("../utils/utils.js");
18
19
  const picocolors_1 = __importDefault(require("picocolors"));
@@ -23,6 +24,8 @@ const log_js_1 = require("../utils/log.js");
23
24
  const pipe_utils_js_1 = require("../utils/pipe_utils.js");
24
25
  const fs_1 = __importDefault(require("fs"));
25
26
  const path_1 = __importDefault(require("path"));
27
+ const testruns_js_1 = require("../fetch/testruns.js");
28
+ const report_js_1 = require("../fetch/report.js");
26
29
  const debug = (0, debug_1.default)('@testomatio/reporter:cli');
27
30
  const version = (0, utils_js_1.getPackageVersion)();
28
31
  const program = new commander_1.Command();
@@ -43,8 +46,13 @@ program
43
46
  // remaining output to stderr, skip the banner, and silence info-level logs so
44
47
  // stdout stays clean for capture (e.g. RUN_ID=$(... start --format id)).
45
48
  // Set TESTOMATIO_LOG_LEVEL=INFO to re-enable progress logs for debugging.
46
- const subOpts = actionCommand.opts();
47
- if (subOpts.filterList || subOpts.format) {
49
+ const subOpts = actionCommand.optsWithGlobals();
50
+ // fetch status|count|report print their result on stdout, so the rest goes to stderr too
51
+ const isFetchSubcommand = actionCommand.parent?.name() === 'fetch';
52
+ if (isFetchSubcommand && !subOpts.format) {
53
+ process.env.TESTOMATIO_LOG_STDERR = '1';
54
+ }
55
+ else if (subOpts.filterList || subOpts.format) {
48
56
  process.env.TESTOMATIO_LOG_STDERR = '1';
49
57
  process.env.TESTOMATIO_LOG_LEVEL ||= 'WARN';
50
58
  // with --format json the logs are machine-readable too: one JSON object per line on stderr
@@ -61,9 +69,10 @@ program
61
69
  .option('--kind <type>', 'Specify run type: automated, manual, mixed, or detect')
62
70
  .option('--filter <filter>', 'Scope the prepared run to tests matching the filter (no execution)')
63
71
  .option('--format <format>', 'Machine-readable output: the run id (--format id) or run details (--format json)')
64
- .option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only)')
72
+ .option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only); env: TESTOMATIO_WARN')
65
73
  .action(async (opts) => {
66
74
  (0, utils_js_1.cleanLatestRunId)();
75
+ opts.warn ||= (0, utils_js_1.transformEnvVarToBoolean)(process.env.TESTOMATIO_WARN);
67
76
  log_js_1.log.info('Starting a new Run on Testomat.io...');
68
77
  const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config_js_1.config.TESTOMATIO;
69
78
  const client = new client_js_1.default({ apiKey });
@@ -118,7 +127,7 @@ program
118
127
  process.exit(0);
119
128
  });
120
129
  });
121
- program
130
+ const fetchCommand = program
122
131
  .command('fetch')
123
132
  .description('Fetch runs from Testomat.io API v2')
124
133
  .argument('[url]', 'URL of a run or of a (filtered) runs page in Testomat.io')
@@ -127,7 +136,8 @@ program
127
136
  .option('--title <text>', 'Filter by run title')
128
137
  .option('--tql <query>', 'Filter using Testomat Query Language')
129
138
  .option('--rungroup <uid>', 'Filter by rungroup id')
130
- .option('--limit <number>', `Max number of runs to return (max ${FETCH_RUNS_MAX_LIMIT})`, String(FETCH_RUNS_DEFAULT_LIMIT))
139
+ // no default value: fetch parses --limit for its subcommands too, see createRunFetcher
140
+ .option('--limit <number>', `Max number of runs to return (default ${FETCH_RUNS_DEFAULT_LIMIT}, max ${FETCH_RUNS_MAX_LIMIT})`)
131
141
  .option('--latest', 'Only fetch the most recent run (shorthand for --limit 1)')
132
142
  .option('--format <format>', 'Machine-readable output: run ids, one per line (--format id) or run details (--format json)')
133
143
  .action(async (pageUrl, opts) => {
@@ -177,6 +187,141 @@ program
177
187
  }
178
188
  console.log((0, pipe_utils_js_1.formatFetchRunsOutput)(body, opts.format));
179
189
  });
190
+ // fetch parses the options it shares with its subcommands (--project, --format, --limit) wherever they are placed,
191
+ // so subcommands declare them only for help and read them with optsWithGlobals()
192
+ /**
193
+ * Build a RunFetcher for the run argument of fetch status|count|report, or exit with an error.
194
+ *
195
+ * @param {string} runArg
196
+ * @param {{project?: string}} opts
197
+ * @returns {RunFetcher}
198
+ */
199
+ function createRunFetcher(runArg, opts) {
200
+ const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config_js_1.config.TESTOMATIO;
201
+ if (!apiKey) {
202
+ log_js_1.log.error(picocolors_1.default.red('⚠️ TESTOMATIO API key required'));
203
+ process.exit(1);
204
+ }
205
+ try {
206
+ return new testruns_js_1.RunFetcher({ ...(0, testruns_js_1.resolveRunTarget)(runArg, opts), apiKey });
207
+ }
208
+ catch (err) {
209
+ log_js_1.log.error(picocolors_1.default.red(`⚠️ ${err.message}`));
210
+ process.exit(1);
211
+ }
212
+ }
213
+ /**
214
+ * @param {{only?: string, onlyFailed?: boolean}} opts
215
+ * @returns {string[]}
216
+ */
217
+ function getStatusFilter(opts) {
218
+ try {
219
+ return (0, testruns_js_1.parseStatuses)(opts);
220
+ }
221
+ catch (err) {
222
+ log_js_1.log.error(picocolors_1.default.red(`⚠️ ${err.message}`));
223
+ process.exit(1);
224
+ }
225
+ }
226
+ /**
227
+ * @param {Error} err
228
+ * @returns {never}
229
+ */
230
+ function exitOnFetchError(err) {
231
+ log_js_1.log.error(picocolors_1.default.red(`⚠️ Failed to fetch: ${err.message}`));
232
+ process.exit(1);
233
+ }
234
+ const RUN_ARGUMENT_DESCRIPTION = 'Run id, or URL of a run in Testomat.io';
235
+ const STATUS_FILTER_DESCRIPTION = `Only testruns with these statuses, comma-separated (${testruns_js_1.TESTRUN_STATUSES.join(', ')})`;
236
+ fetchCommand
237
+ .command('status')
238
+ .description('Show a run with the number of its testruns by status')
239
+ .argument('<run>', RUN_ARGUMENT_DESCRIPTION)
240
+ .option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
241
+ .option('--format <format>', 'Machine-readable output: run status and counts as JSON (--format json)')
242
+ .action(async (runArg, _opts, command) => {
243
+ const opts = command.optsWithGlobals();
244
+ const fetcher = createRunFetcher(runArg, opts);
245
+ const [run, summary] = await Promise.all([fetcher.fetchRun(), fetcher.countTestrunsByStatus()]).catch(exitOnFetchError);
246
+ const counts = Object.fromEntries(testruns_js_1.TESTRUN_STATUSES.map(status => [status, summary.byStatus[status] || 0]));
247
+ if (opts.format === 'json') {
248
+ const data = { id: run.id, title: run.title, status: run.status, url: fetcher.runUrl, total: summary.total };
249
+ console.log(JSON.stringify({ ...data, ...counts }, null, 2));
250
+ return;
251
+ }
252
+ const lines = [`run: ${run.title}`, `status: ${run.status}`, `url: ${fetcher.runUrl}`, `total: ${summary.total}`];
253
+ for (const [status, count] of Object.entries(counts))
254
+ lines.push(`${status}: ${count}`);
255
+ console.log(lines.join('\n'));
256
+ });
257
+ fetchCommand
258
+ .command('count')
259
+ .description('Print the number of testruns of a run, to pick --limit and filters for fetch report')
260
+ .argument('<run>', RUN_ARGUMENT_DESCRIPTION)
261
+ .option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
262
+ .option('--only <statuses>', STATUS_FILTER_DESCRIPTION)
263
+ .option('--only-failed', 'Only failed testruns (shorthand for --only failed)')
264
+ .action(async (runArg, _opts, command) => {
265
+ const opts = command.optsWithGlobals();
266
+ const fetcher = createRunFetcher(runArg, opts);
267
+ const statuses = getStatusFilter(opts);
268
+ const total = await fetcher.countTestruns(statuses).catch(exitOnFetchError);
269
+ console.log(total);
270
+ });
271
+ fetchCommand
272
+ .command('report')
273
+ .description('Print a run with its testruns as a Markdown report')
274
+ .argument('<run>', RUN_ARGUMENT_DESCRIPTION)
275
+ .option('--project <slug>', 'Project slug (or set TESTOMATIO_PROJECT)')
276
+ .option('--only <statuses>', STATUS_FILTER_DESCRIPTION)
277
+ .option('--only-failed', 'Only failed testruns (shorthand for --only failed)')
278
+ .option('--stack', 'Include stack traces')
279
+ .option('--artifacts', 'Download artifacts to --out and link them from the report')
280
+ .option('--out <dir>', 'Directory for downloaded artifacts (default: ./<run-title>-<run-id>)')
281
+ .option('--limit <number>', `Max number of testruns, failed first (default ${testruns_js_1.TESTRUNS_DEFAULT_LIMIT}, max ${testruns_js_1.TESTRUNS_MAX_LIMIT})`)
282
+ .action(async (runArg, _opts, command) => {
283
+ const opts = command.optsWithGlobals();
284
+ const fetcher = createRunFetcher(runArg, opts);
285
+ const statuses = getStatusFilter(opts);
286
+ const { limit, capped } = (0, testruns_js_1.parseTestrunsLimit)(opts.limit);
287
+ if (capped)
288
+ log_js_1.log.warn(picocolors_1.default.yellow(`⚠️ --limit is capped at ${testruns_js_1.TESTRUNS_MAX_LIMIT}`));
289
+ const run = await fetcher.fetchRun().catch(exitOnFetchError);
290
+ if (opts.artifacts) {
291
+ const hasSharedCredentials = await fetcher.applySharedS3Credentials(run);
292
+ if (!hasSharedCredentials && !process.env.S3_BUCKET) {
293
+ log_js_1.log.error(picocolors_1.default.red('⚠️ --artifacts needs S3 access: share S3 credentials in project settings or set S3_BUCKET'));
294
+ process.exit(1);
295
+ }
296
+ }
297
+ const [summary, list] = await Promise.all([
298
+ fetcher.countTestrunsByStatus(),
299
+ fetcher.listTestruns({ statuses, limit }),
300
+ ]).catch(exitOnFetchError);
301
+ let { testruns } = list;
302
+ if (list.total > testruns.length) {
303
+ log_js_1.log.warn(picocolors_1.default.yellow(`⚠️ Showing ${testruns.length} of ${list.total} testruns. Use --limit or --only-failed to narrow it.`));
304
+ }
305
+ if (opts.stack || opts.artifacts) {
306
+ testruns = await fetcher.addTestrunDetails(testruns, (testrun, err) => {
307
+ log_js_1.log.warn(picocolors_1.default.yellow(`⚠️ Failed to fetch details of testrun ${testrun.id}: ${err.message}`));
308
+ });
309
+ if (!opts.stack)
310
+ testruns = testruns.map(({ stack, ...testrun }) => testrun);
311
+ }
312
+ if (opts.artifacts) {
313
+ const outDir = opts.out || (0, testruns_js_1.defaultArtifactsDir)(run, fetcher.runId);
314
+ const artifactsCount = testruns.reduce((sum, testrun) => sum + (testrun.artifacts?.length || 0), 0);
315
+ log_js_1.log.info(`Downloading ${artifactsCount} artifacts to ${outDir}`);
316
+ testruns = await (0, testruns_js_1.downloadArtifacts)(testruns, outDir, (url, err) => {
317
+ log_js_1.log.warn(picocolors_1.default.yellow(`⚠️ Failed to download ${url}: ${err.message}`));
318
+ });
319
+ }
320
+ else {
321
+ testruns = testruns.map(({ artifacts, ...testrun }) => testrun);
322
+ }
323
+ process.stdout.write((0, report_js_1.renderRunReport)({ run, runUrl: fetcher.runUrl, summary, testruns, matched: list.total, statuses }));
324
+ });
180
325
  program
181
326
  .command('run')
182
327
  .alias('test')
@@ -188,8 +333,9 @@ program
188
333
  .option('--kind <type>', 'Specify run type: automated, manual, mixed, or detect')
189
334
  .option('--remote <profile>', 'Trigger run on the named Testomat.io CI profile instead of executing locally')
190
335
  .option('--remote-param <kv>', 'key=value pair forwarded to the CI profile config (repeat for multiple)', (value, prev) => prev.concat([value]), [])
191
- .option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only)')
336
+ .option('--warn', 'Exit 0 instead of 1 when the filter matches no tests (warn only); env: TESTOMATIO_WARN')
192
337
  .action(async (command, opts) => {
338
+ opts.warn ||= (0, utils_js_1.transformEnvVarToBoolean)(process.env.TESTOMATIO_WARN);
193
339
  if (opts.remote) {
194
340
  if (opts.filterList) {
195
341
  log_js_1.log.warn(picocolors_1.default.red('⚠️ --filter-list cannot be combined with --remote'));
@@ -203,6 +349,7 @@ program
203
349
  const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config_js_1.config.TESTOMATIO;
204
350
  const title = process.env.TESTOMATIO_TITLE;
205
351
  const client = new client_js_1.default({ apiKey, title });
352
+ let filterApplied = false;
206
353
  if (opts.filter || opts.filterList) {
207
354
  log_js_1.log.info('Filtering tests...');
208
355
  // Example of use: npx @testomatio/reporter run "npx jest" --filter "testomatio:tag-name=frontend"
@@ -237,6 +384,7 @@ program
237
384
  }
238
385
  if (command && command.split && !opts.remote) {
239
386
  command = (0, utils_js_1.applyFilter)(command, tests);
387
+ filterApplied = true;
240
388
  }
241
389
  }
242
390
  catch (err) {
@@ -310,6 +458,13 @@ program
310
458
  }
311
459
  return process.exit(0);
312
460
  }
461
+ if ((0, detectRunner_js_1.isPlaceholderCommand)(command)) {
462
+ log_js_1.log.error(picocolors_1.default.red(`⚠️ The command "${command}" looks like a placeholder.`));
463
+ log_js_1.log.error('The example command from Testomat.io must be edited:');
464
+ log_js_1.log.error('replace the placeholder with your actual test runner command.');
465
+ log_js_1.log.error((0, detectRunner_js_1.runnerHint)());
466
+ process.exit(1);
467
+ }
313
468
  log_js_1.log.info(`🚀 Running`, picocolors_1.default.green(command));
314
469
  const runTests = async () => {
315
470
  const testCmds = command.split(' ');
@@ -317,9 +472,28 @@ program
317
472
  stdio: 'inherit',
318
473
  env: { ...process.env, TESTOMATIO_PROCEED: 'true', runId: client.runId, TESTOMATIO_RUN: client.runId },
319
474
  });
475
+ let errored = false;
476
+ cmd.on('error', err => {
477
+ errored = true;
478
+ const spawnError = /** @type {NodeJS.ErrnoException} */ (err);
479
+ if (spawnError.code === 'ENOENT') {
480
+ log_js_1.log.error(picocolors_1.default.red(`🔴 Command not found: ${picocolors_1.default.bold(testCmds[0])}`));
481
+ log_js_1.log.error((0, detectRunner_js_1.runnerHint)());
482
+ }
483
+ else {
484
+ log_js_1.log.error(picocolors_1.default.red(`🔴 Failed to start command: ${spawnError.message}`));
485
+ }
486
+ const finish = apiKey ? client.updateRunStatus('failed').catch(() => { }) : Promise.resolve();
487
+ finish.then(() => process.exit(1));
488
+ });
320
489
  cmd.on('close', async (code) => {
490
+ if (errored)
491
+ return;
321
492
  const emoji = code === 0 ? '🟢' : '🔴';
322
493
  log_js_1.log.info(emoji, `Runner exited with ${picocolors_1.default.bold(code)}`);
494
+ if (code !== 0 && filterApplied) {
495
+ (0, detectRunner_js_1.buildFilterWarning)(command).forEach(line => log_js_1.log.warn(line));
496
+ }
323
497
  if (apiKey) {
324
498
  const status = code === 0 ? 'passed' : 'failed';
325
499
  await client.updateRunStatus(status);
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Render a fetched run with its testruns as a Markdown report.
3
+ *
4
+ * @param {object} params
5
+ * @param {object} params.run - Run from GET /api/v2/:project/runs/:id
6
+ * @param {string} params.runUrl
7
+ * @param {{total: number, byStatus: Record<string, number>}} params.summary - Counts of all testruns of the run
8
+ * @param {object[]} params.testruns
9
+ * @param {number} params.matched - Number of testruns matching the filter
10
+ * @param {string[]} params.statuses - Status filter, empty for all testruns
11
+ * @returns {string}
12
+ */
13
+ export function renderRunReport({ run, runUrl, summary, testruns, matched, statuses }: {
14
+ run: object;
15
+ runUrl: string;
16
+ summary: {
17
+ total: number;
18
+ byStatus: Record<string, number>;
19
+ };
20
+ testruns: object[];
21
+ matched: number;
22
+ statuses: string[];
23
+ }): string;
24
+ /**
25
+ * Map a testrun of API v2 to the test shape of the Markdown pipe.
26
+ * Without a suite, the API returns the title as "<suite> ⇒ <title>".
27
+ *
28
+ * @param {object} testrun
29
+ * @returns {object}
30
+ */
31
+ export function toReportTest(testrun: object): object;
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderRunReport = renderRunReport;
4
+ exports.toReportTest = toReportTest;
5
+ const markdown_js_1 = require("../pipe/markdown.js");
6
+ const TITLE_SEPARATOR = ' ⇒ ';
7
+ /**
8
+ * Map a testrun of API v2 to the test shape of the Markdown pipe.
9
+ * Without a suite, the API returns the title as "<suite> ⇒ <title>".
10
+ *
11
+ * @param {object} testrun
12
+ * @returns {object}
13
+ */
14
+ function toReportTest(testrun) {
15
+ let title = String(testrun.test_title || '').trim();
16
+ let suite = testrun.suiteTitle;
17
+ if (!suite && title.includes(TITLE_SEPARATOR)) {
18
+ const [suitePart, ...titleParts] = title.split(TITLE_SEPARATOR);
19
+ suite = suitePart.trim();
20
+ title = titleParts.join(TITLE_SEPARATOR).trim();
21
+ }
22
+ return {
23
+ title,
24
+ suite_title: suite,
25
+ status: testrun.status,
26
+ run_time: testrun.run_time,
27
+ test_id: testrun.testId,
28
+ message: testrun.message,
29
+ stack: testrun.stack,
30
+ artifacts: testrun.artifacts,
31
+ };
32
+ }
33
+ /**
34
+ * Render a fetched run with its testruns as a Markdown report.
35
+ *
36
+ * @param {object} params
37
+ * @param {object} params.run - Run from GET /api/v2/:project/runs/:id
38
+ * @param {string} params.runUrl
39
+ * @param {{total: number, byStatus: Record<string, number>}} params.summary - Counts of all testruns of the run
40
+ * @param {object[]} params.testruns
41
+ * @param {number} params.matched - Number of testruns matching the filter
42
+ * @param {string[]} params.statuses - Status filter, empty for all testruns
43
+ * @returns {string}
44
+ */
45
+ function renderRunReport({ run, runUrl, summary, testruns, matched, statuses }) {
46
+ const status = String(run.status || 'unknown');
47
+ const sections = [`# ${run.title || run.id} — ${status.toUpperCase()}`];
48
+ const rows = [
49
+ ['Run ID', `\`${run.id}\``],
50
+ ['Run URL', `<${runUrl}>`],
51
+ ['Status', status],
52
+ ];
53
+ if (run.env)
54
+ rows.push(['Environment', run.env]);
55
+ if (run.launched_at)
56
+ rows.push(['Started', run.launched_at]);
57
+ if (run.finished_at)
58
+ rows.push(['Finished', run.finished_at]);
59
+ let shown = `${testruns.length} of ${matched} shown`;
60
+ if (statuses.length)
61
+ shown += ` (only: ${statuses.join(', ')})`;
62
+ rows.push(['Testruns', shown]);
63
+ sections.push(['| Key | Value |', '| --- | ----- |', ...rows.map(([key, value]) => `| ${key} | ${value} |`)].join('\n'));
64
+ if (run.description?.trim())
65
+ sections.push(`## Description\n\n${run.description.trim()}`);
66
+ const counts = Object.entries(summary.byStatus).sort(([, a], [, b]) => b - a);
67
+ sections.push([
68
+ '## Summary',
69
+ '',
70
+ '| Status | Count |',
71
+ '| ------ | ----- |',
72
+ ...counts.map(([name, count]) => `| ${name} | ${count} |`),
73
+ `| total | ${summary.total} |`,
74
+ ].join('\n'));
75
+ sections.push((0, markdown_js_1.renderTests)(testruns.map(toReportTest)));
76
+ return `${sections.join('\n\n')}\n`;
77
+ }
78
+
79
+ module.exports.renderRunReport = renderRunReport;
80
+
81
+ module.exports.toReportTest = toReportTest;