@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.
- package/README.md +50 -11
- package/dist/index.cjs +1 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/plugin/index.cjs +142 -284
- package/dist/plugin/index.cjs.map +1 -1
- package/dist/plugin/index.d.cts +32 -29
- package/dist/plugin/index.d.ts +32 -29
- package/dist/plugin/index.js +142 -283
- package/dist/plugin/index.js.map +1 -1
- package/package.json +5 -5
package/dist/plugin/index.cjs
CHANGED
|
@@ -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
|
|
40
|
-
var
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
126
|
-
`
|
|
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/
|
|
145
|
-
var
|
|
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
|
|
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.
|
|
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
|
|
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: "
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
|
738
|
-
|
|
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
|
|
766
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|