@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.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/plugin/attachment-reader.ts
|
|
2
|
-
import * as
|
|
3
|
-
import * as
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
89
|
-
`
|
|
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/
|
|
108
|
-
import
|
|
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
|
|
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.
|
|
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
|
|
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: "
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
701
|
-
|
|
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
|
|
729
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|