@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.
@@ -1,6 +1,6 @@
1
1
  // src/plugin/attachment-reader.ts
2
- import * as fs from "fs";
3
- import * as path from "path";
2
+ import * as fs2 from "fs";
3
+ import * as path2 from "path";
4
4
 
5
5
  // src/shared/logger.ts
6
6
  var PREFIX = "[qualflare-cypress]";
@@ -19,6 +19,46 @@ var logger = {
19
19
  }
20
20
  };
21
21
 
22
+ // src/plugin/video-writer.ts
23
+ import * as fs from "fs";
24
+ import * as path from "path";
25
+ import { randomUUID } from "crypto";
26
+ var VIDEO_MIME_TYPES_BY_EXTENSION = {
27
+ ".mp4": "video/mp4",
28
+ ".webm": "video/webm",
29
+ ".mov": "video/quicktime"
30
+ };
31
+ function copyVideoAttachment(filePath, outputDir, maxVideoBytes) {
32
+ const ext = path.extname(filePath).toLowerCase();
33
+ const mimeType = VIDEO_MIME_TYPES_BY_EXTENSION[ext];
34
+ if (!mimeType) {
35
+ logger.warn(`skipping video attachment "${filePath}": unsupported video format.`);
36
+ return void 0;
37
+ }
38
+ let fileSize;
39
+ try {
40
+ fileSize = fs.statSync(filePath).size;
41
+ } catch (err) {
42
+ logger.warn(`skipping video attachment "${filePath}": could not stat file: ${err.message}`);
43
+ return void 0;
44
+ }
45
+ if (fileSize > maxVideoBytes) {
46
+ logger.warn(
47
+ `skipping video attachment "${filePath}": ${fileSize} bytes exceeds the configured maxVideoBytes cap of ${maxVideoBytes} bytes.`
48
+ );
49
+ return void 0;
50
+ }
51
+ const localVideoPath = `${randomUUID()}${ext}`;
52
+ try {
53
+ fs.mkdirSync(outputDir, { recursive: true });
54
+ fs.copyFileSync(filePath, path.join(outputDir, localVideoPath));
55
+ } catch (err) {
56
+ logger.warn(`skipping video attachment "${filePath}": could not copy file: ${err.message}`);
57
+ return void 0;
58
+ }
59
+ return { localVideoPath, fileSize, mimeType };
60
+ }
61
+
22
62
  // src/plugin/attachment-reader.ts
