@qualflare/cucumberjs 0.1.0 → 0.2.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,19 @@ __export(formatter_exports, {
35
35
  module.exports = __toCommonJS(formatter_exports);
36
36
 
37
37
  // src/formatter/formatter.ts
38
+ var import_node_crypto2 = 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/shared/constants.ts
44
+ var RESERVED_MESSAGE_MEDIA_TYPE = "application/vnd.qualflare.message+json";
45
+ var MAX_SUITES_PER_LAUNCH = 2e3;
46
+ var MAX_CASES_PER_SUITE = 5e3;
47
+ var MAX_TAGS_PER_CASE = 64;
48
+ var MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;
49
+ var MAX_STEPS_PER_TEST_ATTEMPT = 300;
50
+
40
51
  // src/config/ci-detect.ts
41
52
  var ciInfo = __toESM(require("ci-info"), 1);
42
53
  function parsePositiveInt(raw) {
@@ -208,22 +219,30 @@ function envInt(...names) {
208
219
  const parsed = Number.parseInt(raw, 10);
209
220
  return Number.isFinite(parsed) ? parsed : void 0;
210
221
  }
211
- var QualflareConfigError = class extends Error {
212
- constructor(message) {
213
- super(message);
214
- this.name = "QualflareConfigError";
222
+ function argvShardIndex(argv = process.argv) {
223
+ for (let i = 0; i < argv.length; i += 1) {
224
+ const arg = argv[i];
225
+ if (arg === void 0) {
226
+ continue;
227
+ }
228
+ const raw = arg === "--shard" ? argv[i + 1] : arg.startsWith("--shard=") ? arg.slice("--shard=".length) : void 0;
229
+ if (raw === void 0) {
230
+ continue;
231
+ }
232
+ if (!/^\d+\/\d+$/.test(raw)) {
233
+ return void 0;
234
+ }
235
+ const oneBased = Number.parseInt(raw.split("/")[0] ?? "", 10);
236
+ return Number.isFinite(oneBased) && oneBased >= 1 ? oneBased - 1 : void 0;
215
237
  }
216
- };
238
+ return void 0;
239
+ }
217
240
  function resolveConfig(options, deps = {}) {
218
241
  const doDetectGit = deps.detectGit ?? detectGit;
219
242
  const doDetectCi = deps.detectCi ?? detectCi;
220
243
  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
- }
244
+ const outputDir = options.outputDir || firstEnv2("QUALFLARE_OUTPUT_DIR") || "./qualflare-results";
245
+ const shardIndex = options.shardIndex ?? envInt("QUALFLARE_SHARD_INDEX") ?? argvShardIndex();
227
246
  const milestoneRaw = options.milestone !== void 0 ? options.milestone : envInt("QUALFLARE_MILESTONE", "QF_MILESTONE");
228
247
  const milestone = milestoneRaw !== void 0 && milestoneRaw !== null && milestoneRaw >= 1 ? milestoneRaw : null;
229
248
  const envBranch = firstEnv2("QUALFLARE_BRANCH", "QF_BRANCH");
@@ -238,13 +257,9 @@ function resolveConfig(options, deps = {}) {
238
257
  const ciRunUrl = options.ciRunUrl ?? detectedCi.ciRunUrl;
239
258
  const ciPrNumber = options.ciPrNumber ?? detectedCi.ciPrNumber;
240
259
  return {
241
- token,
242
- apiEndpoint: options.apiEndpoint ?? firstEnv2("QUALFLARE_API_ENDPOINT") ?? "https://api.qualflare.com",
243
260
  // `||` (truthy check), not `??`, for these three REQUIRED-non-empty wire
244
261
  // 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
262
+ // default (the server rejects an empty `environment`). Ported verbatim from
248
263
  // qualflare-cypress, where this was found via deep adversarial review.
249
264
  environment: (options.environment || void 0) ?? firstEnv2("QUALFLARE_ENVIRONMENT", "QF_ENVIRONMENT") ?? "development",
250
265
  language: (options.language || void 0) ?? firstEnv2("QUALFLARE_LANGUAGE", "QF_LANGUAGE") ?? "en-US",
@@ -260,38 +275,18 @@ function resolveConfig(options, deps = {}) {
260
275
  ciBuildNumber,
261
276
  ciRunUrl,
262
277
  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,
270
278
  attachScreenshots: options.attachScreenshots ?? envBool("QUALFLARE_ATTACH_SCREENSHOTS") ?? true,
271
279
  includeStepHooks: options.includeStepHooks ?? envBool("QUALFLARE_INCLUDE_STEP_HOOKS") ?? false,
272
280
  maxAttachmentBytes: options.maxAttachmentBytes ?? envInt("QUALFLARE_MAX_ATTACHMENT_BYTES") ?? 15e5,
273
281
  maxTotalAttachmentBytes: options.maxTotalAttachmentBytes ?? envInt("QUALFLARE_MAX_TOTAL_ATTACHMENT_BYTES") ?? 75e4,
282
+ maxVideoBytes: options.maxVideoBytes ?? envInt("QUALFLARE_MAX_VIDEO_BYTES") ?? MAX_VIDEO_UPLOAD_BYTES,
274
283
  debug: options.debug ?? envBool("QUALFLARE_DEBUG", "QF_DEBUG") ?? false,
275
- enabled
284
+ enabled,
285
+ outputDir,
286
+ shardIndex
276
287
  };
277
288
  }
278
289
 
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
290
  // src/shared/logger.ts
296
291
  var PREFIX = "[qualflare-cucumberjs]";
297
292
  var logger = {
@@ -309,220 +304,86 @@ var logger = {
309
304
  }
310
305
  };
311
306
 
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
- }
307
+ // src/formatter/attachment-budget.ts
308
+ var fs2 = __toESM(require("fs"), 1);
309
+ var path2 = __toESM(require("path"), 1);
319
310
 
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
- }
311
+ // src/formatter/video-uploader.ts
312
+ var import_node_crypto = require("crypto");
313
+ var fs = __toESM(require("fs"), 1);
314
+ var path = __toESM(require("path"), 1);
315
+ var VIDEO_MIME_TYPES_BY_EXTENSION = {
316
+ ".mp4": "video/mp4",
317
+ ".webm": "video/webm",
318
+ ".mov": "video/quicktime"
319
+ };
320
+ var EXTENSION_BY_VIDEO_MIME_TYPE = {
321
+ "video/mp4": ".mp4",
322
+ "video/webm": ".webm",
323
+ "video/quicktime": ".mov"
324
+ };
325
+ function resolveVideoMimeType(mimeType, filePath) {
326
+ if (filePath) {
327
+ const extension2 = path.extname(filePath).toLowerCase();
328
+ const resolvedMimeType = VIDEO_MIME_TYPES_BY_EXTENSION[extension2];
329
+ return resolvedMimeType ? { mimeType: resolvedMimeType, extension: extension2 } : void 0;
330
+ }
331
+ const normalized = mimeType?.toLowerCase();
332
+ const extension = normalized ? EXTENSION_BY_VIDEO_MIME_TYPE[normalized] : void 0;
333
+ return normalized && extension ? { mimeType: normalized, extension } : void 0;
344
334
  }
345
- function renderFields(fields) {
346
- if (!fields || fields.length === 0) {
335
+ function writeVideoAttachment(pending, outputDir, maxVideoBytes) {
336
+ const resolved = resolveVideoMimeType(pending.mimeType, pending.path);
337
+ if (!resolved) {
338
+ logger.warn(`skipping video attachment "${pending.name}": unsupported video format.`);
347
339
  return void 0;
348
340
  }
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}`);
341
+ const localVideoPath = `${(0, import_node_crypto.randomUUID)()}${resolved.extension}`;
342
+ const destination = path.join(outputDir, localVideoPath);
343
+ if (pending.path !== void 0) {
344
+ let fileSize;
345
+ try {
346
+ fileSize = fs.statSync(pending.path).size;
347
+ } catch (err) {
348
+ logger.warn(`skipping video attachment "${pending.path}": could not stat file: ${err.message}`);
349
+ return void 0;
365
350
  }
366
- const hint = actionHint(init.statusCode);
367
- if (hint) {
368
- parts.push(`(${hint})`);
351
+ if (fileSize > maxVideoBytes) {
352
+ logger.warn(
353
+ `skipping video attachment "${pending.path}": ${fileSize} bytes exceeds the configured maxVideoBytes cap of ${maxVideoBytes} bytes.`
354
+ );
355
+ return void 0;
369
356
  }
370
- if (init.requestId) {
371
- parts.push(`[request_id: ${init.requestId}]`);
357
+ try {
358
+ fs.mkdirSync(outputDir, { recursive: true });
359
+ fs.copyFileSync(pending.path, destination);
360
+ } catch (err) {
361
+ logger.warn(`skipping video attachment "${pending.path}": could not copy file: ${err.message}`);
362
+ return void 0;
372
363
  }
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;
364
+ return { localVideoPath, fileSize, mimeType: resolved.mimeType };
379
365
  }
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
366
+ if (pending.content !== void 0) {
367
+ const fileSize = Buffer.byteLength(pending.content, "base64");
368
+ if (fileSize > maxVideoBytes) {
369
+ logger.warn(
370
+ `skipping video attachment "${pending.name}": ${fileSize} bytes exceeds the configured maxVideoBytes cap of ${maxVideoBytes} bytes.`
475
371
  );
476
- if (this.opts.debug) {
477
- logger.debug(`retrying after ${Math.round(delay)}ms (status ${statusCode})`);
478
- }
479
- await sleep(delay);
372
+ return void 0;
480
373
  }
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"');
374
+ try {
375
+ fs.mkdirSync(outputDir, { recursive: true });
376
+ fs.writeFileSync(destination, Buffer.from(pending.content, "base64"));
377
+ } catch (err) {
378
+ logger.warn(`skipping video attachment "${pending.name}": could not write file: ${err.message}`);
379
+ return void 0;
489
380
  }
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
- });
381
+ return { localVideoPath, fileSize, mimeType: resolved.mimeType };
496
382
  }
383
+ return void 0;
497
384
  }
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;
506
- }
507
- }
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
385
 
523
386
  // src/formatter/attachment-budget.ts
524
- var fs = __toESM(require("fs"), 1);
525
- var path = __toESM(require("path"), 1);
526
387
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([".mp4", ".webm", ".mov", ".avi", ".mkv"]);
527
388
  var AttachmentBudget = class {
528
389
  constructor(maxTotalBytes) {
@@ -547,7 +408,7 @@ function isVideoLike(mimeType, filePath) {
547
408
  if (mimeType?.toLowerCase().startsWith("video/")) {
548
409
  return true;
549
410
  }
550
- if (filePath && VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase())) {
411
+ if (filePath && VIDEO_EXTENSIONS.has(path2.extname(filePath).toLowerCase())) {
551
412
  return true;
552
413
  }
553
414
  return false;
@@ -555,7 +416,7 @@ function isVideoLike(mimeType, filePath) {
555
416
  function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
556
417
  let size;
557
418
  try {
558
- size = fs.statSync(filePath).size;
419
+ size = fs2.statSync(filePath).size;
559
420
  } catch (err) {
560
421
  return { skipped: true, reason: `could not stat file: ${err.message}` };
561
422
  }
@@ -572,7 +433,7 @@ function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
572
433
  };
573
434
  }
574
435
  try {
575
- const content = fs.readFileSync(filePath).toString("base64");
436
+ const content = fs2.readFileSync(filePath).toString("base64");
576
437
  return { skipped: false, content };
577
438
  } catch (err) {
578
439
  return { skipped: true, reason: `could not read file: ${err.message}` };
@@ -582,12 +443,6 @@ function resolvePendingAttachment(pending, config, budget) {
582
443
  if (!config.attachScreenshots) {
583
444
  return void 0;
584
445
  }
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
446
  if (pending.content !== void 0) {
592
447
  const bytes = Buffer.byteLength(pending.content, "base64");
593
448
  if (bytes > config.maxAttachmentBytes) {
@@ -620,6 +475,22 @@ function resolvePendingAttachment(pending, config, budget) {
620
475
  }
621
476
  return void 0;
622
477
  }
478
+ async function resolveVideoAttachment(pending, config) {
479
+ if (!config.attachScreenshots) {
480
+ return void 0;
481
+ }
482
+ const written = writeVideoAttachment(pending, config.outputDir, config.maxVideoBytes);
483
+ if (!written) {
484
+ return void 0;
485
+ }
486
+ return {
487
+ name: pending.name,
488
+ mimeType: written.mimeType,
489
+ localVideoPath: written.localVideoPath,
490
+ fileSize: written.fileSize,
491
+ stepIndex: pending.stepIndex
492
+ };
493
+ }
623
494
 
624
495
  // src/formatter/attempt-tracker.ts
625
496
  var import_messages2 = require("@cucumber/messages");
@@ -866,7 +737,8 @@ var AttemptTracker = class {
866
737
  tags: [],
867
738
  properties: {},
868
739
  attachments: [],
869
- stepCapWarned: false
740
+ stepCapWarned: false,
741
+ pendingVideoWrites: []
870
742
  };
871
743
  this.byTestCaseStartedId.set(e.id, record);
872
744
  const attempts = this.byTestCaseId.get(testCase.id) ?? [];
@@ -944,11 +816,25 @@ var AttemptTracker = class {
944
816
  }
945
817
  const stepIndex = this.resolveStepIndex(record, e.testStepId);
946
818
  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
- );
819
+ this.resolveAttachment(record, { name: e.fileName || "attachment", mimeType: e.mediaType, content, stepIndex });
820
+ }
821
+ /** Resolves one pending attachment, routing a video-like one through the
822
+ * write-to-`outputDir` flow (tracked in `record.pendingVideoWrites` so
823
+ * `finish()` can wait for it) and everything else through the synchronous
824
+ * inline path — shared by the real `World.attach()` handler above and both
825
+ * `qualflare.attachment()`/`attachmentFromFile()` runtime-message cases
826
+ * below. */
827
+ resolveAttachment(record, pending) {
828
+ if (isVideoLike(pending.mimeType, pending.path)) {
829
+ const write = resolveVideoAttachment(pending, this.config).then((resolved2) => {
830
+ if (resolved2) {
831
+ record.attachments.push(resolved2);
832
+ }
833
+ });
834
+ record.pendingVideoWrites.push(write);
835
+ return;
836
+ }
837
+ const resolved = resolvePendingAttachment(pending, this.config, this.attachmentBudget);
952
838
  if (resolved) {
953
839
  record.attachments.push(resolved);
954
840
  }
@@ -1005,26 +891,12 @@ var AttemptTracker = class {
1005
891
  }
1006
892
  case "attachment": {
1007
893
  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
- }
894
+ this.resolveAttachment(record, { name: message.name, mimeType: message.mimeType, content: message.contentBase64, stepIndex });
1016
895
  return;
1017
896
  }
1018
897
  case "attachment_from_file": {
1019
898
  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
- }
899
+ this.resolveAttachment(record, { name: message.name, mimeType: message.mimeType, path: message.path, stepIndex });
1028
900
  return;
1029
901
  }
1030
902
  case "step_start": {
@@ -1049,8 +921,21 @@ var AttemptTracker = class {
1049
921
  }
1050
922
  /** Returns the collapsed result once all attempts of this logical
1051
923
  * scenario have arrived, or `undefined` if more attempts are coming
1052
- * (`willBeRetried === true`). */
1053
- finish(e) {
924
+ * (`willBeRetried === true`).
925
+ *
926
+ * Async because it must first await every attempt's own
927
+ * `pendingVideoWrites` (any video attached anywhere across every retry
928
+ * of this scenario). This is load-bearing, not just tidiness:
929
+ * `collapseAttempts`/`buildCase` read `attachments` by REFERENCE, not by
930
+ * copy, and `buildCase` runs synchronously right after this resolves — a
931
+ * scenario whose ONLY attachment is a still-uploading video would have an
932
+ * EMPTY `attachments` array at that instant, and `buildCase` captures
933
+ * `attachments.length > 0 ? attachments : undefined` as a plain
934
+ * `undefined` VALUE right then, permanently — a later push onto the
935
+ * (still-live) array reference would no longer be visible through
936
+ * `undefined`. Awaiting here first guarantees `attachments` is complete
937
+ * before `buildCase` ever reads it. */
938
+ async finish(e) {
1054
939
  const record = this.byTestCaseStartedId.get(e.testCaseStartedId);
1055
940
  this.byTestCaseStartedId.delete(e.testCaseStartedId);
1056
941
  if (!record) {
@@ -1062,6 +947,10 @@ var AttemptTracker = class {
1062
947
  const testCaseId = record.testCase.id;
1063
948
  const attempts = this.byTestCaseId.get(testCaseId) ?? [record];
1064
949
  this.byTestCaseId.delete(testCaseId);
950
+ const pendingWrites = attempts.flatMap((a) => a.pendingVideoWrites);
951
+ if (pendingWrites.length > 0) {
952
+ await Promise.all(pendingWrites);
953
+ }
1065
954
  const snapshots = attempts.map((a) => {
1066
955
  const worst = a.stepResults.length > 0 ? (0, import_messages2.getWorstTestStepResult)(a.stepResults) : void 0;
1067
956
  const duration = a.stepResults.reduce((sum, r) => sum + messageDurationToNs(r.duration), 0);
@@ -1096,6 +985,11 @@ function timestampMs(ts) {
1096
985
 
1097
986
  // src/formatter/collect-builder.ts
1098
987
  var os = __toESM(require("os"), 1);
988
+
989
+ // src/config/version.ts
990
+ var PACKAGE_VERSION = "0.2.0";
991
+
992
+ // src/formatter/collect-builder.ts
1099
993
  function resolveOs(config) {
1100
994
  if (config.os) {
1101
995
  return config.os;
@@ -1255,7 +1149,7 @@ var RunHookTracker = class {
1255
1149
  }
1256
1150
  return {
1257
1151
  name: "(global hooks)",
1258
- category: "bdd",
1152
+ category: "cucumber",
1259
1153
  duration: this.failed.reduce((sum, c) => sum + c.duration, 0),
1260
1154
  cases: this.failed
1261
1155
  };
@@ -1263,7 +1157,7 @@ var RunHookTracker = class {
1263
1157
  };
1264
1158
 
1265
1159
  // src/formatter/suite-builder.ts
1266
- var path2 = __toESM(require("path"), 1);
1160
+ var path3 = __toESM(require("path"), 1);
1267
1161
  function groupIntoSuites(cases, cwd, extraSuite) {
1268
1162
  const byUri = /* @__PURE__ */ new Map();
1269
1163
  for (const { uri, case: kase } of cases) {
@@ -1283,7 +1177,7 @@ function groupIntoSuites(cases, cwd, extraSuite) {
1283
1177
  }
1284
1178
  suites.push({
1285
1179
  name: relativizeUri(uri, cwd),
1286
- category: "bdd",
1180
+ category: "cucumber",
1287
1181
  duration: kases.reduce((sum, c) => sum + c.duration, 0),
1288
1182
  cases: kases.slice(0, MAX_CASES_PER_SUITE)
1289
1183
  });
@@ -1303,10 +1197,10 @@ function relativizeUri(uri, cwd) {
1303
1197
  if (normalized.startsWith("file://")) {
1304
1198
  normalized = new URL(normalized).pathname;
1305
1199
  }
1306
- if (path2.isAbsolute(normalized)) {
1307
- normalized = path2.relative(cwd, normalized);
1200
+ if (path3.isAbsolute(normalized)) {
1201
+ normalized = path3.relative(cwd, normalized);
1308
1202
  }
1309
- return normalized.split(path2.sep).join("/");
1203
+ return normalized.split(path3.sep).join("/");
1310
1204
  }
1311
1205
 
1312
1206
  // src/formatter/formatter.ts
@@ -1320,12 +1214,25 @@ var QualflareCucumberFormatter = class extends import_cucumber.Formatter {
1320
1214
  attemptTracker;
1321
1215
  runHookTracker = new RunHookTracker();
1322
1216
  finishedCases = [];
1217
+ /** One promise per `testCaseFinished` envelope, resolving once that
1218
+ * scenario's `AttemptTracker.finish()` (which itself awaits any pending
1219
+ * video uploads — see its doc comment) has settled and, if it produced a
1220
+ * result, been pushed into `finishedCases`. `finished()` awaits all of
1221
+ * these before building/uploading the Collect payload, so a scenario
1222
+ * whose only attachment is a still-uploading video is never silently
1223
+ * dropped from the report. */
1224
+ pendingCaseBuilds = [];
1323
1225
  constructor(options) {
1324
1226
  super(options);
1325
1227
  this.config = resolveConfig(options.parsedArgvOptions);
1326
1228
  this.hookIndex = buildHookIndex(options.supportCodeLibrary);
1327
1229
  this.attachmentBudget = new AttachmentBudget(this.config.maxTotalAttachmentBytes);
1328
- this.attemptTracker = new AttemptTracker(this.hookIndex, this.gherkin, this.config, this.attachmentBudget);
1230
+ this.attemptTracker = new AttemptTracker(
1231
+ this.hookIndex,
1232
+ this.gherkin,
1233
+ this.config,
1234
+ this.attachmentBudget
1235
+ );
1329
1236
  if (!this.config.enabled) {
1330
1237
  return;
1331
1238
  }
@@ -1376,15 +1283,20 @@ var QualflareCucumberFormatter = class extends import_cucumber.Formatter {
1376
1283
  return;
1377
1284
  }
1378
1285
  if (envelope.testCaseFinished) {
1379
- const finished = this.attemptTracker.finish(envelope.testCaseFinished);
1380
- if (finished) {
1286
+ const pending = this.attemptTracker.finish(envelope.testCaseFinished).then((finished) => {
1287
+ if (!finished) {
1288
+ return;
1289
+ }
1381
1290
  const pickle = this.pickleIndex.get(finished.pickleId);
1382
1291
  if (pickle) {
1383
1292
  this.finishedCases.push(buildCase(finished.uri, pickle, finished.collapsed, this.gherkin));
1384
1293
  } else {
1385
1294
  logger.warn(`could not resolve pickle "${finished.pickleId}" for a finished scenario \u2014 it will not be uploaded.`);
1386
1295
  }
1387
- }
1296
+ }).catch((err) => {
1297
+ logger.error("failed to process a cucumber-js event:", err);
1298
+ });
1299
+ this.pendingCaseBuilds.push(pending);
1388
1300
  return;
1389
1301
  }
1390
1302
  if (envelope.testRunHookStarted) {
@@ -1401,41 +1313,41 @@ var QualflareCucumberFormatter = class extends import_cucumber.Formatter {
1401
1313
  }
1402
1314
  async finished() {
1403
1315
  try {
1316
+ await Promise.all(this.pendingCaseBuilds);
1404
1317
  if (this.config.enabled) {
1405
- await this.uploadResults();
1318
+ this.writeResults();
1406
1319
  }
1407
1320
  } finally {
1408
1321
  await super.finished();
1409
1322
  }
1410
1323
  }
1411
- async uploadResults() {
1324
+ /** Writes this process's Collect payload into `outputDir` under a unique
1325
+ * filename. Never uploads: `qualflare-cli collect <outputDir>` does that,
1326
+ * merging every file it finds there into one Launch. Multiple shards can
1327
+ * therefore share one directory safely — the UUID filename is what keeps
1328
+ * them from overwriting each other. */
1329
+ writeResults() {
1412
1330
  const suites = groupIntoSuites(this.finishedCases, this.cwd, this.runHookTracker.buildSuite());
1413
1331
  if (suites.length === 0) {
1414
1332
  if (this.config.debug) {
1415
- logger.debug("no scenarios reported \u2014 skipping upload.");
1333
+ logger.debug("no scenarios reported \u2014 skipping file write.");
1416
1334
  }
1417
1335
  return;
1418
1336
  }
1419
1337
  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;
1338
+ if (this.config.shardIndex !== void 0) {
1339
+ for (const suite of payload.suites) {
1340
+ for (const c of suite.cases) {
1341
+ c.shardIndex = this.config.shardIndex;
1342
+ }
1436
1343
  }
1437
- logger.error("failed to upload results to Qualflare:", err);
1438
1344
  }
1345
+ fs3.mkdirSync(this.config.outputDir, { recursive: true });
1346
+ const outputPath = path4.join(this.config.outputDir, `${(0, import_node_crypto2.randomUUID)()}.json`);
1347
+ fs3.writeFileSync(outputPath, JSON.stringify(payload));
1348
+ logger.info(
1349
+ `wrote Collect payload to ${outputPath} \u2014 run \`qualflare-cli collect ${this.config.outputDir}\` to upload it.`
1350
+ );
1439
1351
  }
1440
1352
  };
1441
1353
  //# sourceMappingURL=index.cjs.map