@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.
@@ -1,6 +1,17 @@
1
1
  // src/formatter/formatter.ts
2
+ import { randomUUID as randomUUID2 } from "crypto";
3
+ import * as fs3 from "fs";
4
+ import * as path4 from "path";
2
5
  import { Formatter } from "@cucumber/cucumber";
3
6
 
7
+ // src/shared/constants.ts
8
+ var RESERVED_MESSAGE_MEDIA_TYPE = "application/vnd.qualflare.message+json";
9
+ var MAX_SUITES_PER_LAUNCH = 2e3;
10
+ var MAX_CASES_PER_SUITE = 5e3;
11
+ var MAX_TAGS_PER_CASE = 64;
12
+ var MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;
13
+ var MAX_STEPS_PER_TEST_ATTEMPT = 300;
14
+
4
15
  // src/config/ci-detect.ts
5
16
  import * as ciInfo from "ci-info";
6
17
  function parsePositiveInt(raw) {
@@ -172,22 +183,30 @@ function envInt(...names) {
172
183
  const parsed = Number.parseInt(raw, 10);
173
184
  return Number.isFinite(parsed) ? parsed : void 0;
174
185
  }
175
- var QualflareConfigError = class extends Error {
176
- constructor(message) {
177
- super(message);
178
- this.name = "QualflareConfigError";
186
+ function argvShardIndex(argv = process.argv) {
187
+ for (let i = 0; i < argv.length; i += 1) {
188
+ const arg = argv[i];
189
+ if (arg === void 0) {
190
+ continue;
191
+ }
192
+ const raw = arg === "--shard" ? argv[i + 1] : arg.startsWith("--shard=") ? arg.slice("--shard=".length) : void 0;
193
+ if (raw === void 0) {
194
+ continue;
195
+ }
196
+ if (!/^\d+\/\d+$/.test(raw)) {
197
+ return void 0;
198
+ }
199
+ const oneBased = Number.parseInt(raw.split("/")[0] ?? "", 10);
200
+ return Number.isFinite(oneBased) && oneBased >= 1 ? oneBased - 1 : void 0;
179
201
  }
180
- };
202
+ return void 0;
203
+ }
181
204
  function resolveConfig(options, deps = {}) {
182
205
  const doDetectGit = deps.detectGit ?? detectGit;
183
206
  const doDetectCi = deps.detectCi ?? detectCi;
184
207
  const enabled = options.enabled ?? envBool("QUALFLARE_ENABLED") ?? true;
185
- const token = options.token ?? firstEnv2("QUALFLARE_TOKEN", "QF_TOKEN") ?? "";
186
- if (enabled && token === "") {
187
- throw new QualflareConfigError(
188
- "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."
189
- );
190
- }
208
+ const outputDir = options.outputDir || firstEnv2("QUALFLARE_OUTPUT_DIR") || "./qualflare-results";
209
+ const shardIndex = options.shardIndex ?? envInt("QUALFLARE_SHARD_INDEX") ?? argvShardIndex();
191
210
  const milestoneRaw = options.milestone !== void 0 ? options.milestone : envInt("QUALFLARE_MILESTONE", "QF_MILESTONE");
192
211
  const milestone = milestoneRaw !== void 0 && milestoneRaw !== null && milestoneRaw >= 1 ? milestoneRaw : null;
193
212
  const envBranch = firstEnv2("QUALFLARE_BRANCH", "QF_BRANCH");
@@ -202,13 +221,9 @@ function resolveConfig(options, deps = {}) {
202
221
  const ciRunUrl = options.ciRunUrl ?? detectedCi.ciRunUrl;
203
222
  const ciPrNumber = options.ciPrNumber ?? detectedCi.ciPrNumber;
204
223
  return {
205
- token,
206
- apiEndpoint: options.apiEndpoint ?? firstEnv2("QUALFLARE_API_ENDPOINT") ?? "https://api.qualflare.com",
207
224
  // `||` (truthy check), not `??`, for these three REQUIRED-non-empty wire
208
225
  // fields — an explicit `''` option must not silently win over the
209
- // default (the server rejects an empty `environment`, and since
210
- // `failOnUploadError` defaults `false`, that would fail the entire
211
- // upload with no visible error by default). Ported verbatim from
226
+ // default (the server rejects an empty `environment`). Ported verbatim from
212
227
  // qualflare-cypress, where this was found via deep adversarial review.
213
228
  environment: (options.environment || void 0) ?? firstEnv2("QUALFLARE_ENVIRONMENT", "QF_ENVIRONMENT") ?? "development",
214
229
  language: (options.language || void 0) ?? firstEnv2("QUALFLARE_LANGUAGE", "QF_LANGUAGE") ?? "en-US",
@@ -224,38 +239,18 @@ function resolveConfig(options, deps = {}) {
224
239
  ciBuildNumber,
225
240
  ciRunUrl,
226
241
  ciPrNumber,
227
- timeoutMs: options.timeoutMs ?? envInt("QUALFLARE_TIMEOUT_MS") ?? 12e4,
228
- retry: {
229
- max: options.retry?.max ?? envInt("QUALFLARE_RETRY_MAX", "QF_RETRY_MAX") ?? 3,
230
- baseDelayMs: options.retry?.baseDelayMs ?? envInt("QUALFLARE_RETRY_BASE_DELAY_MS") ?? 1e3,
231
- maxDelayMs: options.retry?.maxDelayMs ?? envInt("QUALFLARE_RETRY_MAX_DELAY_MS") ?? 3e4
232
- },
233
- failOnUploadError: options.failOnUploadError ?? envBool("QUALFLARE_FAIL_ON_UPLOAD_ERROR") ?? false,
234
242
  attachScreenshots: options.attachScreenshots ?? envBool("QUALFLARE_ATTACH_SCREENSHOTS") ?? true,
235
243
  includeStepHooks: options.includeStepHooks ?? envBool("QUALFLARE_INCLUDE_STEP_HOOKS") ?? false,
236
244
  maxAttachmentBytes: options.maxAttachmentBytes ?? envInt("QUALFLARE_MAX_ATTACHMENT_BYTES") ?? 15e5,
237
245
  maxTotalAttachmentBytes: options.maxTotalAttachmentBytes ?? envInt("QUALFLARE_MAX_TOTAL_ATTACHMENT_BYTES") ?? 75e4,
246
+ maxVideoBytes: options.maxVideoBytes ?? envInt("QUALFLARE_MAX_VIDEO_BYTES") ?? MAX_VIDEO_UPLOAD_BYTES,
238
247
  debug: options.debug ?? envBool("QUALFLARE_DEBUG", "QF_DEBUG") ?? false,
239
- enabled
248
+ enabled,
249
+ outputDir,
250
+ shardIndex
240
251
  };
241
252
  }
242
253
 
243
- // src/http/client.ts
244
- import { request } from "undici";
245
-
246
- // src/shared/constants.ts
247
- var RESERVED_MESSAGE_MEDIA_TYPE = "application/vnd.qualflare.message+json";
248
- var HEADER_TOKEN = "QF_TOKEN";
249
- var HEADER_IDEMPOTENCY_KEY = "Idempotency-Key";
250
- var HEADER_CONTENT_TYPE = "Content-Type";
251
- var HEADER_ACCEPT = "Accept";
252
- var HEADER_USER_AGENT = "User-Agent";
253
- var MAX_SUITES_PER_LAUNCH = 2e3;
254
- var MAX_CASES_PER_SUITE = 5e3;
255
- var MAX_TAGS_PER_CASE = 64;
256
- var MAX_IDEMPOTENCY_KEY_CHARS = 255;
257
- var MAX_STEPS_PER_TEST_ATTEMPT = 300;
258
-
259
254
  // src/shared/logger.ts
260
255
  var PREFIX = "[qualflare-cucumberjs]";
261
256
  var logger = {
@@ -273,220 +268,86 @@ var logger = {
273
268
  }
274
269
  };
275
270
 
276
- // src/http/backoff.ts
277
- function computeDelay(attempt, baseDelayMs, maxDelayMs, retryAfterMs) {
278
- const exponential = baseDelayMs * 2 ** Math.max(0, attempt - 1);
279
- const jittered = Math.random() * Math.min(exponential, maxDelayMs);
280
- const floor = retryAfterMs !== void 0 ? retryAfterMs : 0;
281
- return Math.min(Math.max(jittered, floor), maxDelayMs);
282
- }
271
+ // src/formatter/attachment-budget.ts
272
+ import * as fs2 from "fs";
273
+ import * as path2 from "path";
283
274
 
284
- // src/http/errors.ts
285
- function friendlyHint(code) {
286
- switch (code) {
287
- case "environment.not_found":
288
- return "Environment not found. Check the `environment` option or create it in Qualflare.";
289
- case "milestone.not_found":
290
- return "Milestone not found. Check the `milestone` option or its sequence number in Qualflare.";
291
- case "common.validation_failed":
292
- return "Validation failed. Check the request data below.";
293
- default:
294
- return void 0;
295
- }
296
- }
297
- function actionHint(statusCode) {
298
- switch (statusCode) {
299
- case 401:
300
- return "the configured token is missing or invalid \u2014 check `token`/QUALFLARE_TOKEN";
301
- case 403:
302
- return "the token lacks access to this project";
303
- case 402:
304
- return "a plan limit was reached \u2014 check your Qualflare subscription";
305
- default:
306
- return void 0;
307
- }
275
+ // src/formatter/video-uploader.ts
276
+ import { randomUUID } from "crypto";
277
+ import * as fs from "fs";
278
+ import * as path from "path";
279
+ var VIDEO_MIME_TYPES_BY_EXTENSION = {
280
+ ".mp4": "video/mp4",
281
+ ".webm": "video/webm",
282
+ ".mov": "video/quicktime"
283
+ };
284
+ var EXTENSION_BY_VIDEO_MIME_TYPE = {
285
+ "video/mp4": ".mp4",
286
+ "video/webm": ".webm",
287
+ "video/quicktime": ".mov"
288
+ };
289
+ function resolveVideoMimeType(mimeType, filePath) {
290
+ if (filePath) {
291
+ const extension2 = path.extname(filePath).toLowerCase();
292
+ const resolvedMimeType = VIDEO_MIME_TYPES_BY_EXTENSION[extension2];
293
+ return resolvedMimeType ? { mimeType: resolvedMimeType, extension: extension2 } : void 0;
294
+ }
295
+ const normalized = mimeType?.toLowerCase();
296
+ const extension = normalized ? EXTENSION_BY_VIDEO_MIME_TYPE[normalized] : void 0;
297
+ return normalized && extension ? { mimeType: normalized, extension } : void 0;
308
298
  }
309
- function renderFields(fields) {
310
- if (!fields || fields.length === 0) {
299
+ function writeVideoAttachment(pending, outputDir, maxVideoBytes) {
300
+ const resolved = resolveVideoMimeType(pending.mimeType, pending.path);
301
+ if (!resolved) {
302
+ logger.warn(`skipping video attachment "${pending.name}": unsupported video format.`);
311
303
  return void 0;
312
304
  }
313
- return fields.map((f) => {
314
- const rule = f.rule ? ` (${f.rule})` : "";
315
- const msg = f.message ? `: ${f.message}` : "";
316
- return `${f.field}${rule}${msg}`;
317
- }).join("; ");
318
- }
319
- var QualflareApiError = class extends Error {
320
- code;
321
- statusCode;
322
- requestId;
323
- fields;
324
- constructor(init) {
325
- const parts = [init.message];
326
- const fieldsRendered = renderFields(init.fields);
327
- if (fieldsRendered) {
328
- parts.push(`fields: ${fieldsRendered}`);
305
+ const localVideoPath = `${randomUUID()}${resolved.extension}`;
306
+ const destination = path.join(outputDir, localVideoPath);
307
+ if (pending.path !== void 0) {
308
+ let fileSize;
309
+ try {
310
+ fileSize = fs.statSync(pending.path).size;
311
+ } catch (err) {
312
+ logger.warn(`skipping video attachment "${pending.path}": could not stat file: ${err.message}`);
313
+ return void 0;
329
314
  }
330
- const hint = actionHint(init.statusCode);
331
- if (hint) {
332
- parts.push(`(${hint})`);
315
+ if (fileSize > maxVideoBytes) {
316
+ logger.warn(
317
+ `skipping video attachment "${pending.path}": ${fileSize} bytes exceeds the configured maxVideoBytes cap of ${maxVideoBytes} bytes.`
318
+ );
319
+ return void 0;
333
320
  }
334
- if (init.requestId) {
335
- parts.push(`[request_id: ${init.requestId}]`);
321
+ try {
322
+ fs.mkdirSync(outputDir, { recursive: true });
323
+ fs.copyFileSync(pending.path, destination);
324
+ } catch (err) {
325
+ logger.warn(`skipping video attachment "${pending.path}": could not copy file: ${err.message}`);
326
+ return void 0;
336
327
  }
337
- super(parts.join(" \u2014 "), init.cause !== void 0 ? { cause: init.cause } : void 0);
338
- this.name = "QualflareApiError";
339
- this.code = init.code;
340
- this.statusCode = init.statusCode;
341
- this.requestId = init.requestId;
342
- this.fields = init.fields;
328
+ return { localVideoPath, fileSize, mimeType: resolved.mimeType };
343
329
  }
344
- };
345
- function buildApiError(statusCode, body) {
346
- const code = body?.code;
347
- const message = body?.message || friendlyHint(code) || body?.error || `request failed with status ${statusCode}`;
348
- return new QualflareApiError({
349
- message,
350
- code,
351
- statusCode,
352
- requestId: body?.request_id,
353
- fields: body?.fields
354
- });
355
- }
356
-
357
- // src/http/idempotency.ts
358
- import { randomUUID } from "crypto";
359
- function newIdempotencyKey() {
360
- const key = randomUUID();
361
- if (key.length > MAX_IDEMPOTENCY_KEY_CHARS) {
362
- return key.slice(0, MAX_IDEMPOTENCY_KEY_CHARS);
363
- }
364
- return key;
365
- }
366
-
367
- // src/http/client.ts
368
- var RETRYABLE_STATUS_CODES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
369
- function redactToken(token) {
370
- return token.length > 0 ? "***REDACTED***" : "(none)";
371
- }
372
- var QualflareHttpClient = class {
373
- constructor(opts) {
374
- this.opts = opts;
375
- }
376
- opts;
377
- async send(collect) {
378
- const url = `${this.opts.endpoint.replace(/\/+$/, "")}/api/v1/collect`;
379
- const idempotencyKey = newIdempotencyKey();
380
- const body = JSON.stringify(collect);
381
- const maxAttempts = Math.max(1, this.opts.retry.max + 1);
382
- let lastError;
383
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
384
- if (this.opts.debug) {
385
- logger.debug(
386
- `POST ${url} (attempt ${attempt}/${maxAttempts}, QF_TOKEN: ${redactToken(this.opts.token)})`
387
- );
388
- }
389
- let statusCode;
390
- let responseBody;
391
- let responseHeaders;
392
- try {
393
- const res = await request(url, {
394
- method: "POST",
395
- headers: {
396
- [HEADER_TOKEN]: this.opts.token,
397
- [HEADER_CONTENT_TYPE]: "application/json",
398
- [HEADER_ACCEPT]: "application/json",
399
- [HEADER_USER_AGENT]: this.opts.userAgent,
400
- [HEADER_IDEMPOTENCY_KEY]: idempotencyKey
401
- },
402
- body,
403
- maxRedirections: 0,
404
- signal: AbortSignal.timeout(this.opts.timeoutMs)
405
- });
406
- statusCode = res.statusCode;
407
- responseHeaders = res.headers;
408
- responseBody = await res.body.text();
409
- } catch (err) {
410
- lastError = err;
411
- if (this.opts.debug) {
412
- logger.debug(`attempt ${attempt} transport error: ${err?.message ?? err}`);
413
- }
414
- if (attempt < maxAttempts) {
415
- await sleep(computeDelay(attempt, this.opts.retry.baseDelayMs, this.opts.retry.maxDelayMs));
416
- continue;
417
- }
418
- throw new QualflareApiError({
419
- message: `failed to send request to ${url}`,
420
- cause: err
421
- });
422
- }
423
- if (this.opts.debug) {
424
- logger.debug(`attempt ${attempt} response: ${statusCode}`);
425
- }
426
- if (statusCode >= 200 && statusCode < 300) {
427
- return parseSuccess(responseBody);
428
- }
429
- const parsedError = parseErrorBody(responseBody);
430
- if (!RETRYABLE_STATUS_CODES.has(statusCode) || attempt === maxAttempts) {
431
- throw buildApiError(statusCode, parsedError);
432
- }
433
- const retryAfterMs = parseRetryAfter(responseHeaders["retry-after"]);
434
- const delay = computeDelay(
435
- attempt,
436
- this.opts.retry.baseDelayMs,
437
- this.opts.retry.maxDelayMs,
438
- retryAfterMs
330
+ if (pending.content !== void 0) {
331
+ const fileSize = Buffer.byteLength(pending.content, "base64");
332
+ if (fileSize > maxVideoBytes) {
333
+ logger.warn(
334
+ `skipping video attachment "${pending.name}": ${fileSize} bytes exceeds the configured maxVideoBytes cap of ${maxVideoBytes} bytes.`
439
335
  );
440
- if (this.opts.debug) {
441
- logger.debug(`retrying after ${Math.round(delay)}ms (status ${statusCode})`);
442
- }
443
- await sleep(delay);
336
+ return void 0;
444
337
  }
445
- throw lastError instanceof Error ? lastError : new QualflareApiError({ message: "request failed for an unknown reason" });
446
- }
447
- };
448
- function parseSuccess(responseBody) {
449
- try {
450
- const parsed = JSON.parse(responseBody);
451
- if (typeof parsed.seq !== "number") {
452
- throw new Error('response body missing numeric "seq"');
338
+ try {
339
+ fs.mkdirSync(outputDir, { recursive: true });
340
+ fs.writeFileSync(destination, Buffer.from(pending.content, "base64"));
341
+ } catch (err) {
342
+ logger.warn(`skipping video attachment "${pending.name}": could not write file: ${err.message}`);
343
+ return void 0;
453
344
  }
454
- return parsed;
455
- } catch (err) {
456
- throw new QualflareApiError({
457
- message: "server returned a success status but an unparseable body",
458
- cause: err
459
- });
345
+ return { localVideoPath, fileSize, mimeType: resolved.mimeType };
460
346
  }
347
+ return void 0;
461
348
  }
462
- function parseErrorBody(responseBody) {
463
- if (!responseBody) {
464
- return void 0;
465
- }
466
- try {
467
- return JSON.parse(responseBody);
468
- } catch {
469
- return void 0;
470
- }
471
- }
472
- function parseRetryAfter(value) {
473
- const raw = Array.isArray(value) ? value[0] : value;
474
- if (!raw) {
475
- return void 0;
476
- }
477
- const seconds = Number(raw);
478
- return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : void 0;
479
- }
480
- function sleep(ms) {
481
- return new Promise((resolve) => setTimeout(resolve, ms));
482
- }
483
-
484
- // src/config/version.ts
485
- var PACKAGE_VERSION = "0.1.0";
486
349
 
487
350
  // src/formatter/attachment-budget.ts
488
- import * as fs from "fs";
489
- import * as path from "path";
490
351
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([".mp4", ".webm", ".mov", ".avi", ".mkv"]);
491
352
  var AttachmentBudget = class {
492
353
  constructor(maxTotalBytes) {
@@ -511,7 +372,7 @@ function isVideoLike(mimeType, filePath) {
511
372
  if (mimeType?.toLowerCase().startsWith("video/")) {
512
373
  return true;
513
374
  }
514
- if (filePath && VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase())) {
375
+ if (filePath && VIDEO_EXTENSIONS.has(path2.extname(filePath).toLowerCase())) {
515
376
  return true;
516
377
  }
517
378
  return false;
@@ -519,7 +380,7 @@ function isVideoLike(mimeType, filePath) {
519
380
  function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
520
381
  let size;
521
382
  try {
522
- size = fs.statSync(filePath).size;
383
+ size = fs2.statSync(filePath).size;
523
384
  } catch (err) {
524
385
  return { skipped: true, reason: `could not stat file: ${err.message}` };
525
386
  }
@@ -536,7 +397,7 @@ function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
536
397
  };
537
398
  }
538
399
  try {
539
- const content = fs.readFileSync(filePath).toString("base64");
400
+ const content = fs2.readFileSync(filePath).toString("base64");
540
401
  return { skipped: false, content };
541
402
  } catch (err) {
542
403
  return { skipped: true, reason: `could not read file: ${err.message}` };
@@ -546,12 +407,6 @@ function resolvePendingAttachment(pending, config, budget) {
546
407
  if (!config.attachScreenshots) {
547
408
  return void 0;
548
409
  }
549
- if (isVideoLike(pending.mimeType, pending.path)) {
550
- logger.warn(
551
- `refusing to attach "${pending.name}": video attachments are not supported yet (${pending.path ?? pending.mimeType ?? "unknown"}).`
552
- );
553
- return void 0;
554
- }
555
410
  if (pending.content !== void 0) {
556
411
  const bytes = Buffer.byteLength(pending.content, "base64");
557
412
  if (bytes > config.maxAttachmentBytes) {
@@ -584,6 +439,22 @@ function resolvePendingAttachment(pending, config, budget) {
584
439
  }
585
440
  return void 0;
586
441
  }
442
+ async function resolveVideoAttachment(pending, config) {
443
+ if (!config.attachScreenshots) {
444
+ return void 0;
445
+ }
446
+ const written = writeVideoAttachment(pending, config.outputDir, config.maxVideoBytes);
447
+ if (!written) {
448
+ return void 0;
449
+ }
450
+ return {
451
+ name: pending.name,
452
+ mimeType: written.mimeType,
453
+ localVideoPath: written.localVideoPath,
454
+ fileSize: written.fileSize,
455
+ stepIndex: pending.stepIndex
456
+ };
457
+ }
587
458
 
588
459
  // src/formatter/attempt-tracker.ts
589
460
  import {
@@ -832,7 +703,8 @@ var AttemptTracker = class {
832
703
  tags: [],
833
704
  properties: {},
834
705
  attachments: [],
835
- stepCapWarned: false
706
+ stepCapWarned: false,
707
+ pendingVideoWrites: []
836
708
  };
837
709
  this.byTestCaseStartedId.set(e.id, record);
838
710
  const attempts = this.byTestCaseId.get(testCase.id) ?? [];
@@ -910,11 +782,25 @@ var AttemptTracker = class {
910
782
  }
911
783
  const stepIndex = this.resolveStepIndex(record, e.testStepId);
912
784
  const content = e.contentEncoding === "BASE64" ? e.body : Buffer.from(e.body, "utf8").toString("base64");
913
- const resolved = resolvePendingAttachment(
914
- { name: e.fileName || "attachment", mimeType: e.mediaType, content, stepIndex },
915
- this.config,
916
- this.attachmentBudget
917
- );
785
+ this.resolveAttachment(record, { name: e.fileName || "attachment", mimeType: e.mediaType, content, stepIndex });
786
+ }
787
+ /** Resolves one pending attachment, routing a video-like one through the
788
+ * write-to-`outputDir` flow (tracked in `record.pendingVideoWrites` so
789
+ * `finish()` can wait for it) and everything else through the synchronous
790
+ * inline path — shared by the real `World.attach()` handler above and both
791
+ * `qualflare.attachment()`/`attachmentFromFile()` runtime-message cases
792
+ * below. */
793
+ resolveAttachment(record, pending) {
794
+ if (isVideoLike(pending.mimeType, pending.path)) {
795
+ const write = resolveVideoAttachment(pending, this.config).then((resolved2) => {
796
+ if (resolved2) {
797
+ record.attachments.push(resolved2);
798
+ }
799
+ });
800
+ record.pendingVideoWrites.push(write);
801
+ return;
802
+ }
803
+ const resolved = resolvePendingAttachment(pending, this.config, this.attachmentBudget);
918
804
  if (resolved) {
919
805
  record.attachments.push(resolved);
920
806
  }
@@ -971,26 +857,12 @@ var AttemptTracker = class {
971
857
  }
972
858
  case "attachment": {
973
859
  const stepIndex = testStepId !== void 0 ? record.stepIndexByTestStepId.get(testStepId) : void 0;
974
- const resolved = resolvePendingAttachment(
975
- { name: message.name, mimeType: message.mimeType, content: message.contentBase64, stepIndex },
976
- this.config,
977
- this.attachmentBudget
978
- );
979
- if (resolved) {
980
- record.attachments.push(resolved);
981
- }
860
+ this.resolveAttachment(record, { name: message.name, mimeType: message.mimeType, content: message.contentBase64, stepIndex });
982
861
  return;
983
862
  }
984
863
  case "attachment_from_file": {
985
864
  const stepIndex = testStepId !== void 0 ? record.stepIndexByTestStepId.get(testStepId) : void 0;
986
- const resolved = resolvePendingAttachment(
987
- { name: message.name, mimeType: message.mimeType, path: message.path, stepIndex },
988
- this.config,
989
- this.attachmentBudget
990
- );
991
- if (resolved) {
992
- record.attachments.push(resolved);
993
- }
865
+ this.resolveAttachment(record, { name: message.name, mimeType: message.mimeType, path: message.path, stepIndex });
994
866
  return;
995
867
  }
996
868
  case "step_start": {
@@ -1015,8 +887,21 @@ var AttemptTracker = class {
1015
887
  }
1016
888
  /** Returns the collapsed result once all attempts of this logical
1017
889
  * scenario have arrived, or `undefined` if more attempts are coming
1018
- * (`willBeRetried === true`). */
1019
- finish(e) {
890
+ * (`willBeRetried === true`).
891
+ *
892
+ * Async because it must first await every attempt's own
893
+ * `pendingVideoWrites` (any video attached anywhere across every retry
894
+ * of this scenario). This is load-bearing, not just tidiness:
895
+ * `collapseAttempts`/`buildCase` read `attachments` by REFERENCE, not by
896
+ * copy, and `buildCase` runs synchronously right after this resolves — a
897
+ * scenario whose ONLY attachment is a still-uploading video would have an
898
+ * EMPTY `attachments` array at that instant, and `buildCase` captures
899
+ * `attachments.length > 0 ? attachments : undefined` as a plain
900
+ * `undefined` VALUE right then, permanently — a later push onto the
901
+ * (still-live) array reference would no longer be visible through
902
+ * `undefined`. Awaiting here first guarantees `attachments` is complete
903
+ * before `buildCase` ever reads it. */
904
+ async finish(e) {
1020
905
  const record = this.byTestCaseStartedId.get(e.testCaseStartedId);
1021
906
  this.byTestCaseStartedId.delete(e.testCaseStartedId);
1022
907
  if (!record) {
@@ -1028,6 +913,10 @@ var AttemptTracker = class {
1028
913
  const testCaseId = record.testCase.id;
1029
914
  const attempts = this.byTestCaseId.get(testCaseId) ?? [record];
1030
915
  this.byTestCaseId.delete(testCaseId);
916
+ const pendingWrites = attempts.flatMap((a) => a.pendingVideoWrites);
917
+ if (pendingWrites.length > 0) {
918
+ await Promise.all(pendingWrites);
919
+ }
1031
920
  const snapshots = attempts.map((a) => {
1032
921
  const worst = a.stepResults.length > 0 ? getWorstTestStepResult(a.stepResults) : void 0;
1033
922
  const duration = a.stepResults.reduce((sum, r) => sum + messageDurationToNs(r.duration), 0);
@@ -1062,6 +951,11 @@ function timestampMs(ts) {
1062
951
 
1063
952
  // src/formatter/collect-builder.ts
1064
953
  import * as os from "os";
954
+
955
+ // src/config/version.ts
956
+ var PACKAGE_VERSION = "0.2.0";
957
+
958
+ // src/formatter/collect-builder.ts
1065
959
  function resolveOs(config) {
1066
960
  if (config.os) {
1067
961
  return config.os;
@@ -1221,7 +1115,7 @@ var RunHookTracker = class {
1221
1115
  }
1222
1116
  return {
1223
1117
  name: "(global hooks)",
1224
- category: "bdd",
1118
+ category: "cucumber",
1225
1119
  duration: this.failed.reduce((sum, c) => sum + c.duration, 0),
1226
1120
  cases: this.failed
1227
1121
  };
@@ -1229,7 +1123,7 @@ var RunHookTracker = class {
1229
1123
  };
1230
1124
 
1231
1125
  // src/formatter/suite-builder.ts
1232
- import * as path2 from "path";
1126
+ import * as path3 from "path";
1233
1127
  function groupIntoSuites(cases, cwd, extraSuite) {
1234
1128
  const byUri = /* @__PURE__ */ new Map();
1235
1129
  for (const { uri, case: kase } of cases) {
@@ -1249,7 +1143,7 @@ function groupIntoSuites(cases, cwd, extraSuite) {
1249
1143
  }
1250
1144
  suites.push({
1251
1145
  name: relativizeUri(uri, cwd),
1252
- category: "bdd",
1146
+ category: "cucumber",
1253
1147
  duration: kases.reduce((sum, c) => sum + c.duration, 0),
1254
1148
  cases: kases.slice(0, MAX_CASES_PER_SUITE)
1255
1149
  });
@@ -1269,10 +1163,10 @@ function relativizeUri(uri, cwd) {
1269
1163
  if (normalized.startsWith("file://")) {
1270
1164
  normalized = new URL(normalized).pathname;
1271
1165
  }
1272
- if (path2.isAbsolute(normalized)) {
1273
- normalized = path2.relative(cwd, normalized);
1166
+ if (path3.isAbsolute(normalized)) {
1167
+ normalized = path3.relative(cwd, normalized);
1274
1168
  }
1275
- return normalized.split(path2.sep).join("/");
1169
+ return normalized.split(path3.sep).join("/");
1276
1170
  }
1277
1171
 
1278
1172
  // src/formatter/formatter.ts
@@ -1286,12 +1180,25 @@ var QualflareCucumberFormatter = class extends Formatter {
1286
1180
  attemptTracker;
1287
1181
  runHookTracker = new RunHookTracker();
1288
1182
  finishedCases = [];
1183
+ /** One promise per `testCaseFinished` envelope, resolving once that
1184
+ * scenario's `AttemptTracker.finish()` (which itself awaits any pending
1185
+ * video uploads — see its doc comment) has settled and, if it produced a
1186
+ * result, been pushed into `finishedCases`. `finished()` awaits all of
1187
+ * these before building/uploading the Collect payload, so a scenario
1188
+ * whose only attachment is a still-uploading video is never silently
1189
+ * dropped from the report. */
1190
+ pendingCaseBuilds = [];
1289
1191
  constructor(options) {
1290
1192
  super(options);
1291
1193
  this.config = resolveConfig(options.parsedArgvOptions);
1292
1194
  this.hookIndex = buildHookIndex(options.supportCodeLibrary);
1293
1195
  this.attachmentBudget = new AttachmentBudget(this.config.maxTotalAttachmentBytes);
1294
- this.attemptTracker = new AttemptTracker(this.hookIndex, this.gherkin, this.config, this.attachmentBudget);
1196
+ this.attemptTracker = new AttemptTracker(
1197
+ this.hookIndex,
1198
+ this.gherkin,
1199
+ this.config,
1200
+ this.attachmentBudget
1201
+ );
1295
1202
  if (!this.config.enabled) {
1296
1203
  return;
1297
1204
  }
@@ -1342,15 +1249,20 @@ var QualflareCucumberFormatter = class extends Formatter {
1342
1249
  return;
1343
1250
  }
1344
1251
  if (envelope.testCaseFinished) {
1345
- const finished = this.attemptTracker.finish(envelope.testCaseFinished);
1346
- if (finished) {
1252
+ const pending = this.attemptTracker.finish(envelope.testCaseFinished).then((finished) => {
1253
+ if (!finished) {
1254
+ return;
1255
+ }
1347
1256
  const pickle = this.pickleIndex.get(finished.pickleId);
1348
1257
  if (pickle) {
1349
1258
  this.finishedCases.push(buildCase(finished.uri, pickle, finished.collapsed, this.gherkin));
1350
1259
  } else {
1351
1260
  logger.warn(`could not resolve pickle "${finished.pickleId}" for a finished scenario \u2014 it will not be uploaded.`);
1352
1261
  }
1353
- }
1262
+ }).catch((err) => {
1263
+ logger.error("failed to process a cucumber-js event:", err);
1264
+ });
1265
+ this.pendingCaseBuilds.push(pending);
1354
1266
  return;
1355
1267
  }
1356
1268
  if (envelope.testRunHookStarted) {
@@ -1367,41 +1279,41 @@ var QualflareCucumberFormatter = class extends Formatter {
1367
1279
  }
1368
1280
  async finished() {
1369
1281
  try {
1282
+ await Promise.all(this.pendingCaseBuilds);
1370
1283
  if (this.config.enabled) {
1371
- await this.uploadResults();
1284
+ this.writeResults();
1372
1285
  }
1373
1286
  } finally {
1374
1287
  await super.finished();
1375
1288
  }
1376
1289
  }
1377
- async uploadResults() {
1290
+ /** Writes this process's Collect payload into `outputDir` under a unique
1291
+ * filename. Never uploads: `qualflare-cli collect <outputDir>` does that,
1292
+ * merging every file it finds there into one Launch. Multiple shards can
1293
+ * therefore share one directory safely — the UUID filename is what keeps
1294
+ * them from overwriting each other. */
1295
+ writeResults() {
1378
1296
  const suites = groupIntoSuites(this.finishedCases, this.cwd, this.runHookTracker.buildSuite());
1379
1297
  if (suites.length === 0) {
1380
1298
  if (this.config.debug) {
1381
- logger.debug("no scenarios reported \u2014 skipping upload.");
1299
+ logger.debug("no scenarios reported \u2014 skipping file write.");
1382
1300
  }
1383
1301
  return;
1384
1302
  }
1385
1303
  const payload = buildCollectPayload(suites, this.config);
1386
- const client = new QualflareHttpClient({
1387
- endpoint: this.config.apiEndpoint,
1388
- token: this.config.token,
1389
- timeoutMs: this.config.timeoutMs,
1390
- retry: this.config.retry,
1391
- userAgent: `qualflare-cucumberjs/${PACKAGE_VERSION}`,
1392
- debug: this.config.debug
1393
- });
1394
- try {
1395
- const result = await client.send(payload);
1396
- if (this.config.debug) {
1397
- logger.debug(`uploaded launch #${result.seq}.`);
1398
- }
1399
- } catch (err) {
1400
- if (this.config.failOnUploadError) {
1401
- throw err;
1304
+ if (this.config.shardIndex !== void 0) {
1305
+ for (const suite of payload.suites) {
1306
+ for (const c of suite.cases) {
1307
+ c.shardIndex = this.config.shardIndex;
1308
+ }
1402
1309
  }
1403
- logger.error("failed to upload results to Qualflare:", err);
1404
1310
  }
1311
+ fs3.mkdirSync(this.config.outputDir, { recursive: true });
1312
+ const outputPath = path4.join(this.config.outputDir, `${randomUUID2()}.json`);
1313
+ fs3.writeFileSync(outputPath, JSON.stringify(payload));
1314
+ logger.info(
1315
+ `wrote Collect payload to ${outputPath} \u2014 run \`qualflare-cli collect ${this.config.outputDir}\` to upload it.`
1316
+ );
1405
1317
  }
1406
1318
  };
1407
1319
  export {