@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.
@@ -1,6 +1,20 @@
1
1
  // src/formatter/formatter.ts
2
+ import { randomUUID as randomUUID3 } 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/config/resolve-config.ts
8
+ import { randomUUID } from "crypto";
9
+
10
+ // src/shared/constants.ts
11
+ var RESERVED_MESSAGE_MEDIA_TYPE = "application/vnd.qualflare.message+json";
12
+ var MAX_SUITES_PER_LAUNCH = 2e3;
13
+ var MAX_CASES_PER_SUITE = 5e3;
14
+ var MAX_TAGS_PER_CASE = 64;
15
+ var MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;
16
+ var MAX_STEPS_PER_TEST_ATTEMPT = 300;
17
+
4
18
  // src/config/ci-detect.ts
5
19
  import * as ciInfo from "ci-info";
6
20
  function parsePositiveInt(raw) {
@@ -18,6 +32,7 @@ var PROVIDERS = [
18
32
  detect: (env) => env.GITHUB_ACTIONS === "true",
19
33
  providerName: "GitHub Actions",
20
34
  buildNumber: (env) => nonEmpty(env.GITHUB_RUN_NUMBER),
35
+ runId: (env) => nonEmpty(env.GITHUB_RUN_ID),
21
36
  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,
22
37
  prNumber: (env) => {
23
38
  const match = /^refs\/pull\/(\d+)\/merge$/.exec(env.GITHUB_REF ?? "");
@@ -28,6 +43,7 @@ var PROVIDERS = [
28
43
  detect: (env) => env.GITLAB_CI === "true",
29
44
  providerName: "GitLab CI",
30
45
  buildNumber: (env) => nonEmpty(env.CI_PIPELINE_IID),
46
+ runId: (env) => nonEmpty(env.CI_PIPELINE_ID),
31
47
  runUrl: (env) => nonEmpty(env.CI_PIPELINE_URL),
32
48
  prNumber: (env) => parsePositiveInt(env.CI_MERGE_REQUEST_IID)
33
49
  },
@@ -35,6 +51,7 @@ var PROVIDERS = [
35
51
  detect: (env) => env.CIRCLECI === "true",
36
52
  providerName: "CircleCI",
37
53
  buildNumber: (env) => nonEmpty(env.CIRCLE_BUILD_NUM),
54
+ runId: (env) => nonEmpty(env.CIRCLE_WORKFLOW_ID ?? env.CIRCLE_BUILD_NUM),
38
55
  runUrl: (env) => nonEmpty(env.CIRCLE_BUILD_URL),
39
56
  prNumber: (env) => parsePositiveInt(env.CIRCLE_PR_NUMBER)
40
57
  },
@@ -42,6 +59,7 @@ var PROVIDERS = [
42
59
  detect: (env) => env.BUILDKITE === "true",
43
60
  providerName: "Buildkite",
44
61
  buildNumber: (env) => nonEmpty(env.BUILDKITE_BUILD_NUMBER),
62
+ runId: (env) => nonEmpty(env.BUILDKITE_BUILD_ID),
45
63
  runUrl: (env) => nonEmpty(env.BUILDKITE_BUILD_URL),
46
64
  prNumber: (env) => {
47
65
  const raw = env.BUILDKITE_PULL_REQUEST;
@@ -57,6 +75,7 @@ var PROVIDERS = [
57
75
  detect: (env) => Boolean(env.JENKINS_URL),
58
76
  providerName: "Jenkins",
59
77
  buildNumber: (env) => nonEmpty(env.BUILD_NUMBER),
78
+ runId: (env) => nonEmpty(env.BUILD_TAG ?? env.BUILD_NUMBER),
60
79
  runUrl: (env) => nonEmpty(env.BUILD_URL)
61
80
  // Jenkins has no standardized PR-number env var across its many PR
62
81
  // plugins (Multibranch, GitHub Branch Source, etc.) — deliberately
@@ -66,6 +85,7 @@ var PROVIDERS = [
66
85
  detect: (env) => env.TF_BUILD === "True" || env.TF_BUILD === "true",
67
86
  providerName: "Azure Pipelines",
68
87
  buildNumber: (env) => nonEmpty(env.BUILD_BUILDID),
88
+ runId: (env) => nonEmpty(env.BUILD_BUILDID),
69
89
  runUrl: (env) => {
70
90
  const collectionUri = env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI;
71
91
  const project = env.SYSTEM_TEAMPROJECT;
@@ -81,6 +101,7 @@ var PROVIDERS = [
81
101
  detect: (env) => Boolean(env.BITBUCKET_BUILD_NUMBER),
82
102
  providerName: "Bitbucket Pipelines",
83
103
  buildNumber: (env) => nonEmpty(env.BITBUCKET_BUILD_NUMBER),
104
+ runId: (env) => nonEmpty(env.BITBUCKET_BUILD_NUMBER),
84
105
  runUrl: (env) => {
85
106
  const origin = env.BITBUCKET_GIT_HTTP_ORIGIN;
86
107
  if (!origin) {
@@ -102,6 +123,8 @@ function detectCi(env = process.env) {
102
123
  if (runUrl !== void 0) result.ciRunUrl = runUrl;
103
124
  const prNumber = provider.prNumber?.(env);
104
125
  if (prNumber !== void 0) result.ciPrNumber = prNumber;
126
+ const runId = provider.runId?.(env);
127
+ if (runId !== void 0) result.ciRunId = runId;
105
128
  return result;
106
129
  }
107
130
  if (ciInfo.name) {
@@ -172,22 +195,30 @@ function envInt(...names) {
172
195
  const parsed = Number.parseInt(raw, 10);
173
196
  return Number.isFinite(parsed) ? parsed : void 0;
174
197
  }
175
- var QualflareConfigError = class extends Error {
176
- constructor(message) {
177
- super(message);
178
- this.name = "QualflareConfigError";
198
+ function argvShardIndex(argv = process.argv) {
199
+ for (let i = 0; i < argv.length; i += 1) {
200
+ const arg = argv[i];
201
+ if (arg === void 0) {
202
+ continue;
203
+ }
204
+ const raw = arg === "--shard" ? argv[i + 1] : arg.startsWith("--shard=") ? arg.slice("--shard=".length) : void 0;
205
+ if (raw === void 0) {
206
+ continue;
207
+ }
208
+ if (!/^\d+\/\d+$/.test(raw)) {
209
+ return void 0;
210
+ }
211
+ const oneBased = Number.parseInt(raw.split("/")[0] ?? "", 10);
212
+ return Number.isFinite(oneBased) && oneBased >= 1 ? oneBased - 1 : void 0;
179
213
  }
180
- };
214
+ return void 0;
215
+ }
181
216
  function resolveConfig(options, deps = {}) {
182
217
  const doDetectGit = deps.detectGit ?? detectGit;
183
218
  const doDetectCi = deps.detectCi ?? detectCi;
184
219
  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
- }
220
+ const outputDir = options.outputDir || firstEnv2("QUALFLARE_OUTPUT_DIR") || "./qualflare-results";
221
+ const shardIndex = options.shardIndex ?? envInt("QUALFLARE_SHARD_INDEX") ?? argvShardIndex();
191
222
  const milestoneRaw = options.milestone !== void 0 ? options.milestone : envInt("QUALFLARE_MILESTONE", "QF_MILESTONE");
192
223
  const milestone = milestoneRaw !== void 0 && milestoneRaw !== null && milestoneRaw >= 1 ? milestoneRaw : null;
193
224
  const envBranch = firstEnv2("QUALFLARE_BRANCH", "QF_BRANCH");
@@ -201,14 +232,11 @@ function resolveConfig(options, deps = {}) {
201
232
  const ciBuildNumber = options.ciBuildNumber ?? detectedCi.ciBuildNumber;
202
233
  const ciRunUrl = options.ciRunUrl ?? detectedCi.ciRunUrl;
203
234
  const ciPrNumber = options.ciPrNumber ?? detectedCi.ciPrNumber;
235
+ const runId = options.runId ?? firstEnv2("QUALFLARE_RUN_ID") ?? detectedCi.ciRunId ?? randomUUID();
204
236
  return {
205
- token,
206
- apiEndpoint: options.apiEndpoint ?? firstEnv2("QUALFLARE_API_ENDPOINT") ?? "https://api.qualflare.com",
207
237
  // `||` (truthy check), not `??`, for these three REQUIRED-non-empty wire
208
238
  // 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
239
+ // default (the server rejects an empty `environment`). Ported verbatim from
212
240
  // qualflare-cypress, where this was found via deep adversarial review.
213
241
  environment: (options.environment || void 0) ?? firstEnv2("QUALFLARE_ENVIRONMENT", "QF_ENVIRONMENT") ?? "development",
214
242
  language: (options.language || void 0) ?? firstEnv2("QUALFLARE_LANGUAGE", "QF_LANGUAGE") ?? "en-US",
@@ -224,38 +252,19 @@ function resolveConfig(options, deps = {}) {
224
252
  ciBuildNumber,
225
253
  ciRunUrl,
226
254
  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,
255
+ runId,
234
256
  attachScreenshots: options.attachScreenshots ?? envBool("QUALFLARE_ATTACH_SCREENSHOTS") ?? true,
235
257
  includeStepHooks: options.includeStepHooks ?? envBool("QUALFLARE_INCLUDE_STEP_HOOKS") ?? false,
236
258
  maxAttachmentBytes: options.maxAttachmentBytes ?? envInt("QUALFLARE_MAX_ATTACHMENT_BYTES") ?? 15e5,
237
259
  maxTotalAttachmentBytes: options.maxTotalAttachmentBytes ?? envInt("QUALFLARE_MAX_TOTAL_ATTACHMENT_BYTES") ?? 75e4,
260
+ maxVideoBytes: options.maxVideoBytes ?? envInt("QUALFLARE_MAX_VIDEO_BYTES") ?? MAX_VIDEO_UPLOAD_BYTES,
238
261
  debug: options.debug ?? envBool("QUALFLARE_DEBUG", "QF_DEBUG") ?? false,
239
- enabled
262
+ enabled,
263
+ outputDir,
264
+ shardIndex
240
265
  };
241
266
  }
242
267
 
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
268
  // src/shared/logger.ts
260
269
  var PREFIX = "[qualflare-cucumberjs]";
261
270
  var logger = {
@@ -273,220 +282,86 @@ var logger = {
273
282
  }
274
283
  };
275
284
 
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
- }
285
+ // src/formatter/attachment-budget.ts
286
+ import * as fs2 from "fs";
287
+ import * as path2 from "path";
283
288
 
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
- }
289
+ // src/formatter/video-writer.ts
290
+ import { randomUUID as randomUUID2 } from "crypto";
291
+ import * as fs from "fs";
292
+ import * as path from "path";
293
+ var VIDEO_MIME_TYPES_BY_EXTENSION = {
294
+ ".mp4": "video/mp4",
295
+ ".webm": "video/webm",
296
+ ".mov": "video/quicktime"
297
+ };
298
+ var EXTENSION_BY_VIDEO_MIME_TYPE = {
299
+ "video/mp4": ".mp4",
300
+ "video/webm": ".webm",
301
+ "video/quicktime": ".mov"
302
+ };
303
+ function resolveVideoMimeType(mimeType, filePath) {
304
+ if (filePath) {
305
+ const extension2 = path.extname(filePath).toLowerCase();
306
+ const resolvedMimeType = VIDEO_MIME_TYPES_BY_EXTENSION[extension2];
307
+ return resolvedMimeType ? { mimeType: resolvedMimeType, extension: extension2 } : void 0;
308
+ }
309
+ const normalized = mimeType?.toLowerCase();
310
+ const extension = normalized ? EXTENSION_BY_VIDEO_MIME_TYPE[normalized] : void 0;
311
+ return normalized && extension ? { mimeType: normalized, extension } : void 0;
308
312
  }
309
- function renderFields(fields) {
310
- if (!fields || fields.length === 0) {
313
+ function writeVideoAttachment(pending, outputDir, maxVideoBytes) {
314
+ const resolved = resolveVideoMimeType(pending.mimeType, pending.path);
315
+ if (!resolved) {
316
+ logger.warn(`skipping video attachment "${pending.name}": unsupported video format.`);
311
317
  return void 0;
312
318
  }
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}`);
319
+ const localVideoPath = `${randomUUID2()}${resolved.extension}`;
320
+ const destination = path.join(outputDir, localVideoPath);
321
+ if (pending.path !== void 0) {
322
+ let fileSize;
323
+ try {
324
+ fileSize = fs.statSync(pending.path).size;
325
+ } catch (err) {
326
+ logger.warn(`skipping video attachment "${pending.path}": could not stat file: ${err.message}`);
327
+ return void 0;
329
328
  }
330
- const hint = actionHint(init.statusCode);
331
- if (hint) {
332
- parts.push(`(${hint})`);
329
+ if (fileSize > maxVideoBytes) {
330
+ logger.warn(
331
+ `skipping video attachment "${pending.path}": ${fileSize} bytes exceeds the configured maxVideoBytes cap of ${maxVideoBytes} bytes.`
332
+ );
333
+ return void 0;
333
334
  }
334
- if (init.requestId) {
335
- parts.push(`[request_id: ${init.requestId}]`);
335
+ try {
336
+ fs.mkdirSync(outputDir, { recursive: true });
337
+ fs.copyFileSync(pending.path, destination);
338
+ } catch (err) {
339
+ logger.warn(`skipping video attachment "${pending.path}": could not copy file: ${err.message}`);
340
+ return void 0;
336
341
  }
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;
342
+ return { localVideoPath, fileSize, mimeType: resolved.mimeType };
343
343
  }
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
344
+ if (pending.content !== void 0) {
345
+ const fileSize = Buffer.byteLength(pending.content, "base64");
346
+ if (fileSize > maxVideoBytes) {
347
+ logger.warn(
348
+ `skipping video attachment "${pending.name}": ${fileSize} bytes exceeds the configured maxVideoBytes cap of ${maxVideoBytes} bytes.`
439
349
  );
440
- if (this.opts.debug) {
441
- logger.debug(`retrying after ${Math.round(delay)}ms (status ${statusCode})`);
442
- }
443
- await sleep(delay);
350
+ return void 0;
444
351
  }
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"');
352
+ try {
353
+ fs.mkdirSync(outputDir, { recursive: true });
354
+ fs.writeFileSync(destination, Buffer.from(pending.content, "base64"));
355
+ } catch (err) {
356
+ logger.warn(`skipping video attachment "${pending.name}": could not write file: ${err.message}`);
357
+ return void 0;
453
358
  }
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
- });
460
- }
461
- }
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;
359
+ return { localVideoPath, fileSize, mimeType: resolved.mimeType };
470
360
  }
361
+ return void 0;
471
362
  }
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
363
 
487
364
  // src/formatter/attachment-budget.ts
488
- import * as fs from "fs";
489
- import * as path from "path";
490
365
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([".mp4", ".webm", ".mov", ".avi", ".mkv"]);
491
366
  var AttachmentBudget = class {
492
367
  constructor(maxTotalBytes) {
@@ -511,7 +386,7 @@ function isVideoLike(mimeType, filePath) {
511
386
  if (mimeType?.toLowerCase().startsWith("video/")) {
512
387
  return true;
513
388
  }
514
- if (filePath && VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase())) {
389
+ if (filePath && VIDEO_EXTENSIONS.has(path2.extname(filePath).toLowerCase())) {
515
390
  return true;
516
391
  }
517
392
  return false;
@@ -519,7 +394,7 @@ function isVideoLike(mimeType, filePath) {
519
394
  function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
520
395
  let size;
521
396
  try {
522
- size = fs.statSync(filePath).size;
397
+ size = fs2.statSync(filePath).size;
523
398
  } catch (err) {
524
399
  return { skipped: true, reason: `could not stat file: ${err.message}` };
525
400
  }
@@ -536,7 +411,7 @@ function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
536
411
  };
537
412
  }
538
413
  try {
539
- const content = fs.readFileSync(filePath).toString("base64");
414
+ const content = fs2.readFileSync(filePath).toString("base64");
540
415
  return { skipped: false, content };
541
416
  } catch (err) {
542
417
  return { skipped: true, reason: `could not read file: ${err.message}` };
@@ -546,12 +421,6 @@ function resolvePendingAttachment(pending, config, budget) {
546
421
  if (!config.attachScreenshots) {
547
422
  return void 0;
548
423
  }
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
424
  if (pending.content !== void 0) {
556
425
  const bytes = Buffer.byteLength(pending.content, "base64");
557
426
  if (bytes > config.maxAttachmentBytes) {
@@ -584,6 +453,22 @@ function resolvePendingAttachment(pending, config, budget) {
584
453
  }
585
454
  return void 0;
586
455
  }
456
+ async function resolveVideoAttachment(pending, config) {
457
+ if (!config.attachScreenshots) {
458
+ return void 0;
459
+ }
460
+ const written = writeVideoAttachment(pending, config.outputDir, config.maxVideoBytes);
461
+ if (!written) {
462
+ return void 0;
463
+ }
464
+ return {
465
+ name: pending.name,
466
+ mimeType: written.mimeType,
467
+ localVideoPath: written.localVideoPath,
468
+ fileSize: written.fileSize,
469
+ stepIndex: pending.stepIndex
470
+ };
471
+ }
587
472
 
588
473
  // src/formatter/attempt-tracker.ts
589
474
  import {
@@ -832,7 +717,8 @@ var AttemptTracker = class {
832
717
  tags: [],
833
718
  properties: {},
834
719
  attachments: [],
835
- stepCapWarned: false
720
+ stepCapWarned: false,
721
+ pendingVideoWrites: []
836
722
  };
837
723
  this.byTestCaseStartedId.set(e.id, record);
838
724
  const attempts = this.byTestCaseId.get(testCase.id) ?? [];
@@ -910,11 +796,25 @@ var AttemptTracker = class {
910
796
  }
911
797
  const stepIndex = this.resolveStepIndex(record, e.testStepId);
912
798
  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
- );
799
+ this.resolveAttachment(record, { name: e.fileName || "attachment", mimeType: e.mediaType, content, stepIndex });
800
+ }
801
+ /** Resolves one pending attachment, routing a video-like one through the
802
+ * write-to-`outputDir` flow (tracked in `record.pendingVideoWrites` so
803
+ * `finish()` can wait for it) and everything else through the synchronous
804
+ * inline path — shared by the real `World.attach()` handler above and both
805
+ * `qualflare.attachment()`/`attachmentFromFile()` runtime-message cases
806
+ * below. */
807
+ resolveAttachment(record, pending) {
808
+ if (isVideoLike(pending.mimeType, pending.path)) {
809
+ const write = resolveVideoAttachment(pending, this.config).then((resolved2) => {
810
+ if (resolved2) {
811
+ record.attachments.push(resolved2);
812
+ }
813
+ });
814
+ record.pendingVideoWrites.push(write);
815
+ return;
816
+ }
817
+ const resolved = resolvePendingAttachment(pending, this.config, this.attachmentBudget);
918
818
  if (resolved) {
919
819
  record.attachments.push(resolved);
920
820
  }
@@ -971,26 +871,12 @@ var AttemptTracker = class {
971
871
  }
972
872
  case "attachment": {
973
873
  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
- }
874
+ this.resolveAttachment(record, { name: message.name, mimeType: message.mimeType, content: message.contentBase64, stepIndex });
982
875
  return;
983
876
  }
984
877
  case "attachment_from_file": {
985
878
  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
- }
879
+ this.resolveAttachment(record, { name: message.name, mimeType: message.mimeType, path: message.path, stepIndex });
994
880
  return;
995
881
  }
996
882
  case "step_start": {
@@ -1015,8 +901,21 @@ var AttemptTracker = class {
1015
901
  }
1016
902
  /** Returns the collapsed result once all attempts of this logical
1017
903
  * scenario have arrived, or `undefined` if more attempts are coming
1018
- * (`willBeRetried === true`). */
1019
- finish(e) {
904
+ * (`willBeRetried === true`).
905
+ *
906
+ * Async because it must first await every attempt's own
907
+ * `pendingVideoWrites` (any video attached anywhere across every retry
908
+ * of this scenario). This is load-bearing, not just tidiness:
909
+ * `collapseAttempts`/`buildCase` read `attachments` by REFERENCE, not by
910
+ * copy, and `buildCase` runs synchronously right after this resolves — a
911
+ * scenario whose ONLY attachment is a still-uploading video would have an
912
+ * EMPTY `attachments` array at that instant, and `buildCase` captures
913
+ * `attachments.length > 0 ? attachments : undefined` as a plain
914
+ * `undefined` VALUE right then, permanently — a later push onto the
915
+ * (still-live) array reference would no longer be visible through
916
+ * `undefined`. Awaiting here first guarantees `attachments` is complete
917
+ * before `buildCase` ever reads it. */
918
+ async finish(e) {
1020
919
  const record = this.byTestCaseStartedId.get(e.testCaseStartedId);
1021
920
  this.byTestCaseStartedId.delete(e.testCaseStartedId);
1022
921
  if (!record) {
@@ -1028,6 +927,10 @@ var AttemptTracker = class {
1028
927
  const testCaseId = record.testCase.id;
1029
928
  const attempts = this.byTestCaseId.get(testCaseId) ?? [record];
1030
929
  this.byTestCaseId.delete(testCaseId);
930
+ const pendingWrites = attempts.flatMap((a) => a.pendingVideoWrites);
931
+ if (pendingWrites.length > 0) {
932
+ await Promise.all(pendingWrites);
933
+ }
1031
934
  const snapshots = attempts.map((a) => {
1032
935
  const worst = a.stepResults.length > 0 ? getWorstTestStepResult(a.stepResults) : void 0;
1033
936
  const duration = a.stepResults.reduce((sum, r) => sum + messageDurationToNs(r.duration), 0);
@@ -1062,6 +965,11 @@ function timestampMs(ts) {
1062
965
 
1063
966
  // src/formatter/collect-builder.ts
1064
967
  import * as os from "os";
968
+
969
+ // src/config/version.ts
970
+ var PACKAGE_VERSION = "0.3.0";
971
+
972
+ // src/formatter/collect-builder.ts
1065
973
  function resolveOs(config) {
1066
974
  if (config.os) {
1067
975
  return config.os;
@@ -1082,7 +990,8 @@ function buildCollectPayload(suites, config) {
1082
990
  metadata: {
1083
991
  version: PACKAGE_VERSION,
1084
992
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1085
- cliName: "qualflare-cucumberjs"
993
+ cliName: "qualflare-cucumberjs",
994
+ runId: config.runId
1086
995
  },
1087
996
  properties: config.properties,
1088
997
  suites,
@@ -1221,7 +1130,7 @@ var RunHookTracker = class {
1221
1130
  }
1222
1131
  return {
1223
1132
  name: "(global hooks)",
1224
- category: "bdd",
1133
+ category: "cucumber",
1225
1134
  duration: this.failed.reduce((sum, c) => sum + c.duration, 0),
1226
1135
  cases: this.failed
1227
1136
  };
@@ -1229,7 +1138,7 @@ var RunHookTracker = class {
1229
1138
  };
1230
1139
 
1231
1140
  // src/formatter/suite-builder.ts
1232
- import * as path2 from "path";
1141
+ import * as path3 from "path";
1233
1142
  function groupIntoSuites(cases, cwd, extraSuite) {
1234
1143
  const byUri = /* @__PURE__ */ new Map();
1235
1144
  for (const { uri, case: kase } of cases) {
@@ -1249,7 +1158,7 @@ function groupIntoSuites(cases, cwd, extraSuite) {
1249
1158
  }
1250
1159
  suites.push({
1251
1160
  name: relativizeUri(uri, cwd),
1252
- category: "bdd",
1161
+ category: "cucumber",
1253
1162
  duration: kases.reduce((sum, c) => sum + c.duration, 0),
1254
1163
  cases: kases.slice(0, MAX_CASES_PER_SUITE)
1255
1164
  });
@@ -1269,10 +1178,10 @@ function relativizeUri(uri, cwd) {
1269
1178
  if (normalized.startsWith("file://")) {
1270
1179
  normalized = new URL(normalized).pathname;
1271
1180
  }
1272
- if (path2.isAbsolute(normalized)) {
1273
- normalized = path2.relative(cwd, normalized);
1181
+ if (path3.isAbsolute(normalized)) {
1182
+ normalized = path3.relative(cwd, normalized);
1274
1183
  }
1275
- return normalized.split(path2.sep).join("/");
1184
+ return normalized.split(path3.sep).join("/");
1276
1185
  }
1277
1186
 
1278
1187
  // src/formatter/formatter.ts
@@ -1286,12 +1195,25 @@ var QualflareCucumberFormatter = class extends Formatter {
1286
1195
  attemptTracker;
1287
1196
  runHookTracker = new RunHookTracker();
1288
1197
  finishedCases = [];
1198
+ /** One promise per `testCaseFinished` envelope, resolving once that
1199
+ * scenario's `AttemptTracker.finish()` (which itself awaits any pending
1200
+ * video uploads — see its doc comment) has settled and, if it produced a
1201
+ * result, been pushed into `finishedCases`. `finished()` awaits all of
1202
+ * these before building/uploading the Collect payload, so a scenario
1203
+ * whose only attachment is a still-uploading video is never silently
1204
+ * dropped from the report. */
1205
+ pendingCaseBuilds = [];
1289
1206
  constructor(options) {
1290
1207
  super(options);
1291
1208
  this.config = resolveConfig(options.parsedArgvOptions);
1292
1209
  this.hookIndex = buildHookIndex(options.supportCodeLibrary);
1293
1210
  this.attachmentBudget = new AttachmentBudget(this.config.maxTotalAttachmentBytes);
1294
- this.attemptTracker = new AttemptTracker(this.hookIndex, this.gherkin, this.config, this.attachmentBudget);
1211
+ this.attemptTracker = new AttemptTracker(
1212
+ this.hookIndex,
1213
+ this.gherkin,
1214
+ this.config,
1215
+ this.attachmentBudget
1216
+ );
1295
1217
  if (!this.config.enabled) {
1296
1218
  return;
1297
1219
  }
@@ -1342,15 +1264,20 @@ var QualflareCucumberFormatter = class extends Formatter {
1342
1264
  return;
1343
1265
  }
1344
1266
  if (envelope.testCaseFinished) {
1345
- const finished = this.attemptTracker.finish(envelope.testCaseFinished);
1346
- if (finished) {
1267
+ const pending = this.attemptTracker.finish(envelope.testCaseFinished).then((finished) => {
1268
+ if (!finished) {
1269
+ return;
1270
+ }
1347
1271
  const pickle = this.pickleIndex.get(finished.pickleId);
1348
1272
  if (pickle) {
1349
1273
  this.finishedCases.push(buildCase(finished.uri, pickle, finished.collapsed, this.gherkin));
1350
1274
  } else {
1351
1275
  logger.warn(`could not resolve pickle "${finished.pickleId}" for a finished scenario \u2014 it will not be uploaded.`);
1352
1276
  }
1353
- }
1277
+ }).catch((err) => {
1278
+ logger.error("failed to process a cucumber-js event:", err);
1279
+ });
1280
+ this.pendingCaseBuilds.push(pending);
1354
1281
  return;
1355
1282
  }
1356
1283
  if (envelope.testRunHookStarted) {
@@ -1367,41 +1294,41 @@ var QualflareCucumberFormatter = class extends Formatter {
1367
1294
  }
1368
1295
  async finished() {
1369
1296
  try {
1297
+ await Promise.all(this.pendingCaseBuilds);
1370
1298
  if (this.config.enabled) {
1371
- await this.uploadResults();
1299
+ this.writeResults();
1372
1300
  }
1373
1301
  } finally {
1374
1302
  await super.finished();
1375
1303
  }
1376
1304
  }
1377
- async uploadResults() {
1305
+ /** Writes this process's Collect payload into `outputDir` under a unique
1306
+ * filename. Never uploads: `qualflare-cli collect <outputDir>` does that,
1307
+ * merging every file it finds there into one Launch. Multiple shards can
1308
+ * therefore share one directory safely — the UUID filename is what keeps
1309
+ * them from overwriting each other. */
1310
+ writeResults() {
1378
1311
  const suites = groupIntoSuites(this.finishedCases, this.cwd, this.runHookTracker.buildSuite());
1379
1312
  if (suites.length === 0) {
1380
1313
  if (this.config.debug) {
1381
- logger.debug("no scenarios reported \u2014 skipping upload.");
1314
+ logger.debug("no scenarios reported \u2014 skipping file write.");
1382
1315
  }
1383
1316
  return;
1384
1317
  }
1385
1318
  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;
1319
+ if (this.config.shardIndex !== void 0) {
1320
+ for (const suite of payload.suites) {
1321
+ for (const c of suite.cases) {
1322
+ c.shardIndex = this.config.shardIndex;
1323
+ }
1402
1324
  }
1403
- logger.error("failed to upload results to Qualflare:", err);
1404
1325
  }
1326
+ fs3.mkdirSync(this.config.outputDir, { recursive: true });
1327
+ const outputPath = path4.join(this.config.outputDir, `${randomUUID3()}.json`);
1328
+ fs3.writeFileSync(outputPath, JSON.stringify(payload));
1329
+ logger.info(
1330
+ `wrote Collect payload to ${outputPath} \u2014 run \`qualflare-cli collect ${this.config.outputDir}\` to upload it.`
1331
+ );
1405
1332
  }
1406
1333
  };
1407
1334
  export {