@qualflare/cypress 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -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 +126 -275
- package/dist/plugin/index.cjs.map +1 -1
- package/dist/plugin/index.d.cts +22 -20
- package/dist/plugin/index.d.ts +22 -20
- package/dist/plugin/index.js +126 -275
- package/dist/plugin/index.js.map +1 -1
- package/package.json +5 -5
package/dist/plugin/index.cjs
CHANGED
|
@@ -36,8 +36,8 @@ __export(plugin_exports, {
|
|
|
36
36
|
module.exports = __toCommonJS(plugin_exports);
|
|
37
37
|
|
|
38
38
|
// src/plugin/attachment-reader.ts
|
|
39
|
-
var
|
|
40
|
-
var
|
|
39
|
+
var fs2 = __toESM(require("fs"), 1);
|
|
40
|
+
var path2 = __toESM(require("path"), 1);
|
|
41
41
|
|
|
42
42
|
// src/shared/logger.ts
|
|
43
43
|
var PREFIX = "[qualflare-cypress]";
|
|
@@ -56,6 +56,46 @@ var logger = {
|
|
|
56
56
|
}
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
+
// src/plugin/video-uploader.ts
|
|
60
|
+
var fs = __toESM(require("fs"), 1);
|
|
61
|
+
var path = __toESM(require("path"), 1);
|
|
62
|
+
var import_node_crypto = require("crypto");
|
|
63
|
+
var VIDEO_MIME_TYPES_BY_EXTENSION = {
|
|
64
|
+
".mp4": "video/mp4",
|
|
65
|
+
".webm": "video/webm",
|
|
66
|
+
".mov": "video/quicktime"
|
|
67
|
+
};
|
|
68
|
+
function copyVideoAttachment(filePath, outputDir, maxVideoBytes) {
|
|
69
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
70
|
+
const mimeType = VIDEO_MIME_TYPES_BY_EXTENSION[ext];
|
|
71
|
+
if (!mimeType) {
|
|
72
|
+
logger.warn(`skipping video attachment "${filePath}": unsupported video format.`);
|
|
73
|
+
return void 0;
|
|
74
|
+
}
|
|
75
|
+
let fileSize;
|
|
76
|
+
try {
|
|
77
|
+
fileSize = fs.statSync(filePath).size;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
logger.warn(`skipping video attachment "${filePath}": could not stat file: ${err.message}`);
|
|
80
|
+
return void 0;
|
|
81
|
+
}
|
|
82
|
+
if (fileSize > maxVideoBytes) {
|
|
83
|
+
logger.warn(
|
|
84
|
+
`skipping video attachment "${filePath}": ${fileSize} bytes exceeds the configured maxVideoBytes cap of ${maxVideoBytes} bytes.`
|
|
85
|
+
);
|
|
86
|
+
return void 0;
|
|
87
|
+
}
|
|
88
|
+
const localVideoPath = `${(0, import_node_crypto.randomUUID)()}${ext}`;
|
|
89
|
+
try {
|
|
90
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
91
|
+
fs.copyFileSync(filePath, path.join(outputDir, localVideoPath));
|
|
92
|
+
} catch (err) {
|
|
93
|
+
logger.warn(`skipping video attachment "${filePath}": could not copy file: ${err.message}`);
|
|
94
|
+
return void 0;
|
|
95
|
+
}
|
|
96
|
+
return { localVideoPath, fileSize, mimeType };
|
|
97
|
+
}
|
|
98
|
+
|
|
59
99
|
// src/plugin/attachment-reader.ts
|
|
60
100
|
var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([".mp4", ".webm", ".mov", ".avi", ".mkv"]);
|
|
61
101
|
var AttachmentBudget = class {
|
|
@@ -81,7 +121,7 @@ function isVideoLike(attachment) {
|
|
|
81
121
|
if (attachment.mimeType?.toLowerCase().startsWith("video/")) {
|
|
82
122
|
return true;
|
|
83
123
|
}
|
|
84
|
-
if (attachment.path && VIDEO_EXTENSIONS.has(
|
|
124
|
+
if (attachment.path && VIDEO_EXTENSIONS.has(path2.extname(attachment.path).toLowerCase())) {
|
|
85
125
|
return true;
|
|
86
126
|
}
|
|
87
127
|
return false;
|
|
@@ -89,7 +129,7 @@ function isVideoLike(attachment) {
|
|
|
89
129
|
function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
|
|
90
130
|
let size;
|
|
91
131
|
try {
|
|
92
|
-
size =
|
|
132
|
+
size = fs2.statSync(filePath).size;
|
|
93
133
|
} catch (err) {
|
|
94
134
|
return { skipped: true, reason: `could not stat file: ${err.message}` };
|
|
95
135
|
}
|
|
@@ -106,7 +146,7 @@ function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
|
|
|
106
146
|
};
|
|
107
147
|
}
|
|
108
148
|
try {
|
|
109
|
-
const content =
|
|
149
|
+
const content = fs2.readFileSync(filePath).toString("base64");
|
|
110
150
|
return { skipped: false, content };
|
|
111
151
|
} catch (err) {
|
|
112
152
|
return { skipped: true, reason: `could not read file: ${err.message}` };
|
|
@@ -122,9 +162,20 @@ function resolveAttachments(attachments, config, budget) {
|
|
|
122
162
|
const resolved = [];
|
|
123
163
|
for (const attachment of attachments) {
|
|
124
164
|
if (isVideoLike(attachment)) {
|
|
125
|
-
|
|
126
|
-
`
|
|
127
|
-
|
|
165
|
+
if (!attachment.path) {
|
|
166
|
+
logger.warn(`skipping video attachment "${attachment.name}": no local file path to copy.`);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const copied = copyVideoAttachment(attachment.path, config.outputDir, config.maxVideoBytes);
|
|
170
|
+
if (!copied) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
resolved.push({
|
|
174
|
+
...attachment,
|
|
175
|
+
mimeType: copied.mimeType,
|
|
176
|
+
localVideoPath: copied.localVideoPath,
|
|
177
|
+
fileSize: copied.fileSize
|
|
178
|
+
});
|
|
128
179
|
continue;
|
|
129
180
|
}
|
|
130
181
|
if (attachment.content !== void 0 || !attachment.path) {
|
|
@@ -141,228 +192,18 @@ function resolveAttachments(attachments, config, budget) {
|
|
|
141
192
|
return resolved.length > 0 ? resolved : void 0;
|
|
142
193
|
}
|
|
143
194
|
|
|
144
|
-
// src/
|
|
145
|
-
var
|
|
195
|
+
// src/plugin/events.ts
|
|
196
|
+
var fs3 = __toESM(require("fs"), 1);
|
|
197
|
+
var path3 = __toESM(require("path"), 1);
|
|
198
|
+
var import_node_crypto2 = require("crypto");
|
|
146
199
|
|
|
147
200
|
// src/shared/constants.ts
|
|
148
201
|
var TASK_REPORT_CASE = "qualflareReportCase";
|
|
149
202
|
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
203
|
var MAX_SUITES_PER_LAUNCH = 2e3;
|
|
156
204
|
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
|
-
}
|
|
205
|
+
var MAX_ATTACHMENTS_PER_CASE = 50;
|
|
206
|
+
var MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
366
207
|
|
|
367
208
|
// src/shared/duration.ts
|
|
368
209
|
var NS_PER_MS = 1e6;
|
|
@@ -373,11 +214,13 @@ function msToNs(ms) {
|
|
|
373
214
|
return Math.round(ms * NS_PER_MS);
|
|
374
215
|
}
|
|
375
216
|
|
|
217
|
+
// src/plugin/collect-builder.ts
|
|
218
|
+
var os = __toESM(require("os"), 1);
|
|
219
|
+
|
|
376
220
|
// src/plugin/version.ts
|
|
377
|
-
var PACKAGE_VERSION = "0.
|
|
221
|
+
var PACKAGE_VERSION = "0.2.0";
|
|
378
222
|
|
|
379
223
|
// src/plugin/collect-builder.ts
|
|
380
|
-
var os = __toESM(require("os"), 1);
|
|
381
224
|
function resolveOs(config, info) {
|
|
382
225
|
if (config.os) {
|
|
383
226
|
return config.os;
|
|
@@ -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,77 @@ 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
|
+
}
|
|
555
417
|
|
|
556
418
|
// src/plugin/ci-detect.ts
|
|
557
419
|
var ciInfo = __toESM(require("ci-info"), 1);
|
|
@@ -734,12 +596,8 @@ function resolveConfig(options, deps = {}) {
|
|
|
734
596
|
const doDetectGit = deps.detectGit ?? detectGit;
|
|
735
597
|
const doDetectCi = deps.detectCi ?? detectCi;
|
|
736
598
|
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
|
-
}
|
|
599
|
+
const outputDir = options.outputDir || firstEnv2("QUALFLARE_OUTPUT_DIR") || "./qualflare-results";
|
|
600
|
+
const shardIndex = options.shardIndex ?? envInt("QUALFLARE_SHARD_INDEX");
|
|
743
601
|
const milestoneRaw = options.milestone !== void 0 ? options.milestone : envInt("QUALFLARE_MILESTONE", "QF_MILESTONE");
|
|
744
602
|
const milestone = milestoneRaw !== void 0 && milestoneRaw !== null && milestoneRaw >= 1 ? milestoneRaw : null;
|
|
745
603
|
const envBranch = firstEnv2("QUALFLARE_BRANCH", "QF_BRANCH");
|
|
@@ -754,17 +612,14 @@ function resolveConfig(options, deps = {}) {
|
|
|
754
612
|
const ciRunUrl = options.ciRunUrl ?? detectedCi.ciRunUrl;
|
|
755
613
|
const ciPrNumber = options.ciPrNumber ?? detectedCi.ciPrNumber;
|
|
756
614
|
return {
|
|
757
|
-
token,
|
|
758
|
-
apiEndpoint: options.apiEndpoint ?? firstEnv2("QUALFLARE_API_ENDPOINT") ?? "https://api.qualflare.com",
|
|
759
615
|
// `||` (truthy check), not `??`, for these three REQUIRED-non-empty wire
|
|
760
616
|
// fields — matching `collect-builder.ts`'s `resolveOs`/`resolveBrowser`,
|
|
761
617
|
// which already correctly treat an explicit `''` option as "not set."
|
|
762
618
|
// `??` only falls back on `null`/`undefined`, so `environment: ''` would
|
|
763
619
|
// previously win outright over the `'development'` default, silently
|
|
764
620
|
// 400ing the whole launch (the server rejects an empty `environment`)
|
|
765
|
-
// and — since
|
|
766
|
-
//
|
|
767
|
-
// self-review.
|
|
621
|
+
// and — since this process no longer attempts uploads — the error would
|
|
622
|
+
// be deferred until qualflare-cli tries to upload.
|
|
768
623
|
environment: (options.environment || void 0) ?? firstEnv2("QUALFLARE_ENVIRONMENT", "QF_ENVIRONMENT") ?? "development",
|
|
769
624
|
language: (options.language || void 0) ?? firstEnv2("QUALFLARE_LANGUAGE", "QF_LANGUAGE") ?? "en-US",
|
|
770
625
|
milestone,
|
|
@@ -779,18 +634,13 @@ function resolveConfig(options, deps = {}) {
|
|
|
779
634
|
ciBuildNumber,
|
|
780
635
|
ciRunUrl,
|
|
781
636
|
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,
|
|
789
637
|
attachScreenshots: options.attachScreenshots ?? envBool("QUALFLARE_ATTACH_SCREENSHOTS") ?? true,
|
|
790
638
|
maxAttachmentBytes: options.maxAttachmentBytes ?? envInt("QUALFLARE_MAX_ATTACHMENT_BYTES") ?? 15e5,
|
|
791
639
|
maxTotalAttachmentBytes: options.maxTotalAttachmentBytes ?? envInt("QUALFLARE_MAX_TOTAL_ATTACHMENT_BYTES") ?? 75e4,
|
|
792
|
-
|
|
793
|
-
enabled
|
|
640
|
+
maxVideoBytes: options.maxVideoBytes ?? envInt("QUALFLARE_MAX_VIDEO_BYTES") ?? MAX_VIDEO_UPLOAD_BYTES,
|
|
641
|
+
enabled,
|
|
642
|
+
outputDir,
|
|
643
|
+
shardIndex
|
|
794
644
|
};
|
|
795
645
|
}
|
|
796
646
|
|
|
@@ -822,9 +672,9 @@ var CaseBuffer = class {
|
|
|
822
672
|
};
|
|
823
673
|
function registerTasks(on, buffer, attachmentConfig, attachmentBudget, pendingAttachments, testPhaseGate) {
|
|
824
674
|
on("task", {
|
|
825
|
-
[TASK_REPORT_CASE](testCase) {
|
|
675
|
+
async [TASK_REPORT_CASE](testCase) {
|
|
826
676
|
const attachments = [...testCase.attachments ?? [], ...pendingAttachments.drain()];
|
|
827
|
-
const resolved = resolveAttachments(
|
|
677
|
+
const resolved = await resolveAttachments(
|
|
828
678
|
attachments.length > 0 ? attachments : void 0,
|
|
829
679
|
attachmentConfig,
|
|
830
680
|
attachmentBudget
|
|
@@ -849,7 +699,8 @@ function qualflareCypress(on, config, options = {}) {
|
|
|
849
699
|
const pendingAttachments = new PendingAttachmentQueue();
|
|
850
700
|
const testPhaseGate = new TestPhaseGate();
|
|
851
701
|
const attachmentBudget = new AttachmentBudget(resolved.maxTotalAttachmentBytes);
|
|
852
|
-
|
|
702
|
+
const attachmentConfig = resolved;
|
|
703
|
+
registerTasks(on, buffer, attachmentConfig, attachmentBudget, pendingAttachments, testPhaseGate);
|
|
853
704
|
if (resolved.enabled) {
|
|
854
705
|
registerEvents(on, resolved, buffer, pendingAttachments, testPhaseGate);
|
|
855
706
|
}
|