@qualflare/cypress 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.
@@ -30,14 +30,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/plugin/index.ts
31
31
  var plugin_exports = {};
32
32
  __export(plugin_exports, {
33
- QualflareConfigError: () => QualflareConfigError,
34
33
  qualflareCypress: () => qualflareCypress
35
34
  });
36
35
  module.exports = __toCommonJS(plugin_exports);
37
36
 
38
37
  // src/plugin/attachment-reader.ts
39
- var fs = __toESM(require("fs"), 1);
40
- var path = __toESM(require("path"), 1);
38
+ var fs2 = __toESM(require("fs"), 1);
39
+ var path2 = __toESM(require("path"), 1);
41
40
 
42
41
  // src/shared/logger.ts
43
42
  var PREFIX = "[qualflare-cypress]";
@@ -56,6 +55,46 @@ var logger = {
56
55
  }
57
56
  };
58
57
 
58
+ // src/plugin/video-writer.ts
59
+ var fs = __toESM(require("fs"), 1);
60
+ var path = __toESM(require("path"), 1);
61
+ var import_node_crypto = require("crypto");
62
+ var VIDEO_MIME_TYPES_BY_EXTENSION = {
63
+ ".mp4": "video/mp4",
64
+ ".webm": "video/webm",
65
+ ".mov": "video/quicktime"
66
+ };
67
+ function copyVideoAttachment(filePath, outputDir, maxVideoBytes) {
68
+ const ext = path.extname(filePath).toLowerCase();
69
+ const mimeType = VIDEO_MIME_TYPES_BY_EXTENSION[ext];
70
+ if (!mimeType) {
71
+ logger.warn(`skipping video attachment "${filePath}": unsupported video format.`);
72
+ return void 0;
73
+ }
74
+ let fileSize;
75
+ try {
76
+ fileSize = fs.statSync(filePath).size;
77
+ } catch (err) {
78
+ logger.warn(`skipping video attachment "${filePath}": could not stat file: ${err.message}`);
79
+ return void 0;
80
+ }
81
+ if (fileSize > maxVideoBytes) {
82
+ logger.warn(
83
+ `skipping video attachment "${filePath}": ${fileSize} bytes exceeds the configured maxVideoBytes cap of ${maxVideoBytes} bytes.`
84
+ );
85
+ return void 0;
86
+ }
87
+ const localVideoPath = `${(0, import_node_crypto.randomUUID)()}${ext}`;
88
+ try {
89
+ fs.mkdirSync(outputDir, { recursive: true });
90
+ fs.copyFileSync(filePath, path.join(outputDir, localVideoPath));
91
+ } catch (err) {
92
+ logger.warn(`skipping video attachment "${filePath}": could not copy file: ${err.message}`);
93
+ return void 0;
94
+ }
95
+ return { localVideoPath, fileSize, mimeType };
96
+ }
97
+
59
98
  // src/plugin/attachment-reader.ts
