@qualflare/cucumberjs 0.1.0 → 0.3.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.
@@ -35,8 +35,22 @@ __export(formatter_exports, {
35
35
  module.exports = __toCommonJS(formatter_exports);
36
36
 
37
37
  // src/formatter/formatter.ts
38
+ var import_node_crypto3 = require("crypto");
39
+ var fs3 = __toESM(require("fs"), 1);
40
+ var path4 = __toESM(require("path"), 1);
38
41
  var import_cucumber = require("@cucumber/cucumber");
39
42
 
43
+ // src/config/resolve-config.ts
44
+ var import_node_crypto = require("crypto");
45
+
46
+ // src/shared/constants.ts
47
+ var RESERVED_MESSAGE_MEDIA_TYPE = "application/vnd.qualflare.message+json";
48
+ var MAX_SUITES_PER_LAUNCH = 2e3;
49
+ var MAX_CASES_PER_SUITE = 5e3;
50
+ var MAX_TAGS_PER_CASE = 64;
51
+ var MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;
52
+ var MAX_STEPS_PER_TEST_ATTEMPT = 300;
53
+
40
54
  // src/config/ci-detect.ts
41
55
  var ciInfo = __toESM(require("ci-info"), 1);
42
56
  function parsePositiveInt(raw) {
@@ -54,6 +68,7 @@ var PROVIDERS = [
54
68
  detect: (env) => env.GITHUB_ACTIONS === "true",
55
69
  providerName: "GitHub Actions",
56
70
  buildNumber: (env) => nonEmpty(env.GITHUB_RUN_NUMBER),
71
+ runId: (env) => nonEmpty(env.GITHUB_RUN_ID),
57
72
  runUrl: (env) => env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : void 0,
58
73
  prNumber: (env) => {
59
74
  const match = /^refs\/pull\/(\d+)\/merge$/.exec(env.GITHUB_REF ?? "");
@@ -64,6 +79,7 @@ var PROVIDERS = [
64
79
  detect: (env) => env.GITLAB_CI === "true",
65
80
  providerName: "GitLab CI",
66
81
  buildNumber: (env) => nonEmpty(env.CI_PIPELINE_IID),
82
+ runId: (env) => nonEmpty(env.CI_PIPELINE_ID),
67
83
  runUrl: (env) => nonEmpty(env.CI_PIPELINE_URL),
68
84
  prNumber: (env) => parsePositiveInt(env.CI_MERGE_REQUEST_IID)
69
85
  },
@@ -71,6 +87,7 @@ var PROVIDERS = [
71
87
  detect: (env) => env.CIRCLECI === "true",
72
88
  providerName: "CircleCI",
73
89
  buildNumber: (env) => nonEmpty(env.CIRCLE_BUILD_NUM),
90
+ runId: (env) => nonEmpty(env.CIRCLE_WORKFLOW_ID ?? env.CIRCLE_BUILD_NUM),
74
91
  runUrl: (env) => nonEmpty(env.CIRCLE_BUILD_URL),
75
92
  prNumber: (env) => parsePositiveInt(env.CIRCLE_PR_NUMBER)
76
93
  },
@@ -78,6 +95,7 @@ var PROVIDERS = [
78
95
  detect: (env) => env.BUILDKITE === "true",
79
96
  providerName: "Buildkite",
80
97
  buildNumber: (env) => nonEmpty(env.BUILDKITE_BUILD_NUMBER),
98
+ runId: (env) => nonEmpty(env.BUILDKITE_BUILD_ID),
81
99
  runUrl: (env) => nonEmpty(env.BUILDKITE_BUILD_URL),
82
100
  prNumber: (env) => {
83
101
  const raw = env.BUILDKITE_PULL_REQUEST;
@@ -93,6 +111,7 @@ var PROVIDERS = [
93
111
  detect: (env) => Boolean(env.JENKINS_URL),
94
112
  providerName: "Jenkins",
95
113
  buildNumber: (env) => nonEmpty(env.BUILD_NUMBER),
114
+ runId: (env) => nonEmpty(env.BUILD_TAG ?? env.BUILD_NUMBER),
96
115
  runUrl: (env) => nonEmpty(env.BUILD_URL)
97
116
  // Jenkins has no standardized PR-number env var across its many PR
98
117
  // plugins (Multibranch, GitHub Branch Source, etc.) — deliberately
@@ -102,6 +121,7 @@ var PROVIDERS = [
102
121
  detect: (env) => env.TF_BUILD === "True" || env.TF_BUILD === "true",
103
122
  providerName: "Azure Pipelines",
104
123
  buildNumber: (env) => nonEmpty(env.BUILD_BUILDID),
124
+ runId: (env) => nonEmpty(env.BUILD_BUILDID),
105
125
  runUrl: (env) => {
106
126
  const collectionUri = env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI;
107
127
  const project = env.SYSTEM_TEAMPROJECT;
@@ -117,6 +137,7 @@ var PROVIDERS = [
117
137
  detect: (env) => Boolean(env.BITBUCKET_BUILD_NUMBER),
118
138
  providerName: "Bitbucket Pipelines",
119
139
  buildNumber: (env) => nonEmpty(env.BITBUCKET_BUILD_NUMBER),
140
+ runId: (env) => nonEmpty(env.BITBUCKET_BUILD_NUMBER),
120
141
  runUrl: (env) => {
121
142
  const origin = env.BITBUCKET_GIT_HTTP_ORIGIN;
122
143
  if (!origin) {
@@ -138,6 +159,8 @@ function detectCi(env = process.env) {
138
159
  if (runUrl !== void 0) result.ciRunUrl = runUrl;
139
160
  const prNumber = provider.prNumber?.(env);
140
161
  if (prNumber !== void 0) result.ciPrNumber = prNumber;
162
+ const runId = provider.runId?.(env);
163
+ if (runId !== void 0) result.ciRunId = runId;
141
164
  return result;
142
165
  }
143
166
  if (ciInfo.name) {
@@ -208,22 +231,30 @@ function envInt(...names) {
208
231
  const parsed = Number.parseInt(raw, 10);
209
232
  return Number.isFinite(parsed) ? parsed : void 0;
210
233
  }
211
- var QualflareConfigError = class extends Error {
212
- constructor(message) {
213
- super(message);
214
- this.name = "QualflareConfigError";
234
+ function argvShardIndex(argv = process.argv) {
235
+ for (let i = 0; i < argv.length; i += 1) {
236
+ const arg = argv[i];
237
+ if (arg === void 0) {
238
+ continue;
239
+ }
240
+ const raw = arg === "--shard" ? argv[i + 1] : arg.startsWith("--shard=") ? arg.slice("--shard=".length) : void 0;
241
+ if (raw === void 0) {
242
+ continue;
243
+ }
244
+ if (!/^\d+\/\d+$/.test(raw)) {
245
+ return void 0;
246
+ }
247
+ const oneBased = Number.parseInt(raw.split("/")[0] ?? "", 10);
248
+ return Number.isFinite(oneBased) && oneBased >= 1 ? oneBased - 1 : void 0;
215
249
  }
216
- };
250
+ return void 0;
251
+ }
217
252
  function resolveConfig(options, deps = {}) {
218
253
  const doDetectGit = deps.detectGit ?? detectGit;
219
254
  const doDetectCi = deps.detectCi ?? detectCi;
220
255
  const enabled = options.enabled ?? envBool("QUALFLARE_ENABLED") ?? true;
221
- const token = options.token ?? firstEnv2("QUALFLARE_TOKEN", "QF_TOKEN") ?? "";
222
- if (enabled && token === "") {
223
- throw new QualflareConfigError(
224
- "qualflare-cucumberjs: no token configured. Set the `token` format option or the QUALFLARE_TOKEN (or QF_TOKEN) environment variable, or pass `enabled: false` to disable this formatter."
225
- );
226
- }
256
+ const outputDir = options.outputDir || firstEnv2("QUALFLARE_OUTPUT_DIR") || "./qualflare-results";
257
+ const shardIndex = options.shardIndex ?? envInt("QUALFLARE_SHARD_INDEX") ?? argvShardIndex();
227
258
  const milestoneRaw = options.milestone !== void 0 ? options.milestone : envInt("QUALFLARE_MILESTONE", "QF_MILESTONE");
228
259
  const milestone = milestoneRaw !== void 0 && milestoneRaw !== null && milestoneRaw >= 1 ? milestoneRaw : null;
229
260
  const envBranch = firstEnv2("QUALFLARE_BRANCH", "QF_BRANCH");
@@ -237,14 +268,11 @@ function resolveConfig(options, deps = {}) {
237
268
  const ciBuildNumber = options.ciBuildNumber ?? detectedCi.ciBuildNumber;
238
269
  const ciRunUrl = options.ciRunUrl ?? detectedCi.ciRunUrl;
239
270
  const ciPrNumber = options.ciPrNumber ?? detectedCi.ciPrNumber;
271
+ const runId = options.runId ?? firstEnv2("QUALFLARE_RUN_ID") ?? detectedCi.ciRunId ?? (0, import_node_crypto.randomUUID)();
240
272
  return {
241
- token,
242
- apiEndpoint: options.apiEndpoint ?? firstEnv2("QUALFLARE_API_ENDPOINT") ?? "https://api.qualflare.com",
243
273
  // `||` (truthy check), not `??`, for these three REQUIRED-non-empty wire
244
274
  // fields — an explicit `''` option must not silently win over the
245
- // default (the server rejects an empty `environment`, and since
246
- // `failOnUploadError` defaults `false`, that would fail the entire
247
- // upload with no visible error by default). Ported verbatim from
275
+ // default (the server rejects an empty `environment`). Ported verbatim from
248
276
  // qualflare-cypress, where this was found via deep adversarial review.
249
277
  environment: (options.environment || void 0) ?? firstEnv2("QUALFLARE_ENVIRONMENT", "QF_ENVIRONMENT") ?? "development",
250
278
  language: (options.language || void 0) ?? firstEnv2("QUALFLARE_LANGUAGE", "QF_LANGUAGE") ?? "en-US",
@@ -260,38 +288,19 @@ function resolveConfig(options, deps = {}) {
260
288
  ciBuildNumber,
261
289
  ciRunUrl,
262
290
  ciPrNumber,
263
- timeoutMs: options.timeoutMs ?? envInt("QUALFLARE_TIMEOUT_MS") ?? 12e4,
264
- retry: {
265
- max: options.retry?.max ?? envInt("QUALFLARE_RETRY_MAX", "QF_RETRY_MAX") ?? 3,
266
- baseDelayMs: options.retry?.baseDelayMs ?? envInt("QUALFLARE_RETRY_BASE_DELAY_MS") ?? 1e3,
267
- maxDelayMs: options.retry?.maxDelayMs ?? envInt("QUALFLARE_RETRY_MAX_DELAY_MS") ?? 3e4
268
- },
269
- failOnUploadError: options.failOnUploadError ?? envBool("QUALFLARE_FAIL_ON_UPLOAD_ERROR") ?? false,
291
+ runId,
270
292
  attachScreenshots: options.attachScreenshots ?? envBool("QUALFLARE_ATTACH_SCREENSHOTS") ?? true,
271
293
  includeStepHooks: options.includeStepHooks ?? envBool("QUALFLARE_INCLUDE_STEP_HOOKS") ?? false,
272
294
  maxAttachmentBytes: options.maxAttachmentBytes ?? envInt("QUALFLARE_MAX_ATTACHMENT_BYTES") ?? 15e5,
273
295
  maxTotalAttachmentBytes: options.maxTotalAttachmentBytes ?? envInt("QUALFLARE_MAX_TOTAL_ATTACHMENT_BYTES") ?? 75e4,
296
+ maxVideoBytes: options.maxVideoBytes ?? envInt("QUALFLARE_MAX_VIDEO_BYTES") ?? MAX_VIDEO_UPLOAD_BYTES,
274
297
  debug: options.debug ?? envBool("QUALFLARE_DEBUG", "QF_DEBUG") ?? false,
275
- enabled
298
+ enabled,
299
+ outputDir,
300
+ shardIndex
276
301
  };
277
302
  }
278
303
 
279
- // src/http/client.ts
280
- var import_undici = require("undici");
281
-
282
- // src/shared/constants.ts
283
- var RESERVED_MESSAGE_MEDIA_TYPE = "application/vnd.qualflare.message+json";
284
- var HEADER_TOKEN = "QF_TOKEN";
285
- var HEADER_IDEMPOTENCY_KEY = "Idempotency-Key";
286
- var HEADER_CONTENT_TYPE = "Content-Type";
287
- var HEADER_ACCEPT = "Accept";
288
- var HEADER_USER_AGENT = "User-Agent";
289
- var MAX_SUITES_PER_LAUNCH = 2e3;
290
- var MAX_CASES_PER_SUITE = 5e3;
291
- var MAX_TAGS_PER_CASE = 64;
292
- var MAX_IDEMPOTENCY_KEY_CHARS = 255;
293
- var MAX_STEPS_PER_TEST_ATTEMPT = 300;
294
-
295
304
  // src/shared/logger.ts
296
305
  var PREFIX = "[qualflare-cucumberjs]";
297
306
  var logger = {
@@ -309,220 +318,86 @@ var logger = {
309
318
  }
310
319
  };
311
320
 
312
- // src/http/backoff.ts
313
- function computeDelay(attempt, baseDelayMs, maxDelayMs, retryAfterMs) {
314
- const exponential = baseDelayMs * 2 ** Math.max(0, attempt - 1);
315
- const jittered = Math.random() * Math.min(exponential, maxDelayMs);
316
- const floor = retryAfterMs !== void 0 ? retryAfterMs : 0;
317
- return Math.min(Math.max(jittered, floor), maxDelayMs);
318
- }
321
+ // src/formatter/attachment-budget.ts
322
+ var fs2 = __toESM(require("fs"), 1);
323
+ var path2 = __toESM(require("path"), 1);
319
324
 
320
- // src/http/errors.ts
321
- function friendlyHint(code) {
322
- switch (code) {
323
- case "environment.not_found":
324
- return "Environment not found. Check the `environment` option or create it in Qualflare.";
325
- case "milestone.not_found":
326
- return "Milestone not found. Check the `milestone` option or its sequence number in Qualflare.";
327
- case "common.validation_failed":
328
- return "Validation failed. Check the request data below.";
329
- default:
330
- return void 0;
331
- }
332
- }
333
- function actionHint(statusCode) {
334
- switch (statusCode) {
335
- case 401:
336
- return "the configured token is missing or invalid \u2014 check `token`/QUALFLARE_TOKEN";
337
- case 403:
338
- return "the token lacks access to this project";
339
- case 402:
340
- return "a plan limit was reached \u2014 check your Qualflare subscription";
341
- default:
342
- return void 0;
343
- }
325
+ // src/formatter/video-writer.ts
326
+ var import_node_crypto2 = require("crypto");
327
+ var fs = __toESM(require("fs"), 1);
328
+ var path = __toESM(require("path"), 1);
329
+ var VIDEO_MIME_TYPES_BY_EXTENSION = {
330
+ ".mp4": "video/mp4",
331
+ ".webm": "video/webm",
332
+ ".mov": "video/quicktime"
333
+ };
334
+ var EXTENSION_BY_VIDEO_MIME_TYPE = {
335
+ "video/mp4": ".mp4",
336
+ "video/webm": ".webm",
337
+ "video/quicktime": ".mov"
338
+ };
339
+ function resolveVideoMimeType(mimeType, filePath) {
340
+ if (filePath) {
341
+ const extension2 = path.extname(filePath).toLowerCase();
342
+ const resolvedMimeType = VIDEO_MIME_TYPES_BY_EXTENSION[extension2];
343
+ return resolvedMimeType ? { mimeType: resolvedMimeType, extension: extension2 } : void 0;
344
+ }
345
+ const normalized = mimeType?.toLowerCase();
346
+ const extension = normalized ? EXTENSION_BY_VIDEO_MIME_TYPE[normalized] : void 0;
347
+ return normalized && extension ? { mimeType: normalized, extension } : void 0;
344
348
  }
345
- function renderFields(fields) {
346
- if (!fields || fields.length === 0) {
349
+ function writeVideoAttachment(pending, outputDir, maxVideoBytes) {
350
+ const resolved = resolveVideoMimeType(pending.mimeType, pending.path);
351
+ if (!resolved) {
352
+ logger.warn(`skipping video attachment "${pending.name}": unsupported video format.`);
347
353
  return void 0;
348
354
  }
349
- return fields.map((f) => {
350
- const rule = f.rule ? ` (${f.rule})` : "";
351
- const msg = f.message ? `: ${f.message}` : "";
352
- return `${f.field}${rule}${msg}`;
353
- }).join("; ");
354
- }
355
- var QualflareApiError = class extends Error {
356
- code;
357
- statusCode;
358
- requestId;
359
- fields;
360
- constructor(init) {
361
- const parts = [init.message];
362
- const fieldsRendered = renderFields(init.fields);
363
- if (fieldsRendered) {
364
- parts.push(`fields: ${fieldsRendered}`);
355
+ const localVideoPath = `${(0, import_node_crypto2.randomUUID)()}${resolved.extension}`;
356
+ const destination = path.join(outputDir, localVideoPath);
357
+ if (pending.path !== void 0) {
358
+ let fileSize;
359
+ try {
360
+ fileSize = fs.statSync(pending.path).size;
361
+ } catch (err) {
362
+ logger.warn(`skipping video attachment "${pending.path}": could not stat file: ${err.message}`);
363
+ return void 0;
365
364
  }
366
- const hint = actionHint(init.statusCode);
367
- if (hint) {
368
- parts.push(`(${hint})`);
365
+ if (fileSize > maxVideoBytes) {
366
+ logger.warn(
367
+ `skipping video attachment "${pending.path}": ${fileSize} bytes exceeds the configured maxVideoBytes cap of ${maxVideoBytes} bytes.`
368
+ );
369
+ return void 0;
369
370
  }
370
- if (init.requestId) {
371
- parts.push(`[request_id: ${init.requestId}]`);
371
+ try {
372
+ fs.mkdirSync(outputDir, { recursive: true });
373
+ fs.copyFileSync(pending.path, destination);
374
+ } catch (err) {
375
+ logger.warn(`skipping video attachment "${pending.path}": could not copy file: ${err.message}`);
376
+ return void 0;
372
377
  }
373
- super(parts.join(" \u2014 "), init.cause !== void 0 ? { cause: init.cause } : void 0);
374
- this.name = "QualflareApiError";
375
- this.code = init.code;
376
- this.statusCode = init.statusCode;
377
- this.requestId = init.requestId;
378
- this.fields = init.fields;
378
+ return { localVideoPath, fileSize, mimeType: resolved.mimeType };
379
379
  }
380
- };
381
- function buildApiError(statusCode, body) {
382
- const code = body?.code;
383
- const message = body?.message || friendlyHint(code) || body?.error || `request failed with status ${statusCode}`;
384
- return new QualflareApiError({
385
- message,
386
- code,
387
- statusCode,
388
- requestId: body?.request_id,
389
- fields: body?.fields
390
- });
391
- }
392
-
393
- // src/http/idempotency.ts
394
- var import_node_crypto = require("crypto");
395
- function newIdempotencyKey() {
396
- const key = (0, import_node_crypto.randomUUID)();
397
- if (key.length > MAX_IDEMPOTENCY_KEY_CHARS) {
398
- return key.slice(0, MAX_IDEMPOTENCY_KEY_CHARS);
399
- }
400
- return key;
401
- }
402
-
403
- // src/http/client.ts
404
- var RETRYABLE_STATUS_CODES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
405
- function redactToken(token) {
406
- return token.length > 0 ? "***REDACTED***" : "(none)";
407
- }
408
- var QualflareHttpClient = class {
409
- constructor(opts) {
410
- this.opts = opts;
411
- }
412
- opts;
413
- async send(collect) {
414
- const url = `${this.opts.endpoint.replace(/\/+$/, "")}/api/v1/collect`;
415
- const idempotencyKey = newIdempotencyKey();
416
- const body = JSON.stringify(collect);
417
- const maxAttempts = Math.max(1, this.opts.retry.max + 1);
418
- let lastError;
419
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
420
- if (this.opts.debug) {
421
- logger.debug(
422
- `POST ${url} (attempt ${attempt}/${maxAttempts}, QF_TOKEN: ${redactToken(this.opts.token)})`
423
- );
424
- }
425
- let statusCode;
426
- let responseBody;
427
- let responseHeaders;
428
- try {
429
- const res = await (0, import_undici.request)(url, {
430
- method: "POST",
431
- headers: {
432
- [HEADER_TOKEN]: this.opts.token,
433
- [HEADER_CONTENT_TYPE]: "application/json",
434
- [HEADER_ACCEPT]: "application/json",
435
- [HEADER_USER_AGENT]: this.opts.userAgent,
436
- [HEADER_IDEMPOTENCY_KEY]: idempotencyKey
437
- },
438
- body,
439
- maxRedirections: 0,
440
- signal: AbortSignal.timeout(this.opts.timeoutMs)
441
- });
442
- statusCode = res.statusCode;
443
- responseHeaders = res.headers;
444
- responseBody = await res.body.text();
445
- } catch (err) {
446
- lastError = err;
447
- if (this.opts.debug) {
448
- logger.debug(`attempt ${attempt} transport error: ${err?.message ?? err}`);
449
- }
450
- if (attempt < maxAttempts) {
451
- await sleep(computeDelay(attempt, this.opts.retry.baseDelayMs, this.opts.retry.maxDelayMs));
452
- continue;
453
- }
454
- throw new QualflareApiError({
455
- message: `failed to send request to ${url}`,
456
- cause: err
457
- });
458
- }
459
- if (this.opts.debug) {
460
- logger.debug(`attempt ${attempt} response: ${statusCode}`);
461
- }
462
- if (statusCode >= 200 && statusCode < 300) {
463
- return parseSuccess(responseBody);
464
- }
465
- const parsedError = parseErrorBody(responseBody);
466
- if (!RETRYABLE_STATUS_CODES.has(statusCode) || attempt === maxAttempts) {
467
- throw buildApiError(statusCode, parsedError);
468
- }
469
- const retryAfterMs = parseRetryAfter(responseHeaders["retry-after"]);
470
- const delay = computeDelay(
471
- attempt,
472
- this.opts.retry.baseDelayMs,
473
- this.opts.retry.maxDelayMs,
474
- retryAfterMs
380
+ if (pending.content !== void 0) {
381
+ const fileSize = Buffer.byteLength(pending.content, "base64");
382
+ if (fileSize > maxVideoBytes) {
383
+ logger.warn(
384
+ `skipping video attachment "${pending.name}": ${fileSize} bytes exceeds the configured maxVideoBytes cap of ${maxVideoBytes} bytes.`
475
385
  );
476
- if (this.opts.debug) {
477
- logger.debug(`retrying after ${Math.round(delay)}ms (status ${statusCode})`);
478
- }
479
- await sleep(delay);
386
+ return void 0;
480
387
  }
481
- throw lastError instanceof Error ? lastError : new QualflareApiError({ message: "request failed for an unknown reason" });
482
- }
483
- };
484
- function parseSuccess(responseBody) {
485
- try {
486
- const parsed = JSON.parse(responseBody);
487
- if (typeof parsed.seq !== "number") {
488
- throw new Error('response body missing numeric "seq"');
388
+ try {
389
+ fs.mkdirSync(outputDir, { recursive: true });
390
+ fs.writeFileSync(destination, Buffer.from(pending.content, "base64"));
391
+ } catch (err) {
392
+ logger.warn(`skipping video attachment "${pending.name}": could not write file: ${err.message}`);
393
+ return void 0;
489
394
  }
490
- return parsed;
491
- } catch (err) {
492
- throw new QualflareApiError({
493
- message: "server returned a success status but an unparseable body",
494
- cause: err
495
- });
496
- }
497
- }
498
- function parseErrorBody(responseBody) {
499
- if (!responseBody) {
500
- return void 0;
501
- }
502
- try {
503
- return JSON.parse(responseBody);
504
- } catch {
505
- return void 0;
395
+ return { localVideoPath, fileSize, mimeType: resolved.mimeType };
506
396
  }
397
+ return void 0;
507
398
  }
508
- function parseRetryAfter(value) {
509
- const raw = Array.isArray(value) ? value[0] : value;
510
- if (!raw) {
511
- return void 0;
512
- }
513
- const seconds = Number(raw);
514
- return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : void 0;
515
- }
516
- function sleep(ms) {
517
- return new Promise((resolve) => setTimeout(resolve, ms));
518
- }
519
-
520
- // src/config/version.ts
521
- var PACKAGE_VERSION = "0.1.0";
522
399
 
523
400
  // src/formatter/attachment-budget.ts
524
- var fs = __toESM(require("fs"), 1);
525
- var path = __toESM(require("path"), 1);
526
401
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([".mp4", ".webm", ".mov", ".avi", ".mkv"]);
527
402
  var AttachmentBudget = class {
528
403
  constructor(maxTotalBytes) {
@@ -547,7 +422,7 @@ function isVideoLike(mimeType, filePath) {
547
422
  if (mimeType?.toLowerCase().startsWith("video/")) {
548
423
  return true;
549
424
  }
550
- if (filePath && VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase())) {
425
+ if (filePath && VIDEO_EXTENSIONS.has(path2.extname(filePath).toLowerCase())) {
551
426
  return true;
552
427
  }
553
428
  return false;
@@ -555,7 +430,7 @@ function isVideoLike(mimeType, filePath) {
555
430
  function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
556
431
  let size;
557
432
  try {
558
- size = fs.statSync(filePath).size;
433
+ size = fs2.statSync(filePath).size;
559
434
  } catch (err) {
560
435
  return { skipped: true, reason: `could not stat file: ${err.message}` };
561
436
  }
@@ -572,7 +447,7 @@ function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
572
447
  };
573
448
  }
574
449
  try {
575
- const content = fs.readFileSync(filePath).toString("base64");
450
+ const content = fs2.readFileSync(filePath).toString("base64");
576
451
  return { skipped: false, content };
577
452
  } catch (err) {
578
453
  return { skipped: true, reason: `could not read file: ${err.message}` };
@@ -582,12 +457,6 @@ function resolvePendingAttachment(pending, config, budget) {
582
457
  if (!config.attachScreenshots) {
583
458
  return void 0;
584
459
  }
585
- if (isVideoLike(pending.mimeType, pending.path)) {
586
- logger.warn(
587
- `refusing to attach "${pending.name}": video attachments are not supported yet (${pending.path ?? pending.mimeType ?? "unknown"}).`
588
- );
589
- return void 0;
590
- }
591
460
  if (pending.content !== void 0) {
592
461
  const bytes = Buffer.byteLength(pending.content, "base64");
593
462
  if (bytes > config.maxAttachmentBytes) {
@@ -620,6 +489,22 @@ function resolvePendingAttachment(pending, config, budget) {
620
489
  }
621
490
  return void 0;
622
491
  }
492
+ async function resolveVideoAttachment(pending, config) {
493
+ if (!config.attachScreenshots) {
494
+ return void 0;
495
+ }
496
+ const written = writeVideoAttachment(pending, config.outputDir, config.maxVideoBytes);
497
+ if (!written) {
498
+ return void 0;
499
+ }
500
+ return {
501
+ name: pending.name,
502
+ mimeType: written.mimeType,
503
+ localVideoPath: written.localVideoPath,
504
+ fileSize: written.fileSize,
505
+ stepIndex: pending.stepIndex
506
+ };
507
+ }
623
508
 
624
509
  // src/formatter/attempt-tracker.ts
625
510
  var import_messages2 = require("@cucumber/messages");
@@ -866,7 +751,8 @@ var AttemptTracker = class {
866
751
  tags: [],
867
752
  properties: {},
868
753
  attachments: [],
869
- stepCapWarned: false
754
+ stepCapWarned: false,
755
+ pendingVideoWrites: []
870
756
  };
871
757
  this.byTestCaseStartedId.set(e.id, record);
872
758
  const attempts = this.byTestCaseId.get(testCase.id) ?? [];
@@ -944,11 +830,25 @@ var AttemptTracker = class {
944
830
  }
945
831
  const stepIndex = this.resolveStepIndex(record, e.testStepId);
946
832
  const content = e.contentEncoding === "BASE64" ? e.body : Buffer.from(e.body, "utf8").toString("base64");
947
- const resolved = resolvePendingAttachment(
948
- { name: e.fileName || "attachment", mimeType: e.mediaType, content, stepIndex },
949
- this.config,
950
- this.attachmentBudget
951
- );
833
+ this.resolveAttachment(record, { name: e.fileName || "attachment", mimeType: e.mediaType, content, stepIndex });
834
+ }
835
+ /** Resolves one pending attachment, routing a video-like one through the
836
+ * write-to-`outputDir` flow (tracked in `record.pendingVideoWrites` so
837
+ * `finish()` can wait for it) and everything else through the synchronous
838
+ * inline path — shared by the real `World.attach()` handler above and both
839
+ * `qualflare.attachment()`/`attachmentFromFile()` runtime-message cases
840
+ * below. */
841
+ resolveAttachment(record, pending) {
842
+ if (isVideoLike(pending.mimeType, pending.path)) {
843
+ const write = resolveVideoAttachment(pending, this.config).then((resolved2) => {
844
+ if (resolved2) {
845
+ record.attachments.push(resolved2);
846
+ }
847
+ });
848
+ record.pendingVideoWrites.push(write);
849
+ return;
850
+ }
851
+ const resolved = resolvePendingAttachment(pending, this.config, this.attachmentBudget);
952
852
  if (resolved) {
953
853
  record.attachments.push(resolved);
954
854
  }
@@ -1005,26 +905,12 @@ var AttemptTracker = class {
1005
905
  }
1006
906
  case "attachment": {
1007
907
  const stepIndex = testStepId !== void 0 ? record.stepIndexByTestStepId.get(testStepId) : void 0;
1008
- const resolved = resolvePendingAttachment(
1009
- { name: message.name, mimeType: message.mimeType, content: message.contentBase64, stepIndex },
1010
- this.config,
1011
- this.attachmentBudget
1012
- );
1013
- if (resolved) {
1014
- record.attachments.push(resolved);
1015
- }
908
+ this.resolveAttachment(record, { name: message.name, mimeType: message.mimeType, content: message.contentBase64, stepIndex });
1016
909
  return;
1017
910
  }
1018
911
  case "attachment_from_file": {
1019
912
  const stepIndex = testStepId !== void 0 ? record.stepIndexByTestStepId.get(testStepId) : void 0;
1020
- const resolved = resolvePendingAttachment(
1021
- { name: message.name, mimeType: message.mimeType, path: message.path, stepIndex },
1022
- this.config,
1023
- this.attachmentBudget
1024
- );
1025
- if (resolved) {
1026
- record.attachments.push(resolved);
1027
- }
913
+ this.resolveAttachment(record, { name: message.name, mimeType: message.mimeType, path: message.path, stepIndex });
1028
914
  return;
1029
915
  }
1030
916
  case "step_start": {
@@ -1049,8 +935,21 @@ var AttemptTracker = class {
1049
935
  }
1050
936
  /** Returns the collapsed result once all attempts of this logical
1051
937
  * scenario have arrived, or `undefined` if more attempts are coming
1052
- * (`willBeRetried === true`). */
1053
- finish(e) {
938
+ * (`willBeRetried === true`).
939
+ *
940
+ * Async because it must first await every attempt's own
941
+ * `pendingVideoWrites` (any video attached anywhere across every retry
942
+ * of this scenario). This is load-bearing, not just tidiness:
943
+ * `collapseAttempts`/`buildCase` read `attachments` by REFERENCE, not by
944
+ * copy, and `buildCase` runs synchronously right after this resolves — a
945
+ * scenario whose ONLY attachment is a still-uploading video would have an
946
+ * EMPTY `attachments` array at that instant, and `buildCase` captures
947
+ * `attachments.length > 0 ? attachments : undefined` as a plain
948
+ * `undefined` VALUE right then, permanently — a later push onto the
949
+ * (still-live) array reference would no longer be visible through
950
+ * `undefined`. Awaiting here first guarantees `attachments` is complete
951
+ * before `buildCase` ever reads it. */
952
+ async finish(e) {
1054
953
  const record = this.byTestCaseStartedId.get(e.testCaseStartedId);
1055
954
  this.byTestCaseStartedId.delete(e.testCaseStartedId);
1056
955
  if (!record) {
@@ -1062,6 +961,10 @@ var AttemptTracker = class {
1062
961
  const testCaseId = record.testCase.id;
1063
962
  const attempts = this.byTestCaseId.get(testCaseId) ?? [record];
1064
963
  this.byTestCaseId.delete(testCaseId);
964
+ const pendingWrites = attempts.flatMap((a) => a.pendingVideoWrites);
965
+ if (pendingWrites.length > 0) {
966
+ await Promise.all(pendingWrites);
967
+ }
1065
968
  const snapshots = attempts.map((a) => {
1066
969
  const worst = a.stepResults.length > 0 ? (0, import_messages2.getWorstTestStepResult)(a.stepResults) : void 0;
1067
970
  const duration = a.stepResults.reduce((sum, r) => sum + messageDurationToNs(r.duration), 0);
@@ -1096,6 +999,11 @@ function timestampMs(ts) {
1096
999
 
1097
1000
  // src/formatter/collect-builder.ts
1098
1001
  var os = __toESM(require("os"), 1);
1002
+
1003
+ // src/config/version.ts
1004
+ var PACKAGE_VERSION = "0.3.0";
1005
+
1006
+ // src/formatter/collect-builder.ts
1099
1007
  function resolveOs(config) {
1100
1008
  if (config.os) {
1101
1009
  return config.os;
@@ -1116,7 +1024,8 @@ function buildCollectPayload(suites, config) {
1116
1024
  metadata: {
1117
1025
  version: PACKAGE_VERSION,
1118
1026
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1119
- cliName: "qualflare-cucumberjs"
1027
+ cliName: "qualflare-cucumberjs",
1028
+ runId: config.runId
1120
1029
  },
1121
1030
  properties: config.properties,
1122
1031
  suites,
@@ -1255,7 +1164,7 @@ var RunHookTracker = class {
1255
1164
  }
1256
1165
  return {
1257
1166
  name: "(global hooks)",
1258
- category: "bdd",
1167
+ category: "cucumber",
1259
1168
  duration: this.failed.reduce((sum, c) => sum + c.duration, 0),
1260
1169
  cases: this.failed
1261
1170
  };
@@ -1263,7 +1172,7 @@ var RunHookTracker = class {
1263
1172
  };
1264
1173
 
1265
1174
  // src/formatter/suite-builder.ts
1266
- var path2 = __toESM(require("path"), 1);
1175
+ var path3 = __toESM(require("path"), 1);
1267
1176
  function groupIntoSuites(cases, cwd, extraSuite) {
1268
1177
  const byUri = /* @__PURE__ */ new Map();
1269
1178
  for (const { uri, case: kase } of cases) {
@@ -1283,7 +1192,7 @@ function groupIntoSuites(cases, cwd, extraSuite) {
1283
1192
  }
1284
1193
  suites.push({
1285
1194
  name: relativizeUri(uri, cwd),
1286
- category: "bdd",
1195
+ category: "cucumber",
1287
1196
  duration: kases.reduce((sum, c) => sum + c.duration, 0),
1288
1197
  cases: kases.slice(0, MAX_CASES_PER_SUITE)
1289
1198
  });
@@ -1303,10 +1212,10 @@ function relativizeUri(uri, cwd) {
1303
1212
  if (normalized.startsWith("file://")) {
1304
1213
  normalized = new URL(normalized).pathname;
1305
1214
  }
1306
- if (path2.isAbsolute(normalized)) {
1307
- normalized = path2.relative(cwd, normalized);
1215
+ if (path3.isAbsolute(normalized)) {
1216
+ normalized = path3.relative(cwd, normalized);
1308
1217
  }
1309
- return normalized.split(path2.sep).join("/");
1218
+ return normalized.split(path3.sep).join("/");
1310
1219
  }
1311
1220
 
1312
1221
  // src/formatter/formatter.ts
@@ -1320,12 +1229,25 @@ var QualflareCucumberFormatter = class extends import_cucumber.Formatter {
1320
1229
  attemptTracker;
1321
1230
  runHookTracker = new RunHookTracker();
1322
1231
  finishedCases = [];
1232
+ /** One promise per `testCaseFinished` envelope, resolving once that
1233
+ * scenario's `AttemptTracker.finish()` (which itself awaits any pending
1234
+ * video uploads — see its doc comment) has settled and, if it produced a
1235
+ * result, been pushed into `finishedCases`. `finished()` awaits all of
1236
+ * these before building/uploading the Collect payload, so a scenario
1237
+ * whose only attachment is a still-uploading video is never silently
1238
+ * dropped from the report. */
1239
+ pendingCaseBuilds = [];
1323
1240
  constructor(options) {
1324
1241
  super(options);
1325
1242
  this.config = resolveConfig(options.parsedArgvOptions);
1326
1243
  this.hookIndex = buildHookIndex(options.supportCodeLibrary);
1327
1244
  this.attachmentBudget = new AttachmentBudget(this.config.maxTotalAttachmentBytes);
1328
- this.attemptTracker = new AttemptTracker(this.hookIndex, this.gherkin, this.config, this.attachmentBudget);
1245
+ this.attemptTracker = new AttemptTracker(
1246
+ this.hookIndex,
1247
+ this.gherkin,
1248
+ this.config,
1249
+ this.attachmentBudget
1250
+ );
1329
1251
  if (!this.config.enabled) {
1330
1252
  return;
1331
1253
  }
@@ -1376,15 +1298,20 @@ var QualflareCucumberFormatter = class extends import_cucumber.Formatter {
1376
1298
  return;
1377
1299
  }
1378
1300
  if (envelope.testCaseFinished) {
1379
- const finished = this.attemptTracker.finish(envelope.testCaseFinished);
1380
- if (finished) {
1301
+ const pending = this.attemptTracker.finish(envelope.testCaseFinished).then((finished) => {
1302
+ if (!finished) {
1303
+ return;
1304
+ }
1381
1305
  const pickle = this.pickleIndex.get(finished.pickleId);
1382
1306
  if (pickle) {
1383
1307
  this.finishedCases.push(buildCase(finished.uri, pickle, finished.collapsed, this.gherkin));
1384
1308
  } else {
1385
1309
  logger.warn(`could not resolve pickle "${finished.pickleId}" for a finished scenario \u2014 it will not be uploaded.`);
1386
1310
  }
1387
- }
1311
+ }).catch((err) => {
1312
+ logger.error("failed to process a cucumber-js event:", err);
1313
+ });
1314
+ this.pendingCaseBuilds.push(pending);
1388
1315
  return;
1389
1316
  }
1390
1317
  if (envelope.testRunHookStarted) {
@@ -1401,41 +1328,41 @@ var QualflareCucumberFormatter = class extends import_cucumber.Formatter {
1401
1328
  }
1402
1329
  async finished() {
1403
1330
  try {
1331
+ await Promise.all(this.pendingCaseBuilds);
1404
1332
  if (this.config.enabled) {
1405
- await this.uploadResults();
1333
+ this.writeResults();
1406
1334
  }
1407
1335
  } finally {
1408
1336
  await super.finished();
1409
1337
  }
1410
1338
  }
1411
- async uploadResults() {
1339
+ /** Writes this process's Collect payload into `outputDir` under a unique
1340
+ * filename. Never uploads: `qualflare-cli collect <outputDir>` does that,
1341
+ * merging every file it finds there into one Launch. Multiple shards can
1342
+ * therefore share one directory safely — the UUID filename is what keeps
1343
+ * them from overwriting each other. */
1344
+ writeResults() {
1412
1345
  const suites = groupIntoSuites(this.finishedCases, this.cwd, this.runHookTracker.buildSuite());
1413
1346
  if (suites.length === 0) {
1414
1347
  if (this.config.debug) {
1415
- logger.debug("no scenarios reported \u2014 skipping upload.");
1348
+ logger.debug("no scenarios reported \u2014 skipping file write.");
1416
1349
  }
1417
1350
  return;
1418
1351
  }
1419
1352
  const payload = buildCollectPayload(suites, this.config);
1420
- const client = new QualflareHttpClient({
1421
- endpoint: this.config.apiEndpoint,
1422
- token: this.config.token,
1423
- timeoutMs: this.config.timeoutMs,
1424
- retry: this.config.retry,
1425
- userAgent: `qualflare-cucumberjs/${PACKAGE_VERSION}`,
1426
- debug: this.config.debug
1427
- });
1428
- try {
1429
- const result = await client.send(payload);
1430
- if (this.config.debug) {
1431
- logger.debug(`uploaded launch #${result.seq}.`);
1432
- }
1433
- } catch (err) {
1434
- if (this.config.failOnUploadError) {
1435
- throw err;
1353
+ if (this.config.shardIndex !== void 0) {
1354
+ for (const suite of payload.suites) {
1355
+ for (const c of suite.cases) {
1356
+ c.shardIndex = this.config.shardIndex;
1357
+ }
1436
1358
  }
1437
- logger.error("failed to upload results to Qualflare:", err);
1438
1359
  }
1360
+ fs3.mkdirSync(this.config.outputDir, { recursive: true });
1361
+ const outputPath = path4.join(this.config.outputDir, `${(0, import_node_crypto3.randomUUID)()}.json`);
1362
+ fs3.writeFileSync(outputPath, JSON.stringify(payload));
1363
+ logger.info(
1364
+ `wrote Collect payload to ${outputPath} \u2014 run \`qualflare-cli collect ${this.config.outputDir}\` to upload it.`
1365
+ );
1439
1366
  }
1440
1367
  };
1441
1368
  //# sourceMappingURL=index.cjs.map