23
63
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([".mp4", ".webm", ".mov", ".avi", ".mkv"]);
24
64
  var AttachmentBudget = class {
@@ -44,7 +84,7 @@ function isVideoLike(attachment) {
44
84
  if (attachment.mimeType?.toLowerCase().startsWith("video/")) {
45
85
  return true;
46
86
  }
47
- if (attachment.path && VIDEO_EXTENSIONS.has(path.extname(attachment.path).toLowerCase())) {
87
+ if (attachment.path && VIDEO_EXTENSIONS.has(path2.extname(attachment.path).toLowerCase())) {
48
88
  return true;
49
89
  }
50
90
  return false;
@@ -52,7 +92,7 @@ function isVideoLike(attachment) {
52
92
  function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
53
93
  let size;
54
94
  try {
55
- size = fs.statSync(filePath).size;
95
+ size = fs2.statSync(filePath).size;
56
96
  } catch (err) {
57
97
  return { skipped: true, reason: `could not stat file: ${err.message}` };
58
98
  }
@@ -69,7 +109,7 @@ function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
69
109
  };
70
110
  }
71
111
  try {
72
- const content = fs.readFileSync(filePath).toString("base64");
112
+ const content = fs2.readFileSync(filePath).toString("base64");
73
113
  return { skipped: false, content };
74
114
  } catch (err) {
75
115
  return { skipped: true, reason: `could not read file: ${err.message}` };
@@ -85,9 +125,20 @@ function resolveAttachments(attachments, config, budget) {
85
125
  const resolved = [];
86
126
  for (const attachment of attachments) {
87
127
  if (isVideoLike(attachment)) {
88
- logger.warn(
89
- `refusing to attach "${attachment.name}": video attachments are not supported yet (${attachment.path ?? attachment.mimeType ?? "unknown"}).`
90
- );
128
+ if (!attachment.path) {
129
+ logger.warn(`skipping video attachment "${attachment.name}": no local file path to copy.`);
130
+ continue;
131
+ }
132
+ const copied = copyVideoAttachment(attachment.path, config.outputDir, config.maxVideoBytes);
133
+ if (!copied) {
134
+ continue;
135
+ }
136
+ resolved.push({
137
+ ...attachment,
138
+ mimeType: copied.mimeType,
139
+ localVideoPath: copied.localVideoPath,
140
+ fileSize: copied.fileSize
141
+ });
91
142
  continue;
92
143
  }
93
144
  if (attachment.content !== void 0 || !attachment.path) {
@@ -104,228 +155,18 @@ function resolveAttachments(attachments, config, budget) {
104
155
  return resolved.length > 0 ? resolved : void 0;
105
156
  }
106
157
 
107
- // src/http/client.ts
108
- import { request } from "undici";
158
+ // src/plugin/events.ts
159
+ import * as fs3 from "fs";
160
+ import * as path3 from "path";
161
+ import { randomUUID as randomUUID2 } from "crypto";
109
162
 
110
163
  // src/shared/constants.ts
111
164
  var TASK_REPORT_CASE = "qualflareReportCase";
112
165
  var TASK_MARK_TEST_PHASE_STARTED = "qualflareMarkTestPhaseStarted";
113
- var HEADER_TOKEN = "QF_TOKEN";
114
- var HEADER_IDEMPOTENCY_KEY = "Idempotency-Key";
115
- var HEADER_CONTENT_TYPE = "Content-Type";
116
- var HEADER_ACCEPT = "Accept";
117
- var HEADER_USER_AGENT = "User-Agent";
118
166
  var MAX_SUITES_PER_LAUNCH = 2e3;
119
167
  var MAX_CASES_PER_SUITE = 5e3;
120
- var MAX_IDEMPOTENCY_KEY_CHARS = 255;
121
-
122
- // src/http/backoff.ts
123
- function computeDelay(attempt, baseDelayMs, maxDelayMs, retryAfterMs) {
124
- const exponential = baseDelayMs * 2 ** Math.max(0, attempt - 1);
125
- const jittered = Math.random() * Math.min(exponential, maxDelayMs);
126
- const floor = retryAfterMs !== void 0 ? retryAfterMs : 0;
127
- return Math.min(Math.max(jittered, floor), maxDelayMs);
128
- }
129
-
130
- // src/http/errors.ts
131
- function friendlyHint(code) {
132
- switch (code) {
133
- case "environment.not_found":
134
- return "Environment not found. Check the `environment` option or create it in Qualflare.";
135
- case "milestone.not_found":
136
- return "Milestone not found. Check the `milestone` option or its sequence number in Qualflare.";
137
- case "common.validation_failed":
138
- return "Validation failed. Check the request data below.";
139
- default:
140
- return void 0;
141
- }
142
- }
143
- function actionHint(statusCode) {
144
- switch (statusCode) {
145
- case 401:
146
- return "the configured token is missing or invalid \u2014 check `token`/QUALFLARE_TOKEN";
147
- case 403:
148
- return "the token lacks access to this project";
149
- case 402:
150
- return "a plan limit was reached \u2014 check your Qualflare subscription";
151
- default:
152
- return void 0;
153
- }
154
- }
155
- function renderFields(fields) {
156
- if (!fields || fields.length === 0) {
157
- return void 0;
158
- }
159
- return fields.map((f) => {
160
- const rule = f.rule ? ` (${f.rule})` : "";
161
- const msg = f.message ? `: ${f.message}` : "";
162
- return `${f.field}${rule}${msg}`;
163
- }).join("; ");
164
- }
165
- var QualflareApiError = class extends Error {
166
- code;
167
- statusCode;
168
- requestId;
169
- fields;
170
- constructor(init) {
171
- const parts = [init.message];
172
- const fieldsRendered = renderFields(init.fields);
173
- if (fieldsRendered) {
174
- parts.push(`fields: ${fieldsRendered}`);
175
- }
176
- const hint = actionHint(init.statusCode);
177
- if (hint) {
178
- parts.push(`(${hint})`);
179
- }
180
- if (init.requestId) {
181
- parts.push(`[request_id: ${init.requestId}]`);
182
- }
183
- super(parts.join(" \u2014 "), init.cause !== void 0 ? { cause: init.cause } : void 0);
184
- this.name = "QualflareApiError";
185
- this.code = init.code;
186
- this.statusCode = init.statusCode;
187
- this.requestId = init.requestId;
188
- this.fields = init.fields;
189
- }
190
- };
191
- function buildApiError(statusCode, body) {
192
- const code = body?.code;
193
- const message = body?.message || friendlyHint(code) || body?.error || `request failed with status ${statusCode}`;
194
- return new QualflareApiError({
195
- message,
196
- code,
197
- statusCode,
198
- requestId: body?.request_id,
199
- fields: body?.fields
200
- });
201
- }
202
-
203
- // src/http/idempotency.ts
204
- import { randomUUID } from "crypto";
205
- function newIdempotencyKey() {
206
- const key = randomUUID();
207
- if (key.length > MAX_IDEMPOTENCY_KEY_CHARS) {
208
- return key.slice(0, MAX_IDEMPOTENCY_KEY_CHARS);
209
- }
210
- return key;
211
- }
212
-
213
- // src/http/client.ts
214
- var RETRYABLE_STATUS_CODES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
215
- function redactToken(token) {
216
- return token.length > 0 ? "***REDACTED***" : "(none)";
217
- }
218
- var QualflareHttpClient = class {
219
- constructor(opts) {
220
- this.opts = opts;
221
- }
222
- opts;
223
- async send(collect) {
224
- const url = `${this.opts.endpoint.replace(/\/+$/, "")}/api/v1/collect`;
225
- const idempotencyKey = newIdempotencyKey();
226
- const body = JSON.stringify(collect);
227
- const maxAttempts = Math.max(1, this.opts.retry.max + 1);
228
- let lastError;
229
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
230
- if (this.opts.debug) {
231
- logger.debug(
232
- `POST ${url} (attempt ${attempt}/${maxAttempts}, QF_TOKEN: ${redactToken(this.opts.token)})`
233
- );
234
- }
235
- let statusCode;
236
- let responseBody;
237
- let responseHeaders;
238
- try {
239
- const res = await request(url, {
240
- method: "POST",
241
- headers: {
242
- [HEADER_TOKEN]: this.opts.token,
243
- [HEADER_CONTENT_TYPE]: "application/json",
244
- [HEADER_ACCEPT]: "application/json",
245
- [HEADER_USER_AGENT]: this.opts.userAgent,
246
- [HEADER_IDEMPOTENCY_KEY]: idempotencyKey
247
- },
248
- body,
249
- maxRedirections: 0,
250
- signal: AbortSignal.timeout(this.opts.timeoutMs)
251
- });
252
- statusCode = res.statusCode;
253
- responseHeaders = res.headers;
254
- responseBody = await res.body.text();
255
- } catch (err) {
256
- lastError = err;
257
- if (this.opts.debug) {
258
- logger.debug(`attempt ${attempt} transport error: ${err?.message ?? err}`);
259
- }
260
- if (attempt < maxAttempts) {
261
- await sleep(computeDelay(attempt, this.opts.retry.baseDelayMs, this.opts.retry.maxDelayMs));
262
- continue;
263
- }
264
- throw new QualflareApiError({
265
- message: `failed to send request to ${url}`,
266
- cause: err
267
- });
268
- }
269
- if (this.opts.debug) {
270
- logger.debug(`attempt ${attempt} response: ${statusCode}`);
271
- }
272
- if (statusCode >= 200 && statusCode < 300) {
273
- return parseSuccess(responseBody);
274
- }
275
- const parsedError = parseErrorBody(responseBody);
276
- if (!RETRYABLE_STATUS_CODES.has(statusCode) || attempt === maxAttempts) {
277
- throw buildApiError(statusCode, parsedError);
278
- }
279
- const retryAfterMs = parseRetryAfter(responseHeaders["retry-after"]);
280
- const delay = computeDelay(
281
- attempt,
282
- this.opts.retry.baseDelayMs,
283
- this.opts.retry.maxDelayMs,
284
- retryAfterMs
285
- );
286
- if (this.opts.debug) {
287
- logger.debug(`retrying after ${Math.round(delay)}ms (status ${statusCode})`);
288
- }
289
- await sleep(delay);
290
- }
291
- throw lastError instanceof Error ? lastError : new QualflareApiError({ message: "request failed for an unknown reason" });
292
- }
293
- };
294
- function parseSuccess(responseBody) {
295
- try {
296
- const parsed = JSON.parse(responseBody);
297
- if (typeof parsed.seq !== "number") {
298
- throw new Error('response body missing numeric "seq"');
299
- }
300
- return parsed;
301
- } catch (err) {
302
- throw new QualflareApiError({
303
- message: "server returned a success status but an unparseable body",
304
- cause: err
305
- });
306
- }
307
- }
308
- function parseErrorBody(responseBody) {
309
- if (!responseBody) {
310
- return void 0;
311
- }
312
- try {
313
- return JSON.parse(responseBody);
314
- } catch {
315
- return void 0;
316
- }
317
- }
318
- function parseRetryAfter(value) {
319
- const raw = Array.isArray(value) ? value[0] : value;
320
- if (!raw) {
321
- return void 0;
322
- }
323
- const seconds = Number(raw);
324
- return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : void 0;
325
- }
326
- function sleep(ms) {
327
- return new Promise((resolve) => setTimeout(resolve, ms));
328
- }
168
+ var MAX_ATTACHMENTS_PER_CASE = 50;
169
+ var MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;
329
170
 
330
171
  // src/shared/duration.ts
331
172
  var NS_PER_MS = 1e6;
@@ -336,11 +177,13 @@ function msToNs(ms) {
336
177
  return Math.round(ms * NS_PER_MS);
337
178
  }
338
179
 
180
+ // src/plugin/collect-builder.ts
181
+ import * as os from "os";
182
+
339
183
  // src/plugin/version.ts
340
- var PACKAGE_VERSION = "0.1.0";
184
+ var PACKAGE_VERSION = "0.3.0";
341
185
 
342
186
  // src/plugin/collect-builder.ts
343
- import * as os from "os";
344
187
  function resolveOs(config, info) {
345
188
  if (config.os) {
346
189
  return config.os;
@@ -373,7 +216,8 @@ function buildCollectPayload(accumulator, config, browserInfo) {
373
216
  metadata: {
374
217
  version: PACKAGE_VERSION,
375
218
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
376
- cliName: "qualflare-cypress"
219
+ cliName: "qualflare-cypress",
220
+ runId: config.runId
377
221
  },
378
222
  properties: config.properties,
379
223
  suites: accumulator.getSuites(),
@@ -432,6 +276,7 @@ var PendingAttachmentQueue = class {
432
276
  };
433
277
 
434
278
  // src/plugin/events.ts
279
+ var FAILURE_STATUSES = /* @__PURE__ */ new Set(["failed", "error", "timeout"]);
435
280
  function registerEvents(on, config, buffer, pendingAttachments, testPhaseGate) {
436
281
  const accumulator = new LaunchAccumulator();
437
282
  let browserInfo;
@@ -447,7 +292,7 @@ function registerEvents(on, config, buffer, pendingAttachments, testPhaseGate) {
447
292
  on("after:screenshot", (details) => {
448
293
  if (!testPhaseGate.hasStarted()) {
449
294
  logger.warn(
450
- `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.`
295
+ `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.`
451
296
  );
452
297
  return;
453
298
  }
@@ -462,59 +307,80 @@ function registerEvents(on, config, buffer, pendingAttachments, testPhaseGate) {
462
307
  testPhaseGate.reset();
463
308
  buffer.drain();
464
309
  });
465
- on("after:spec", (spec, results) => {
310
+ on("after:spec", async (spec, results) => {
466
311
  const cases = buffer.drain();
312
+ if (results.video) {
313
+ const failedCase = cases.find((c) => FAILURE_STATUSES.has(c.status));
314
+ if (failedCase) {
315
+ const copied = copyVideoAttachment(results.video, config.outputDir, config.maxVideoBytes);
316
+ if (copied) {
317
+ attachVideo(failedCase, copied);
318
+ }
319
+ } else {
320
+ logger.info(`spec ${spec.relative} recorded a video but no test failed \u2014 not attached.`);
321
+ }
322
+ }
467
323
  const suite = {
468
324
  name: spec.relative,
469
- category: "e2e",
325
+ category: "cypress",
470
326
  duration: msToNs(results.stats.duration ?? Date.now() - currentSpecStart),
471
327
  timestamp: new Date(results.stats.startedAt ?? Date.now()).toISOString(),
472
328
  cases
473
329
  };
474
330
  if (cases.length !== results.stats.tests) {
475
331
  logger.warn(
476
- `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.`
332
+ `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.`
477
333
  );
478
334
  }
479
335
  accumulator.addSuite(suite);
480
336
  const orphaned = pendingAttachments.drain();
481
337
  if (orphaned.length > 0) {
482
338
  logger.warn(
483
- `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.`
484
- );
485
- }
486
- if (results.video) {
487
- logger.info(
488
- `spec ${spec.relative} recorded a video at ${results.video} \u2014 not uploaded (qualflare-cypress does not support video attachments yet).`
339
+ `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.`
489
340
  );
490
341
  }
491
342
  });
492
343
  on("after:run", async () => {
493
344
  const suites = accumulator.getSuites();
494
345
  if (suites.length === 0) {
495
- logger.info("no test results were captured this run \u2014 skipping upload.");
346
+ logger.info("no test results were captured this run \u2014 skipping file write.");
496
347
  return;
497
348
  }
498
349
  const collect = buildCollectPayload(accumulator, config, browserInfo);
499
- const client = new QualflareHttpClient({
500
- endpoint: config.apiEndpoint,
501
- token: config.token,
502
- timeoutMs: config.timeoutMs,
503
- retry: config.retry,
504
- userAgent: `qualflare-cypress/${PACKAGE_VERSION}`,
505
- debug: config.debug
506
- });
507
- try {
508
- const result = await client.send(collect);
509
- logger.info(`uploaded launch #${result.seq} to Qualflare.`);
510
- } catch (err) {
511
- logger.error(`failed to upload results to Qualflare: ${err.message}`);
512
- if (config.failOnUploadError) {
513
- throw err;
350
+ if (config.shardIndex !== void 0) {
351
+ for (const suite of collect.suites) {
352
+ for (const c of suite.cases) {
353
+ c.shardIndex = config.shardIndex;
354
+ }
514
355
  }
515
356
  }
357
+ fs3.mkdirSync(config.outputDir, { recursive: true });
358
+ const outputPath = path3.join(config.outputDir, `${randomUUID2()}.json`);
359
+ fs3.writeFileSync(outputPath, JSON.stringify(collect));
360
+ logger.info(`wrote Collect payload to ${outputPath} \u2014 run \`qualflare-cli collect ${config.outputDir}\` to upload it.`);
516
361
  });
517
362
  }
363
+ function attachVideo(testCase, copied) {
364
+ const attachments = testCase.attachments ?? [];
365
+ if (attachments.length >= MAX_ATTACHMENTS_PER_CASE) {
366
+ logger.warn(
367
+ `not attaching video to "${testCase.name}": already at the server's ${MAX_ATTACHMENTS_PER_CASE}-attachment-per-case cap.`
368
+ );
369
+ return;
370
+ }
371
+ testCase.attachments = [
372
+ ...attachments,
373
+ {
374
+ name: "video",
375
+ mimeType: copied.mimeType,
376
+ localVideoPath: copied.localVideoPath,
377
+ fileSize: copied.fileSize
378
+ }
379
+ ];
380
+ }
381
+
382
+ // src/plugin/resolve-config.ts
383
+ import { randomUUID as randomUUID3 } from "crypto";
518
384
 
519
385
  // src/plugin/ci-detect.ts
520
386
  import * as ciInfo from "ci-info";
@@ -533,6 +399,7 @@ var PROVIDERS = [
533
399
  detect: (env) => env.GITHUB_ACTIONS === "true",
534
400
  providerName: "GitHub Actions",
535
401
  buildNumber: (env) => nonEmpty(env.GITHUB_RUN_NUMBER),
402
+ runId: (env) => nonEmpty(env.GITHUB_RUN_ID),
536
403
  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,
537
404
  prNumber: (env) => {
538
405
  const match = /^refs\/pull\/(\d+)\/merge$/.exec(env.GITHUB_REF ?? "");
@@ -543,6 +410,7 @@ var PROVIDERS = [
543
410
  detect: (env) => env.GITLAB_CI === "true",
544
411
  providerName: "GitLab CI",
545
412
  buildNumber: (env) => nonEmpty(env.CI_PIPELINE_IID),
413
+ runId: (env) => nonEmpty(env.CI_PIPELINE_ID),
546
414
  runUrl: (env) => nonEmpty(env.CI_PIPELINE_URL),
547
415
  prNumber: (env) => parsePositiveInt(env.CI_MERGE_REQUEST_IID)
548
416
  },
@@ -550,6 +418,7 @@ var PROVIDERS = [
550
418
  detect: (env) => env.CIRCLECI === "true",
551
419
  providerName: "CircleCI",
552
420
  buildNumber: (env) => nonEmpty(env.CIRCLE_BUILD_NUM),
421
+ runId: (env) => nonEmpty(env.CIRCLE_WORKFLOW_ID ?? env.CIRCLE_BUILD_NUM),
553
422
  runUrl: (env) => nonEmpty(env.CIRCLE_BUILD_URL),
554
423
  prNumber: (env) => parsePositiveInt(env.CIRCLE_PR_NUMBER)
555
424
  },
@@ -557,6 +426,7 @@ var PROVIDERS = [
557
426
  detect: (env) => env.BUILDKITE === "true",
558
427
  providerName: "Buildkite",
559
428
  buildNumber: (env) => nonEmpty(env.BUILDKITE_BUILD_NUMBER),
429
+ runId: (env) => nonEmpty(env.BUILDKITE_BUILD_ID),
560
430
  runUrl: (env) => nonEmpty(env.BUILDKITE_BUILD_URL),
561
431
  prNumber: (env) => {
562
432
  const raw = env.BUILDKITE_PULL_REQUEST;
@@ -572,6 +442,7 @@ var PROVIDERS = [
572
442
  detect: (env) => Boolean(env.JENKINS_URL),
573
443
  providerName: "Jenkins",
574
444
  buildNumber: (env) => nonEmpty(env.BUILD_NUMBER),
445
+ runId: (env) => nonEmpty(env.BUILD_TAG ?? env.BUILD_NUMBER),
575
446
  runUrl: (env) => nonEmpty(env.BUILD_URL)
576
447
  // Jenkins has no standardized PR-number env var across its many PR
577
448
  // plugins (Multibranch, GitHub Branch Source, etc.) — deliberately
@@ -581,6 +452,7 @@ var PROVIDERS = [
581
452
  detect: (env) => env.TF_BUILD === "True" || env.TF_BUILD === "true",
582
453
  providerName: "Azure Pipelines",
583
454
  buildNumber: (env) => nonEmpty(env.BUILD_BUILDID),
455
+ runId: (env) => nonEmpty(env.BUILD_BUILDID),
584
456
  runUrl: (env) => {
585
457
  const collectionUri = env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI;
586
458
  const project = env.SYSTEM_TEAMPROJECT;
@@ -596,6 +468,7 @@ var PROVIDERS = [
596
468
  detect: (env) => Boolean(env.BITBUCKET_BUILD_NUMBER),
597
469
  providerName: "Bitbucket Pipelines",
598
470
  buildNumber: (env) => nonEmpty(env.BITBUCKET_BUILD_NUMBER),
471
+ runId: (env) => nonEmpty(env.BITBUCKET_BUILD_NUMBER),
599
472
  runUrl: (env) => {
600
473
  const origin = env.BITBUCKET_GIT_HTTP_ORIGIN;
601
474
  if (!origin) {
@@ -617,6 +490,8 @@ function detectCi(env = process.env) {
617
490
  if (runUrl !== void 0) result.ciRunUrl = runUrl;
618
491
  const prNumber = provider.prNumber?.(env);
619
492
  if (prNumber !== void 0) result.ciPrNumber = prNumber;
493
+ const runId = provider.runId?.(env);
494
+ if (runId !== void 0) result.ciRunId = runId;
620
495
  return result;
621
496
  }
622
497
  if (ciInfo.name) {
@@ -687,22 +562,12 @@ function envInt(...names) {
687
562
  const parsed = Number.parseInt(raw, 10);
688
563
  return Number.isFinite(parsed) ? parsed : void 0;
689
564
  }
690
- var QualflareConfigError = class extends Error {
691
- constructor(message) {
692
- super(message);
693
- this.name = "QualflareConfigError";
694
- }
695
- };
696
565
  function resolveConfig(options, deps = {}) {
697
566
  const doDetectGit = deps.detectGit ?? detectGit;
698
567
  const doDetectCi = deps.detectCi ?? detectCi;
699
568
  const enabled = options.enabled ?? envBool("QUALFLARE_ENABLED") ?? true;
700
- const token = options.token ?? firstEnv2("QUALFLARE_TOKEN", "QF_TOKEN") ?? "";
701
- if (enabled && token === "") {
702
- throw new QualflareConfigError(
703
- "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."
704
- );
705
- }
569
+ const outputDir = options.outputDir || firstEnv2("QUALFLARE_OUTPUT_DIR") || "./qualflare-results";
570
+ const shardIndex = options.shardIndex ?? envInt("QUALFLARE_SHARD_INDEX");
706
571
  const milestoneRaw = options.milestone !== void 0 ? options.milestone : envInt("QUALFLARE_MILESTONE", "QF_MILESTONE");
707
572
  const milestone = milestoneRaw !== void 0 && milestoneRaw !== null && milestoneRaw >= 1 ? milestoneRaw : null;
708
573
  const envBranch = firstEnv2("QUALFLARE_BRANCH", "QF_BRANCH");
@@ -716,18 +581,16 @@ function resolveConfig(options, deps = {}) {
716
581
  const ciBuildNumber = options.ciBuildNumber ?? detectedCi.ciBuildNumber;
717
582
  const ciRunUrl = options.ciRunUrl ?? detectedCi.ciRunUrl;
718
583
  const ciPrNumber = options.ciPrNumber ?? detectedCi.ciPrNumber;
584
+ const runId = options.runId ?? firstEnv2("QUALFLARE_RUN_ID") ?? detectedCi.ciRunId ?? randomUUID3();
719
585
  return {
720
- token,
721
- apiEndpoint: options.apiEndpoint ?? firstEnv2("QUALFLARE_API_ENDPOINT") ?? "https://api.qualflare.com",
722
586
  // `||` (truthy check), not `??`, for these three REQUIRED-non-empty wire
723
587
  // fields — matching `collect-builder.ts`'s `resolveOs`/`resolveBrowser`,
724
588
  // which already correctly treat an explicit `''` option as "not set."
725
589
  // `??` only falls back on `null`/`undefined`, so `environment: ''` would
726
590
  // previously win outright over the `'development'` default, silently
727
591
  // 400ing the whole launch (the server rejects an empty `environment`)
728
- // and — since `failOnUploadError` defaults `false`failing the entire
729
- // upload with no visible error by default. Found via deep adversarial
730
- // self-review.
592
+ // and — since this process no longer attempts uploads — the error would
593
+ // be deferred until qualflare-cli tries to upload.
731
594
  environment: (options.environment || void 0) ?? firstEnv2("QUALFLARE_ENVIRONMENT", "QF_ENVIRONMENT") ?? "development",
732
595
  language: (options.language || void 0) ?? firstEnv2("QUALFLARE_LANGUAGE", "QF_LANGUAGE") ?? "en-US",
733
596
  milestone,
@@ -742,18 +605,14 @@ function resolveConfig(options, deps = {}) {
742
605
  ciBuildNumber,
743
606
  ciRunUrl,
744
607
  ciPrNumber,
745
- timeoutMs: options.timeoutMs ?? envInt("QUALFLARE_TIMEOUT_MS") ?? 12e4,
746
- retry: {
747
- max: options.retry?.max ?? envInt("QUALFLARE_RETRY_MAX", "QF_RETRY_MAX") ?? 3,
748
- baseDelayMs: options.retry?.baseDelayMs ?? envInt("QUALFLARE_RETRY_BASE_DELAY_MS") ?? 1e3,
749
- maxDelayMs: options.retry?.maxDelayMs ?? envInt("QUALFLARE_RETRY_MAX_DELAY_MS") ?? 3e4
750
- },
751
- failOnUploadError: options.failOnUploadError ?? envBool("QUALFLARE_FAIL_ON_UPLOAD_ERROR") ?? false,
608
+ runId,
752
609
  attachScreenshots: options.attachScreenshots ?? envBool("QUALFLARE_ATTACH_SCREENSHOTS") ?? true,
753
610
  maxAttachmentBytes: options.maxAttachmentBytes ?? envInt("QUALFLARE_MAX_ATTACHMENT_BYTES") ?? 15e5,
754
611
  maxTotalAttachmentBytes: options.maxTotalAttachmentBytes ?? envInt("QUALFLARE_MAX_TOTAL_ATTACHMENT_BYTES") ?? 75e4,
755
- debug: options.debug ?? envBool("QUALFLARE_DEBUG", "QF_DEBUG") ?? false,
756
- enabled
612
+ maxVideoBytes: options.maxVideoBytes ?? envInt("QUALFLARE_MAX_VIDEO_BYTES") ?? MAX_VIDEO_UPLOAD_BYTES,
613
+ enabled,
614
+ outputDir,
615
+ shardIndex
757
616
  };
758
617
  }
759
618
 
@@ -785,9 +644,9 @@ var CaseBuffer = class {
785
644
  };
786
645
  function registerTasks(on, buffer, attachmentConfig, attachmentBudget, pendingAttachments, testPhaseGate) {
787
646
  on("task", {
788
- [TASK_REPORT_CASE](testCase) {
647
+ async [TASK_REPORT_CASE](testCase) {
789
648
  const attachments = [...testCase.attachments ?? [], ...pendingAttachments.drain()];
790
- const resolved = resolveAttachments(
649
+ const resolved = await resolveAttachments(
791
650
  attachments.length > 0 ? attachments : void 0,
792
651
  attachmentConfig,
793
652
  attachmentBudget
@@ -812,14 +671,14 @@ function qualflareCypress(on, config, options = {}) {
812
671
  const pendingAttachments = new PendingAttachmentQueue();
813
672
  const testPhaseGate = new TestPhaseGate();
814
673
  const attachmentBudget = new AttachmentBudget(resolved.maxTotalAttachmentBytes);
815
- registerTasks(on, buffer, resolved, attachmentBudget, pendingAttachments, testPhaseGate);
674
+ const attachmentConfig = resolved;
675
+ registerTasks(on, buffer, attachmentConfig, attachmentBudget, pendingAttachments, testPhaseGate);
816
676
  if (resolved.enabled) {
817
677
  registerEvents(on, resolved, buffer, pendingAttachments, testPhaseGate);
818
678
  }
819
679
  return config;
820
680
  }
821
681
  export {
822
- QualflareConfigError,
823
682
  qualflareCypress
824
683
  };
825
684
  //# sourceMappingURL=index.js.map