60
99
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([".mp4", ".webm", ".mov", ".avi", ".mkv"]);
61
100
  var AttachmentBudget = class {
@@ -81,7 +120,7 @@ function isVideoLike(attachment) {
81
120
  if (attachment.mimeType?.toLowerCase().startsWith("video/")) {
82
121
  return true;
83
122
  }
84
- if (attachment.path && VIDEO_EXTENSIONS.has(path.extname(attachment.path).toLowerCase())) {
123
+ if (attachment.path && VIDEO_EXTENSIONS.has(path2.extname(attachment.path).toLowerCase())) {
85
124
  return true;
86
125
  }
87
126
  return false;
@@ -89,7 +128,7 @@ function isVideoLike(attachment) {
89
128
  function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
90
129
  let size;
91
130
  try {
92
- size = fs.statSync(filePath).size;
131
+ size = fs2.statSync(filePath).size;
93
132
  } catch (err) {
94
133
  return { skipped: true, reason: `could not stat file: ${err.message}` };
95
134
  }
@@ -106,7 +145,7 @@ function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
106
145
  };
107
146
  }
108
147
  try {
109
- const content = fs.readFileSync(filePath).toString("base64");
148
+ const content = fs2.readFileSync(filePath).toString("base64");
110
149
  return { skipped: false, content };
111
150
  } catch (err) {
112
151
  return { skipped: true, reason: `could not read file: ${err.message}` };
@@ -122,9 +161,20 @@ function resolveAttachments(attachments, config, budget) {
122
161
  const resolved = [];
123
162
  for (const attachment of attachments) {
124
163
  if (isVideoLike(attachment)) {
125
- logger.warn(
126
- `refusing to attach "${attachment.name}": video attachments are not supported yet (${attachment.path ?? attachment.mimeType ?? "unknown"}).`
127
- );
164
+ if (!attachment.path) {
165
+ logger.warn(`skipping video attachment "${attachment.name}": no local file path to copy.`);
166
+ continue;
167
+ }
168
+ const copied = copyVideoAttachment(attachment.path, config.outputDir, config.maxVideoBytes);
169
+ if (!copied) {
170
+ continue;
171
+ }
172
+ resolved.push({
173
+ ...attachment,
174
+ mimeType: copied.mimeType,
175
+ localVideoPath: copied.localVideoPath,
176
+ fileSize: copied.fileSize
177
+ });
128
178
  continue;
129
179
  }
130
180
  if (attachment.content !== void 0 || !attachment.path) {
@@ -141,228 +191,18 @@ function resolveAttachments(attachments, config, budget) {
141
191
  return resolved.length > 0 ? resolved : void 0;
142
192
  }
143
193
 
144
- // src/http/client.ts
145
- var import_undici = require("undici");
194
+ // src/plugin/events.ts
195
+ var fs3 = __toESM(require("fs"), 1);
196
+ var path3 = __toESM(require("path"), 1);
197
+ var import_node_crypto2 = require("crypto");
146
198
 
147
199
  // src/shared/constants.ts
148
200
  var TASK_REPORT_CASE = "qualflareReportCase";
149
201
  var TASK_MARK_TEST_PHASE_STARTED = "qualflareMarkTestPhaseStarted";
150
- var HEADER_TOKEN = "QF_TOKEN";
151
- var HEADER_IDEMPOTENCY_KEY = "Idempotency-Key";
152
- var HEADER_CONTENT_TYPE = "Content-Type";
153
- var HEADER_ACCEPT = "Accept";
154
- var HEADER_USER_AGENT = "User-Agent";
155
202
  var MAX_SUITES_PER_LAUNCH = 2e3;
156
203
  var MAX_CASES_PER_SUITE = 5e3;
157
- var MAX_IDEMPOTENCY_KEY_CHARS = 255;
158
-
159
- // src/http/backoff.ts
160
- function computeDelay(attempt, baseDelayMs, maxDelayMs, retryAfterMs) {
161
- const exponential = baseDelayMs * 2 ** Math.max(0, attempt - 1);
162
- const jittered = Math.random() * Math.min(exponential, maxDelayMs);
163
- const floor = retryAfterMs !== void 0 ? retryAfterMs : 0;
164
- return Math.min(Math.max(jittered, floor), maxDelayMs);
165
- }
166
-
167
- // src/http/errors.ts
168
- function friendlyHint(code) {
169
- switch (code) {
170
- case "environment.not_found":
171
- return "Environment not found. Check the `environment` option or create it in Qualflare.";
172
- case "milestone.not_found":
173
- return "Milestone not found. Check the `milestone` option or its sequence number in Qualflare.";
174
- case "common.validation_failed":
175
- return "Validation failed. Check the request data below.";
176
- default:
177
- return void 0;
178
- }
179
- }
180
- function actionHint(statusCode) {
181
- switch (statusCode) {
182
- case 401:
183
- return "the configured token is missing or invalid \u2014 check `token`/QUALFLARE_TOKEN";
184
- case 403:
185
- return "the token lacks access to this project";
186
- case 402:
187
- return "a plan limit was reached \u2014 check your Qualflare subscription";
188
- default:
189
- return void 0;
190
- }
191
- }
192
- function renderFields(fields) {
193
- if (!fields || fields.length === 0) {
194
- return void 0;
195
- }
196
- return fields.map((f) => {
197
- const rule = f.rule ? ` (${f.rule})` : "";
198
- const msg = f.message ? `: ${f.message}` : "";
199
- return `${f.field}${rule}${msg}`;
200
- }).join("; ");
201
- }
202
- var QualflareApiError = class extends Error {
203
- code;
204
- statusCode;
205
- requestId;
206
- fields;
207
- constructor(init) {
208
- const parts = [init.message];
209
- const fieldsRendered = renderFields(init.fields);
210
- if (fieldsRendered) {
211
- parts.push(`fields: ${fieldsRendered}`);
212
- }
213
- const hint = actionHint(init.statusCode);
214
- if (hint) {
215
- parts.push(`(${hint})`);
216
- }
217
- if (init.requestId) {
218
- parts.push(`[request_id: ${init.requestId}]`);
219
- }
220
- super(parts.join(" \u2014 "), init.cause !== void 0 ? { cause: init.cause } : void 0);
221
- this.name = "QualflareApiError";
222
- this.code = init.code;
223
- this.statusCode = init.statusCode;
224
- this.requestId = init.requestId;
225
- this.fields = init.fields;
226
- }
227
- };
228
- function buildApiError(statusCode, body) {
229
- const code = body?.code;
230
- const message = body?.message || friendlyHint(code) || body?.error || `request failed with status ${statusCode}`;
231
- return new QualflareApiError({
232
- message,
233
- code,
234
- statusCode,
235
- requestId: body?.request_id,
236
- fields: body?.fields
237
- });
238
- }
239
-
240
- // src/http/idempotency.ts
241
- var import_node_crypto = require("crypto");
242
- function newIdempotencyKey() {
243
- const key = (0, import_node_crypto.randomUUID)();
244
- if (key.length > MAX_IDEMPOTENCY_KEY_CHARS) {
245
- return key.slice(0, MAX_IDEMPOTENCY_KEY_CHARS);
246
- }
247
- return key;
248
- }
249
-
250
- // src/http/client.ts
251
- var RETRYABLE_STATUS_CODES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
252
- function redactToken(token) {
253
- return token.length > 0 ? "***REDACTED***" : "(none)";
254
- }
255
- var QualflareHttpClient = class {
256
- constructor(opts) {
257
- this.opts = opts;
258
- }
259
- opts;
260
- async send(collect) {
261
- const url = `${this.opts.endpoint.replace(/\/+$/, "")}/api/v1/collect`;
262
- const idempotencyKey = newIdempotencyKey();
263
- const body = JSON.stringify(collect);
264
- const maxAttempts = Math.max(1, this.opts.retry.max + 1);
265
- let lastError;
266
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
267
- if (this.opts.debug) {
268
- logger.debug(
269
- `POST ${url} (attempt ${attempt}/${maxAttempts}, QF_TOKEN: ${redactToken(this.opts.token)})`
270
- );
271
- }
272
- let statusCode;
273
- let responseBody;
274
- let responseHeaders;
275
- try {
276
- const res = await (0, import_undici.request)(url, {
277
- method: "POST",
278
- headers: {
279
- [HEADER_TOKEN]: this.opts.token,
280
- [HEADER_CONTENT_TYPE]: "application/json",
281
- [HEADER_ACCEPT]: "application/json",
282
- [HEADER_USER_AGENT]: this.opts.userAgent,
283
- [HEADER_IDEMPOTENCY_KEY]: idempotencyKey
284
- },
285
- body,
286
- maxRedirections: 0,
287
- signal: AbortSignal.timeout(this.opts.timeoutMs)
288
- });
289
- statusCode = res.statusCode;
290
- responseHeaders = res.headers;
291
- responseBody = await res.body.text();
292
- } catch (err) {
293
- lastError = err;
294
- if (this.opts.debug) {
295
- logger.debug(`attempt ${attempt} transport error: ${err?.message ?? err}`);
296
- }
297
- if (attempt < maxAttempts) {
298
- await sleep(computeDelay(attempt, this.opts.retry.baseDelayMs, this.opts.retry.maxDelayMs));
299
- continue;
300
- }
301
- throw new QualflareApiError({
302
- message: `failed to send request to ${url}`,
303
- cause: err
304
- });
305
- }
306
- if (this.opts.debug) {
307
- logger.debug(`attempt ${attempt} response: ${statusCode}`);
308
- }
309
- if (statusCode >= 200 && statusCode < 300) {
310
- return parseSuccess(responseBody);
311
- }
312
- const parsedError = parseErrorBody(responseBody);
313
- if (!RETRYABLE_STATUS_CODES.has(statusCode) || attempt === maxAttempts) {
314
- throw buildApiError(statusCode, parsedError);
315
- }
316
- const retryAfterMs = parseRetryAfter(responseHeaders["retry-after"]);
317
- const delay = computeDelay(
318
- attempt,
319
- this.opts.retry.baseDelayMs,
320
- this.opts.retry.maxDelayMs,
321
- retryAfterMs
322
- );
323
- if (this.opts.debug) {
324
- logger.debug(`retrying after ${Math.round(delay)}ms (status ${statusCode})`);
325
- }
326
- await sleep(delay);
327
- }
328
- throw lastError instanceof Error ? lastError : new QualflareApiError({ message: "request failed for an unknown reason" });
329
- }
330
- };
331
- function parseSuccess(responseBody) {
332
- try {
333
- const parsed = JSON.parse(responseBody);
334
- if (typeof parsed.seq !== "number") {
335
- throw new Error('response body missing numeric "seq"');
336
- }
337
- return parsed;
338
- } catch (err) {
339
- throw new QualflareApiError({
340
- message: "server returned a success status but an unparseable body",
341
- cause: err
342
- });
343
- }
344
- }
345
- function parseErrorBody(responseBody) {
346
- if (!responseBody) {
347
- return void 0;
348
- }
349
- try {
350
- return JSON.parse(responseBody);
351
- } catch {
352
- return void 0;
353
- }
354
- }
355
- function parseRetryAfter(value) {
356
- const raw = Array.isArray(value) ? value[0] : value;
357
- if (!raw) {
358
- return void 0;
359
- }
360
- const seconds = Number(raw);
361
- return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : void 0;
362
- }
363
- function sleep(ms) {
364
- return new Promise((resolve) => setTimeout(resolve, ms));
365
- }
204
+ var MAX_ATTACHMENTS_PER_CASE = 50;
205
+ var MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;
366
206
 
367
207
  // src/shared/duration.ts
368
208
  var NS_PER_MS = 1e6;
@@ -373,11 +213,13 @@ function msToNs(ms) {
373
213
  return Math.round(ms * NS_PER_MS);
374
214
  }
375
215
 
216
+ // src/plugin/collect-builder.ts
217
+ var os = __toESM(require("os"), 1);
218
+
376
219
  // src/plugin/version.ts
377
- var PACKAGE_VERSION = "0.1.0";
220
+ var PACKAGE_VERSION = "0.3.0";
378
221
 
379
222
  // src/plugin/collect-builder.ts
380
- var os = __toESM(require("os"), 1);
381
223
  function resolveOs(config, info) {
382
224
  if (config.os) {
383
225
  return config.os;
@@ -410,7 +252,8 @@ function buildCollectPayload(accumulator, config, browserInfo) {
410
252
  metadata: {
411
253
  version: PACKAGE_VERSION,
412
254
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
413
- cliName: "qualflare-cypress"
255
+ cliName: "qualflare-cypress",
256
+ runId: config.runId
414
257
  },
415
258
  properties: config.properties,
416
259
  suites: accumulator.getSuites(),
@@ -469,6 +312,7 @@ var PendingAttachmentQueue = class {
469
312
  };
470
313
 
471
314
  // src/plugin/events.ts
315
+ var FAILURE_STATUSES = /* @__PURE__ */ new Set(["failed", "error", "timeout"]);
472
316
  function registerEvents(on, config, buffer, pendingAttachments, testPhaseGate) {
473
317
  const accumulator = new LaunchAccumulator();
474
318
  let browserInfo;
@@ -484,7 +328,7 @@ function registerEvents(on, config, buffer, pendingAttachments, testPhaseGate) {
484
328
  on("after:screenshot", (details) => {
485
329
  if (!testPhaseGate.hasStarted()) {
486
330
  logger.warn(
487
- `a screenshot ("${details.name || "unnamed"}") was captured before any test in this spec had started (likely in a root \`before()\` hook) and cannot be attributed to a specific test \u2014 it was not uploaded.`
331
+ `a screenshot ("${details.name || "unnamed"}") was captured before any test in this spec had started (likely in a root \`before()\` hook) and cannot be attributed to a specific test \u2014 it was not included in the report.`
488
332
  );
489
333
  return;
490
334
  }
@@ -499,59 +343,80 @@ function registerEvents(on, config, buffer, pendingAttachments, testPhaseGate) {
499
343
  testPhaseGate.reset();
500
344
  buffer.drain();
501
345
  });
502
- on("after:spec", (spec, results) => {
346
+ on("after:spec", async (spec, results) => {
503
347
  const cases = buffer.drain();
348
+ if (results.video) {
349
+ const failedCase = cases.find((c) => FAILURE_STATUSES.has(c.status));
350
+ if (failedCase) {
351
+ const copied = copyVideoAttachment(results.video, config.outputDir, config.maxVideoBytes);
352
+ if (copied) {
353
+ attachVideo(failedCase, copied);
354
+ }
355
+ } else {
356
+ logger.info(`spec ${spec.relative} recorded a video but no test failed \u2014 not attached.`);
357
+ }
358
+ }
504
359
  const suite = {
505
360
  name: spec.relative,
506
- category: "e2e",
361
+ category: "cypress",
507
362
  duration: msToNs(results.stats.duration ?? Date.now() - currentSpecStart),
508
363
  timestamp: new Date(results.stats.startedAt ?? Date.now()).toISOString(),
509
364
  cases
510
365
  };
511
366
  if (cases.length !== results.stats.tests) {
512
367
  logger.warn(
513
- `spec ${spec.relative}: captured ${cases.length} case(s) but Cypress reported ${results.stats.tests} test(s) \u2014 some results may be missing from the uploaded report.`
368
+ `spec ${spec.relative}: captured ${cases.length} case(s) but Cypress reported ${results.stats.tests} test(s) \u2014 some results may be missing from the report.`
514
369
  );
515
370
  }
516
371
  accumulator.addSuite(suite);
517
372
  const orphaned = pendingAttachments.drain();
518
373
  if (orphaned.length > 0) {
519
374
  logger.warn(
520
- `spec ${spec.relative}: ${orphaned.length} screenshot(s) could not be attributed to a specific test (likely taken outside a test body, e.g. in an \`after\` hook) and were not uploaded.`
521
- );
522
- }
523
- if (results.video) {
524
- logger.info(
525
- `spec ${spec.relative} recorded a video at ${results.video} \u2014 not uploaded (qualflare-cypress does not support video attachments yet).`
375
+ `spec ${spec.relative}: ${orphaned.length} screenshot(s) could not be attributed to a specific test (likely taken outside a test body, e.g. in an \`after\` hook) and were not included in the report.`
526
376
  );
527
377
  }
528
378
  });
529
379
  on("after:run", async () => {
530
380
  const suites = accumulator.getSuites();
531
381
  if (suites.length === 0) {
532
- logger.info("no test results were captured this run \u2014 skipping upload.");
382
+ logger.info("no test results were captured this run \u2014 skipping file write.");
533
383
  return;
534
384
  }
535
385
  const collect = buildCollectPayload(accumulator, config, browserInfo);
536
- const client = new QualflareHttpClient({
537
- endpoint: config.apiEndpoint,
538
- token: config.token,
539
- timeoutMs: config.timeoutMs,
540
- retry: config.retry,
541
- userAgent: `qualflare-cypress/${PACKAGE_VERSION}`,
542
- debug: config.debug
543
- });
544
- try {
545
- const result = await client.send(collect);
546
- logger.info(`uploaded launch #${result.seq} to Qualflare.`);
547
- } catch (err) {
548
- logger.error(`failed to upload results to Qualflare: ${err.message}`);
549
- if (config.failOnUploadError) {
550
- throw err;
386
+ if (config.shardIndex !== void 0) {
387
+ for (const suite of collect.suites) {
388
+ for (const c of suite.cases) {
389
+ c.shardIndex = config.shardIndex;
390
+ }
551
391
  }
552
392
  }
393
+ fs3.mkdirSync(config.outputDir, { recursive: true });
394
+ const outputPath = path3.join(config.outputDir, `${(0, import_node_crypto2.randomUUID)()}.json`);
395
+ fs3.writeFileSync(outputPath, JSON.stringify(collect));
396
+ logger.info(`wrote Collect payload to ${outputPath} \u2014 run \`qualflare-cli collect ${config.outputDir}\` to upload it.`);
553
397
  });
554
398
  }
399
+ function attachVideo(testCase, copied) {
400
+ const attachments = testCase.attachments ?? [];
401
+ if (attachments.length >= MAX_ATTACHMENTS_PER_CASE) {
402
+ logger.warn(
403
+ `not attaching video to "${testCase.name}": already at the server's ${MAX_ATTACHMENTS_PER_CASE}-attachment-per-case cap.`
404
+ );
405
+ return;
406
+ }
407
+ testCase.attachments = [
408
+ ...attachments,
409
+ {
410
+ name: "video",
411
+ mimeType: copied.mimeType,
412
+ localVideoPath: copied.localVideoPath,
413
+ fileSize: copied.fileSize
414
+ }
415
+ ];
416
+ }
417
+
418
+ // src/plugin/resolve-config.ts
419
+ var import_node_crypto3 = require("crypto");
555
420
 
556
421
  // src/plugin/ci-detect.ts
557
422
  var ciInfo = __toESM(require("ci-info"), 1);
@@ -570,6 +435,7 @@ var PROVIDERS = [
570
435
  detect: (env) => env.GITHUB_ACTIONS === "true",
571
436
  providerName: "GitHub Actions",
572
437
  buildNumber: (env) => nonEmpty(env.GITHUB_RUN_NUMBER),
438
+ runId: (env) => nonEmpty(env.GITHUB_RUN_ID),
573
439
  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,
574
440
  prNumber: (env) => {
575
441
  const match = /^refs\/pull\/(\d+)\/merge$/.exec(env.GITHUB_REF ?? "");
@@ -580,6 +446,7 @@ var PROVIDERS = [
580
446
  detect: (env) => env.GITLAB_CI === "true",
581
447
  providerName: "GitLab CI",
582
448
  buildNumber: (env) => nonEmpty(env.CI_PIPELINE_IID),
449
+ runId: (env) => nonEmpty(env.CI_PIPELINE_ID),
583
450
  runUrl: (env) => nonEmpty(env.CI_PIPELINE_URL),
584
451
  prNumber: (env) => parsePositiveInt(env.CI_MERGE_REQUEST_IID)
585
452
  },
@@ -587,6 +454,7 @@ var PROVIDERS = [
587
454
  detect: (env) => env.CIRCLECI === "true",
588
455
  providerName: "CircleCI",
589
456
  buildNumber: (env) => nonEmpty(env.CIRCLE_BUILD_NUM),
457
+ runId: (env) => nonEmpty(env.CIRCLE_WORKFLOW_ID ?? env.CIRCLE_BUILD_NUM),
590
458
  runUrl: (env) => nonEmpty(env.CIRCLE_BUILD_URL),
591
459
  prNumber: (env) => parsePositiveInt(env.CIRCLE_PR_NUMBER)
592
460
  },
@@ -594,6 +462,7 @@ var PROVIDERS = [
594
462
  detect: (env) => env.BUILDKITE === "true",
595
463
  providerName: "Buildkite",
596
464
  buildNumber: (env) => nonEmpty(env.BUILDKITE_BUILD_NUMBER),
465
+ runId: (env) => nonEmpty(env.BUILDKITE_BUILD_ID),
597
466
  runUrl: (env) => nonEmpty(env.BUILDKITE_BUILD_URL),
598
467
  prNumber: (env) => {
599
468
  const raw = env.BUILDKITE_PULL_REQUEST;
@@ -609,6 +478,7 @@ var PROVIDERS = [
609
478
  detect: (env) => Boolean(env.JENKINS_URL),
610
479
  providerName: "Jenkins",
611
480
  buildNumber: (env) => nonEmpty(env.BUILD_NUMBER),
481
+ runId: (env) => nonEmpty(env.BUILD_TAG ?? env.BUILD_NUMBER),
612
482
  runUrl: (env) => nonEmpty(env.BUILD_URL)
613
483
  // Jenkins has no standardized PR-number env var across its many PR
614
484
  // plugins (Multibranch, GitHub Branch Source, etc.) — deliberately
@@ -618,6 +488,7 @@ var PROVIDERS = [
618
488
  detect: (env) => env.TF_BUILD === "True" || env.TF_BUILD === "true",
619
489
  providerName: "Azure Pipelines",
620
490
  buildNumber: (env) => nonEmpty(env.BUILD_BUILDID),
491
+ runId: (env) => nonEmpty(env.BUILD_BUILDID),
621
492
  runUrl: (env) => {
622
493
  const collectionUri = env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI;
623
494
  const project = env.SYSTEM_TEAMPROJECT;
@@ -633,6 +504,7 @@ var PROVIDERS = [
633
504
  detect: (env) => Boolean(env.BITBUCKET_BUILD_NUMBER),
634
505
  providerName: "Bitbucket Pipelines",
635
506
  buildNumber: (env) => nonEmpty(env.BITBUCKET_BUILD_NUMBER),
507
+ runId: (env) => nonEmpty(env.BITBUCKET_BUILD_NUMBER),
636
508
  runUrl: (env) => {
637
509
  const origin = env.BITBUCKET_GIT_HTTP_ORIGIN;
638
510
  if (!origin) {
@@ -654,6 +526,8 @@ function detectCi(env = process.env) {
654
526
  if (runUrl !== void 0) result.ciRunUrl = runUrl;
655
527
  const prNumber = provider.prNumber?.(env);
656
528
  if (prNumber !== void 0) result.ciPrNumber = prNumber;
529
+ const runId = provider.runId?.(env);
530
+ if (runId !== void 0) result.ciRunId = runId;
657
531
  return result;
658
532
  }
659
533
  if (ciInfo.name) {
@@ -724,22 +598,12 @@ function envInt(...names) {
724
598
  const parsed = Number.parseInt(raw, 10);
725
599
  return Number.isFinite(parsed) ? parsed : void 0;
726
600
  }
727
- var QualflareConfigError = class extends Error {
728
- constructor(message) {
729
- super(message);
730
- this.name = "QualflareConfigError";
731
- }
732
- };
733
601
  function resolveConfig(options, deps = {}) {
734
602
  const doDetectGit = deps.detectGit ?? detectGit;
735
603
  const doDetectCi = deps.detectCi ?? detectCi;
736
604
  const enabled = options.enabled ?? envBool("QUALFLARE_ENABLED") ?? true;
737
- const token = options.token ?? firstEnv2("QUALFLARE_TOKEN", "QF_TOKEN") ?? "";
738
- if (enabled && token === "") {
739
- throw new QualflareConfigError(
740
- "qualflare-cypress: no token configured. Set the `token` option or the QUALFLARE_TOKEN (or QF_TOKEN) environment variable, or pass `enabled: false` to disable this plugin."
741
- );
742
- }
605
+ const outputDir = options.outputDir || firstEnv2("QUALFLARE_OUTPUT_DIR") || "./qualflare-results";
606
+ const shardIndex = options.shardIndex ?? envInt("QUALFLARE_SHARD_INDEX");
743
607
  const milestoneRaw = options.milestone !== void 0 ? options.milestone : envInt("QUALFLARE_MILESTONE", "QF_MILESTONE");
744
608
  const milestone = milestoneRaw !== void 0 && milestoneRaw !== null && milestoneRaw >= 1 ? milestoneRaw : null;
745
609
  const envBranch = firstEnv2("QUALFLARE_BRANCH", "QF_BRANCH");
@@ -753,18 +617,16 @@ function resolveConfig(options, deps = {}) {
753
617
  const ciBuildNumber = options.ciBuildNumber ?? detectedCi.ciBuildNumber;
754
618
  const ciRunUrl = options.ciRunUrl ?? detectedCi.ciRunUrl;
755
619
  const ciPrNumber = options.ciPrNumber ?? detectedCi.ciPrNumber;
620
+ const runId = options.runId ?? firstEnv2("QUALFLARE_RUN_ID") ?? detectedCi.ciRunId ?? (0, import_node_crypto3.randomUUID)();
756
621
  return {
757
- token,
758
- apiEndpoint: options.apiEndpoint ?? firstEnv2("QUALFLARE_API_ENDPOINT") ?? "https://api.qualflare.com",
759
622
  // `||` (truthy check), not `??`, for these three REQUIRED-non-empty wire
760
623
  // fields — matching `collect-builder.ts`'s `resolveOs`/`resolveBrowser`,
761
624
  // which already correctly treat an explicit `''` option as "not set."
762
625
  // `??` only falls back on `null`/`undefined`, so `environment: ''` would
763
626
  // previously win outright over the `'development'` default, silently
764
627
  // 400ing the whole launch (the server rejects an empty `environment`)
765
- // and — since `failOnUploadError` defaults `false`failing the entire
766
- // upload with no visible error by default. Found via deep adversarial
767
- // self-review.
628
+ // and — since this process no longer attempts uploads — the error would
629
+ // be deferred until qualflare-cli tries to upload.
768
630
  environment: (options.environment || void 0) ?? firstEnv2("QUALFLARE_ENVIRONMENT", "QF_ENVIRONMENT") ?? "development",
769
631
  language: (options.language || void 0) ?? firstEnv2("QUALFLARE_LANGUAGE", "QF_LANGUAGE") ?? "en-US",
770
632
  milestone,
@@ -779,18 +641,14 @@ function resolveConfig(options, deps = {}) {
779
641
  ciBuildNumber,
780
642
  ciRunUrl,
781
643
  ciPrNumber,
782
- timeoutMs: options.timeoutMs ?? envInt("QUALFLARE_TIMEOUT_MS") ?? 12e4,
783
- retry: {
784
- max: options.retry?.max ?? envInt("QUALFLARE_RETRY_MAX", "QF_RETRY_MAX") ?? 3,
785
- baseDelayMs: options.retry?.baseDelayMs ?? envInt("QUALFLARE_RETRY_BASE_DELAY_MS") ?? 1e3,
786
- maxDelayMs: options.retry?.maxDelayMs ?? envInt("QUALFLARE_RETRY_MAX_DELAY_MS") ?? 3e4
787
- },
788
- failOnUploadError: options.failOnUploadError ?? envBool("QUALFLARE_FAIL_ON_UPLOAD_ERROR") ?? false,
644
+ runId,
789
645
  attachScreenshots: options.attachScreenshots ?? envBool("QUALFLARE_ATTACH_SCREENSHOTS") ?? true,
790
646
  maxAttachmentBytes: options.maxAttachmentBytes ?? envInt("QUALFLARE_MAX_ATTACHMENT_BYTES") ?? 15e5,
791
647
  maxTotalAttachmentBytes: options.maxTotalAttachmentBytes ?? envInt("QUALFLARE_MAX_TOTAL_ATTACHMENT_BYTES") ?? 75e4,
792
- debug: options.debug ?? envBool("QUALFLARE_DEBUG", "QF_DEBUG") ?? false,
793
- enabled
648
+ maxVideoBytes: options.maxVideoBytes ?? envInt("QUALFLARE_MAX_VIDEO_BYTES") ?? MAX_VIDEO_UPLOAD_BYTES,
649
+ enabled,
650
+ outputDir,
651
+ shardIndex
794
652
  };
795
653
  }
796
654
 
@@ -822,9 +680,9 @@ var CaseBuffer = class {
822
680
  };
823
681
  function registerTasks(on, buffer, attachmentConfig, attachmentBudget, pendingAttachments, testPhaseGate) {
824
682
  on("task", {
825
- [TASK_REPORT_CASE](testCase) {
683
+ async [TASK_REPORT_CASE](testCase) {
826
684
  const attachments = [...testCase.attachments ?? [], ...pendingAttachments.drain()];
827
- const resolved = resolveAttachments(
685
+ const resolved = await resolveAttachments(
828
686
  attachments.length > 0 ? attachments : void 0,
829
687
  attachmentConfig,
830
688
  attachmentBudget
@@ -849,7 +707,8 @@ function qualflareCypress(on, config, options = {}) {
849
707
  const pendingAttachments = new PendingAttachmentQueue();
850
708
  const testPhaseGate = new TestPhaseGate();
851
709
  const attachmentBudget = new AttachmentBudget(resolved.maxTotalAttachmentBytes);
852
- registerTasks(on, buffer, resolved, attachmentBudget, pendingAttachments, testPhaseGate);
710
+ const attachmentConfig = resolved;
711
+ registerTasks(on, buffer, attachmentConfig, attachmentBudget, pendingAttachments, testPhaseGate);
853
712
  if (resolved.enabled) {
854
713
  registerEvents(on, resolved, buffer, pendingAttachments, testPhaseGate);
855
714
  }
@@ -857,7 +716,6 @@ function qualflareCypress(on, config, options = {}) {
857
716
  }
858
717
  // Annotate the CommonJS export names for ESM import in node:
859
718
  0 && (module.exports = {
860
- QualflareConfigError,
861
719
  qualflareCypress
862
720
  });
863
721
  //# sourceMappingURL=index.cjs.map