@qualflare/cypress 0.1.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/LICENSE +190 -0
- package/README.md +101 -0
- package/dist/index.cjs +931 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +79 -0
- package/dist/index.d.ts +79 -0
- package/dist/index.js +904 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin/index.cjs +863 -0
- package/dist/plugin/index.cjs.map +1 -0
- package/dist/plugin/index.d.cts +101 -0
- package/dist/plugin/index.d.ts +101 -0
- package/dist/plugin/index.js +825 -0
- package/dist/plugin/index.js.map +1 -0
- package/dist/types-BKTS9yBY.d.cts +5 -0
- package/dist/types-BKTS9yBY.d.ts +5 -0
- package/package.json +81 -0
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
// src/plugin/attachment-reader.ts
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
|
|
5
|
+
// src/shared/logger.ts
|
|
6
|
+
var PREFIX = "[qualflare-cypress]";
|
|
7
|
+
var logger = {
|
|
8
|
+
debug(...args) {
|
|
9
|
+
console.debug(PREFIX, ...args);
|
|
10
|
+
},
|
|
11
|
+
info(...args) {
|
|
12
|
+
console.log(PREFIX, ...args);
|
|
13
|
+
},
|
|
14
|
+
warn(...args) {
|
|
15
|
+
console.warn(PREFIX, ...args);
|
|
16
|
+
},
|
|
17
|
+
error(...args) {
|
|
18
|
+
console.error(PREFIX, ...args);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// src/plugin/attachment-reader.ts
|
|
23
|
+
var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([".mp4", ".webm", ".mov", ".avi", ".mkv"]);
|
|
24
|
+
var AttachmentBudget = class {
|
|
25
|
+
constructor(maxTotalBytes) {
|
|
26
|
+
this.maxTotalBytes = maxTotalBytes;
|
|
27
|
+
}
|
|
28
|
+
maxTotalBytes;
|
|
29
|
+
used = 0;
|
|
30
|
+
/** Atomically checks-and-reserves `bytes` against the remaining budget.
|
|
31
|
+
* Returns false (reserving nothing) if it would exceed the total. */
|
|
32
|
+
tryReserve(bytes) {
|
|
33
|
+
if (this.used + bytes > this.maxTotalBytes) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
this.used += bytes;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
get usedBytes() {
|
|
40
|
+
return this.used;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
function isVideoLike(attachment) {
|
|
44
|
+
if (attachment.mimeType?.toLowerCase().startsWith("video/")) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
if (attachment.path && VIDEO_EXTENSIONS.has(path.extname(attachment.path).toLowerCase())) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
|
|
53
|
+
let size;
|
|
54
|
+
try {
|
|
55
|
+
size = fs.statSync(filePath).size;
|
|
56
|
+
} catch (err) {
|
|
57
|
+
return { skipped: true, reason: `could not stat file: ${err.message}` };
|
|
58
|
+
}
|
|
59
|
+
if (size > maxAttachmentBytes) {
|
|
60
|
+
return {
|
|
61
|
+
skipped: true,
|
|
62
|
+
reason: `${size} bytes exceeds the configured per-attachment cap of ${maxAttachmentBytes} bytes`
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (!budget.tryReserve(size)) {
|
|
66
|
+
return {
|
|
67
|
+
skipped: true,
|
|
68
|
+
reason: `would exceed this run's total attachment budget (${budget.usedBytes} bytes already used)`
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const content = fs.readFileSync(filePath).toString("base64");
|
|
73
|
+
return { skipped: false, content };
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return { skipped: true, reason: `could not read file: ${err.message}` };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function resolveAttachments(attachments, config, budget) {
|
|
79
|
+
if (!attachments || attachments.length === 0) {
|
|
80
|
+
return void 0;
|
|
81
|
+
}
|
|
82
|
+
if (!config.attachScreenshots) {
|
|
83
|
+
return void 0;
|
|
84
|
+
}
|
|
85
|
+
const resolved = [];
|
|
86
|
+
for (const attachment of attachments) {
|
|
87
|
+
if (isVideoLike(attachment)) {
|
|
88
|
+
logger.warn(
|
|
89
|
+
`refusing to attach "${attachment.name}": video attachments are not supported yet (${attachment.path ?? attachment.mimeType ?? "unknown"}).`
|
|
90
|
+
);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (attachment.content !== void 0 || !attachment.path) {
|
|
94
|
+
resolved.push(attachment);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const result = readAttachmentFile(attachment.path, config.maxAttachmentBytes, budget);
|
|
98
|
+
if (result.skipped) {
|
|
99
|
+
logger.warn(`skipping attachment "${attachment.name}" (${attachment.path}): ${result.reason}`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
resolved.push({ ...attachment, content: result.content });
|
|
103
|
+
}
|
|
104
|
+
return resolved.length > 0 ? resolved : void 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/http/client.ts
|
|
108
|
+
import { request } from "undici";
|
|
109
|
+
|
|
110
|
+
// src/shared/constants.ts
|
|
111
|
+
var TASK_REPORT_CASE = "qualflareReportCase";
|
|
112
|
+
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
|
+
var MAX_SUITES_PER_LAUNCH = 2e3;
|
|
119
|
+
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
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/shared/duration.ts
|
|
331
|
+
var NS_PER_MS = 1e6;
|
|
332
|
+
function msToNs(ms) {
|
|
333
|
+
if (!Number.isFinite(ms) || ms <= 0) {
|
|
334
|
+
return 0;
|
|
335
|
+
}
|
|
336
|
+
return Math.round(ms * NS_PER_MS);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/plugin/version.ts
|
|
340
|
+
var PACKAGE_VERSION = "0.1.0";
|
|
341
|
+
|
|
342
|
+
// src/plugin/collect-builder.ts
|
|
343
|
+
import * as os from "os";
|
|
344
|
+
function resolveOs(config, info) {
|
|
345
|
+
if (config.os) {
|
|
346
|
+
return config.os;
|
|
347
|
+
}
|
|
348
|
+
if (info?.osName) {
|
|
349
|
+
return info.osVersion ? `${info.osName} ${info.osVersion}` : info.osName;
|
|
350
|
+
}
|
|
351
|
+
return `${os.type()} ${os.release()}`;
|
|
352
|
+
}
|
|
353
|
+
function resolveBrowser(config, info) {
|
|
354
|
+
if (config.browser) {
|
|
355
|
+
return config.browser;
|
|
356
|
+
}
|
|
357
|
+
if (info?.browserName) {
|
|
358
|
+
return info.browserVersion ? `${info.browserName} ${info.browserVersion}` : info.browserName;
|
|
359
|
+
}
|
|
360
|
+
return "";
|
|
361
|
+
}
|
|
362
|
+
function buildCollectPayload(accumulator, config, browserInfo) {
|
|
363
|
+
return {
|
|
364
|
+
framework: config.framework,
|
|
365
|
+
platform: config.platform,
|
|
366
|
+
os: resolveOs(config, browserInfo),
|
|
367
|
+
browser: resolveBrowser(config, browserInfo),
|
|
368
|
+
branch: config.branch,
|
|
369
|
+
commit: config.commit,
|
|
370
|
+
environment: config.environment,
|
|
371
|
+
language: config.language,
|
|
372
|
+
milestone: config.milestone,
|
|
373
|
+
metadata: {
|
|
374
|
+
version: PACKAGE_VERSION,
|
|
375
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
376
|
+
cliName: "qualflare-cypress"
|
|
377
|
+
},
|
|
378
|
+
properties: config.properties,
|
|
379
|
+
suites: accumulator.getSuites(),
|
|
380
|
+
ciProvider: config.ciProvider,
|
|
381
|
+
ciBuildNumber: config.ciBuildNumber,
|
|
382
|
+
ciRunUrl: config.ciRunUrl,
|
|
383
|
+
ciPrNumber: config.ciPrNumber
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/plugin/state.ts
|
|
388
|
+
var LaunchAccumulator = class {
|
|
389
|
+
suites = [];
|
|
390
|
+
truncated = false;
|
|
391
|
+
addSuite(suite) {
|
|
392
|
+
if (this.suites.length >= MAX_SUITES_PER_LAUNCH) {
|
|
393
|
+
if (!this.truncated) {
|
|
394
|
+
this.truncated = true;
|
|
395
|
+
logger.warn(
|
|
396
|
+
`reached the server's ${MAX_SUITES_PER_LAUNCH}-suite-per-launch cap \u2014 further spec files' results will not be uploaded this run.`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
this.suites.push(suite);
|
|
402
|
+
}
|
|
403
|
+
getSuites() {
|
|
404
|
+
return this.suites;
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
var TestPhaseGate = class {
|
|
408
|
+
started = false;
|
|
409
|
+
markStarted() {
|
|
410
|
+
this.started = true;
|
|
411
|
+
}
|
|
412
|
+
hasStarted() {
|
|
413
|
+
return this.started;
|
|
414
|
+
}
|
|
415
|
+
/** Called at `before:spec`, so each spec file gets its own fresh
|
|
416
|
+
* before()-hook-vs-real-test boundary. */
|
|
417
|
+
reset() {
|
|
418
|
+
this.started = false;
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
var PendingAttachmentQueue = class {
|
|
422
|
+
pending = [];
|
|
423
|
+
enqueue(attachment) {
|
|
424
|
+
this.pending.push(attachment);
|
|
425
|
+
}
|
|
426
|
+
/** Returns the buffered attachments and clears the queue. */
|
|
427
|
+
drain() {
|
|
428
|
+
const drained = this.pending;
|
|
429
|
+
this.pending = [];
|
|
430
|
+
return drained;
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// src/plugin/events.ts
|
|
435
|
+
function registerEvents(on, config, buffer, pendingAttachments, testPhaseGate) {
|
|
436
|
+
const accumulator = new LaunchAccumulator();
|
|
437
|
+
let browserInfo;
|
|
438
|
+
let currentSpecStart = 0;
|
|
439
|
+
on("before:run", (details) => {
|
|
440
|
+
browserInfo = {
|
|
441
|
+
browserName: details.browser?.displayName,
|
|
442
|
+
browserVersion: details.browser?.version,
|
|
443
|
+
osName: details.system?.osName,
|
|
444
|
+
osVersion: details.system?.osVersion
|
|
445
|
+
};
|
|
446
|
+
});
|
|
447
|
+
on("after:screenshot", (details) => {
|
|
448
|
+
if (!testPhaseGate.hasStarted()) {
|
|
449
|
+
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.`
|
|
451
|
+
);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
pendingAttachments.enqueue({
|
|
455
|
+
name: details.name || (details.testFailure ? "failure-screenshot" : "screenshot"),
|
|
456
|
+
path: details.path,
|
|
457
|
+
mimeType: "image/png"
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
on("before:spec", () => {
|
|
461
|
+
currentSpecStart = Date.now();
|
|
462
|
+
testPhaseGate.reset();
|
|
463
|
+
buffer.drain();
|
|
464
|
+
});
|
|
465
|
+
on("after:spec", (spec, results) => {
|
|
466
|
+
const cases = buffer.drain();
|
|
467
|
+
const suite = {
|
|
468
|
+
name: spec.relative,
|
|
469
|
+
category: "e2e",
|
|
470
|
+
duration: msToNs(results.stats.duration ?? Date.now() - currentSpecStart),
|
|
471
|
+
timestamp: new Date(results.stats.startedAt ?? Date.now()).toISOString(),
|
|
472
|
+
cases
|
|
473
|
+
};
|
|
474
|
+
if (cases.length !== results.stats.tests) {
|
|
475
|
+
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.`
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
accumulator.addSuite(suite);
|
|
480
|
+
const orphaned = pendingAttachments.drain();
|
|
481
|
+
if (orphaned.length > 0) {
|
|
482
|
+
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).`
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
on("after:run", async () => {
|
|
493
|
+
const suites = accumulator.getSuites();
|
|
494
|
+
if (suites.length === 0) {
|
|
495
|
+
logger.info("no test results were captured this run \u2014 skipping upload.");
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
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;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// src/plugin/ci-detect.ts
|
|
520
|
+
import * as ciInfo from "ci-info";
|
|
521
|
+
function parsePositiveInt(raw) {
|
|
522
|
+
if (!raw) {
|
|
523
|
+
return void 0;
|
|
524
|
+
}
|
|
525
|
+
const n = Number.parseInt(raw, 10);
|
|
526
|
+
return Number.isFinite(n) && n >= 1 ? n : void 0;
|
|
527
|
+
}
|
|
528
|
+
function nonEmpty(raw) {
|
|
529
|
+
return raw && raw.length > 0 ? raw : void 0;
|
|
530
|
+
}
|
|
531
|
+
var PROVIDERS = [
|
|
532
|
+
{
|
|
533
|
+
detect: (env) => env.GITHUB_ACTIONS === "true",
|
|
534
|
+
providerName: "GitHub Actions",
|
|
535
|
+
buildNumber: (env) => nonEmpty(env.GITHUB_RUN_NUMBER),
|
|
536
|
+
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
|
+
prNumber: (env) => {
|
|
538
|
+
const match = /^refs\/pull\/(\d+)\/merge$/.exec(env.GITHUB_REF ?? "");
|
|
539
|
+
return match ? parsePositiveInt(match[1]) : void 0;
|
|
540
|
+
}
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
detect: (env) => env.GITLAB_CI === "true",
|
|
544
|
+
providerName: "GitLab CI",
|
|
545
|
+
buildNumber: (env) => nonEmpty(env.CI_PIPELINE_IID),
|
|
546
|
+
runUrl: (env) => nonEmpty(env.CI_PIPELINE_URL),
|
|
547
|
+
prNumber: (env) => parsePositiveInt(env.CI_MERGE_REQUEST_IID)
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
detect: (env) => env.CIRCLECI === "true",
|
|
551
|
+
providerName: "CircleCI",
|
|
552
|
+
buildNumber: (env) => nonEmpty(env.CIRCLE_BUILD_NUM),
|
|
553
|
+
runUrl: (env) => nonEmpty(env.CIRCLE_BUILD_URL),
|
|
554
|
+
prNumber: (env) => parsePositiveInt(env.CIRCLE_PR_NUMBER)
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
detect: (env) => env.BUILDKITE === "true",
|
|
558
|
+
providerName: "Buildkite",
|
|
559
|
+
buildNumber: (env) => nonEmpty(env.BUILDKITE_BUILD_NUMBER),
|
|
560
|
+
runUrl: (env) => nonEmpty(env.BUILDKITE_BUILD_URL),
|
|
561
|
+
prNumber: (env) => {
|
|
562
|
+
const raw = env.BUILDKITE_PULL_REQUEST;
|
|
563
|
+
if (!raw || raw === "false") {
|
|
564
|
+
return void 0;
|
|
565
|
+
}
|
|
566
|
+
return parsePositiveInt(raw);
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
// Jenkins has no simple `JENKINS=true`-style flag; JENKINS_URL is always
|
|
571
|
+
// set by the Jenkins agent and is the conventional detection signal.
|
|
572
|
+
detect: (env) => Boolean(env.JENKINS_URL),
|
|
573
|
+
providerName: "Jenkins",
|
|
574
|
+
buildNumber: (env) => nonEmpty(env.BUILD_NUMBER),
|
|
575
|
+
runUrl: (env) => nonEmpty(env.BUILD_URL)
|
|
576
|
+
// Jenkins has no standardized PR-number env var across its many PR
|
|
577
|
+
// plugins (Multibranch, GitHub Branch Source, etc.) — deliberately
|
|
578
|
+
// omitted rather than guessing at a plugin-specific variable.
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
detect: (env) => env.TF_BUILD === "True" || env.TF_BUILD === "true",
|
|
582
|
+
providerName: "Azure Pipelines",
|
|
583
|
+
buildNumber: (env) => nonEmpty(env.BUILD_BUILDID),
|
|
584
|
+
runUrl: (env) => {
|
|
585
|
+
const collectionUri = env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI;
|
|
586
|
+
const project = env.SYSTEM_TEAMPROJECT;
|
|
587
|
+
const buildId = env.BUILD_BUILDID;
|
|
588
|
+
if (!collectionUri || !project || !buildId) {
|
|
589
|
+
return void 0;
|
|
590
|
+
}
|
|
591
|
+
return `${collectionUri.replace(/\/+$/, "")}/${encodeURIComponent(project)}/_build/results?buildId=${buildId}`;
|
|
592
|
+
},
|
|
593
|
+
prNumber: (env) => parsePositiveInt(env.SYSTEM_PULLREQUEST_PULLREQUESTNUMBER)
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
detect: (env) => Boolean(env.BITBUCKET_BUILD_NUMBER),
|
|
597
|
+
providerName: "Bitbucket Pipelines",
|
|
598
|
+
buildNumber: (env) => nonEmpty(env.BITBUCKET_BUILD_NUMBER),
|
|
599
|
+
runUrl: (env) => {
|
|
600
|
+
const origin = env.BITBUCKET_GIT_HTTP_ORIGIN;
|
|
601
|
+
if (!origin) {
|
|
602
|
+
return void 0;
|
|
603
|
+
}
|
|
604
|
+
const resultsId = env.BITBUCKET_PIPELINE_UUID ?? env.BITBUCKET_BUILD_NUMBER;
|
|
605
|
+
return resultsId ? `${origin}/addon/pipelines/home#!/results/${resultsId}` : void 0;
|
|
606
|
+
},
|
|
607
|
+
prNumber: (env) => parsePositiveInt(env.BITBUCKET_PR_ID)
|
|
608
|
+
}
|
|
609
|
+
];
|
|
610
|
+
function detectCi(env = process.env) {
|
|
611
|
+
const provider = PROVIDERS.find((p) => p.detect(env));
|
|
612
|
+
if (provider) {
|
|
613
|
+
const result = { ciProvider: provider.providerName };
|
|
614
|
+
const buildNumber = provider.buildNumber?.(env);
|
|
615
|
+
if (buildNumber !== void 0) result.ciBuildNumber = buildNumber;
|
|
616
|
+
const runUrl = provider.runUrl?.(env);
|
|
617
|
+
if (runUrl !== void 0) result.ciRunUrl = runUrl;
|
|
618
|
+
const prNumber = provider.prNumber?.(env);
|
|
619
|
+
if (prNumber !== void 0) result.ciPrNumber = prNumber;
|
|
620
|
+
return result;
|
|
621
|
+
}
|
|
622
|
+
if (ciInfo.name) {
|
|
623
|
+
return { ciProvider: ciInfo.name };
|
|
624
|
+
}
|
|
625
|
+
return {};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// src/plugin/git-detect.ts
|
|
629
|
+
import { execFileSync } from "child_process";
|
|
630
|
+
var defaultExecGit = (args, cwd) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
631
|
+
function firstEnv(env, ...names) {
|
|
632
|
+
for (const name2 of names) {
|
|
633
|
+
const value = env[name2];
|
|
634
|
+
if (value) {
|
|
635
|
+
return value;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return void 0;
|
|
639
|
+
}
|
|
640
|
+
function detectBranchFromGit(exec, cwd) {
|
|
641
|
+
try {
|
|
642
|
+
const out = exec(["symbolic-ref", "--short", "-q", "HEAD"], cwd).trim();
|
|
643
|
+
return out || void 0;
|
|
644
|
+
} catch {
|
|
645
|
+
return void 0;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
function detectCommitFromGit(exec, cwd) {
|
|
649
|
+
try {
|
|
650
|
+
const out = exec(["rev-parse", "HEAD"], cwd).trim();
|
|
651
|
+
return out || void 0;
|
|
652
|
+
} catch {
|
|
653
|
+
return void 0;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
function detectGit(env = process.env, cwd = process.cwd(), exec = defaultExecGit) {
|
|
657
|
+
const branch = firstEnv(env, "GIT_BRANCH", "GITHUB_REF_NAME", "CI_COMMIT_REF_NAME", "BITBUCKET_BRANCH") ?? detectBranchFromGit(exec, cwd);
|
|
658
|
+
const commit = firstEnv(env, "GIT_COMMIT", "GITHUB_SHA", "CI_COMMIT_SHA", "BITBUCKET_COMMIT") ?? detectCommitFromGit(exec, cwd);
|
|
659
|
+
const result = {};
|
|
660
|
+
if (branch !== void 0) result.branch = branch;
|
|
661
|
+
if (commit !== void 0) result.commit = commit;
|
|
662
|
+
return result;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// src/plugin/resolve-config.ts
|
|
666
|
+
function firstEnv2(...names) {
|
|
667
|
+
for (const name2 of names) {
|
|
668
|
+
const value = process.env[name2];
|
|
669
|
+
if (value !== void 0 && value !== "") {
|
|
670
|
+
return value;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return void 0;
|
|
674
|
+
}
|
|
675
|
+
function envBool(...names) {
|
|
676
|
+
const raw = firstEnv2(...names);
|
|
677
|
+
if (raw === void 0) {
|
|
678
|
+
return void 0;
|
|
679
|
+
}
|
|
680
|
+
return raw === "true" || raw === "1";
|
|
681
|
+
}
|
|
682
|
+
function envInt(...names) {
|
|
683
|
+
const raw = firstEnv2(...names);
|
|
684
|
+
if (raw === void 0) {
|
|
685
|
+
return void 0;
|
|
686
|
+
}
|
|
687
|
+
const parsed = Number.parseInt(raw, 10);
|
|
688
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
689
|
+
}
|
|
690
|
+
var QualflareConfigError = class extends Error {
|
|
691
|
+
constructor(message) {
|
|
692
|
+
super(message);
|
|
693
|
+
this.name = "QualflareConfigError";
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
function resolveConfig(options, deps = {}) {
|
|
697
|
+
const doDetectGit = deps.detectGit ?? detectGit;
|
|
698
|
+
const doDetectCi = deps.detectCi ?? detectCi;
|
|
699
|
+
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
|
+
}
|
|
706
|
+
const milestoneRaw = options.milestone !== void 0 ? options.milestone : envInt("QUALFLARE_MILESTONE", "QF_MILESTONE");
|
|
707
|
+
const milestone = milestoneRaw !== void 0 && milestoneRaw !== null && milestoneRaw >= 1 ? milestoneRaw : null;
|
|
708
|
+
const envBranch = firstEnv2("QUALFLARE_BRANCH", "QF_BRANCH");
|
|
709
|
+
const envCommit = firstEnv2("QUALFLARE_COMMIT", "QF_COMMIT");
|
|
710
|
+
const needsGitDetection = options.branch === void 0 && envBranch === void 0 || options.commit === void 0 && envCommit === void 0;
|
|
711
|
+
const detectedGit = needsGitDetection ? doDetectGit() : {};
|
|
712
|
+
const branch = options.branch !== void 0 ? options.branch : envBranch ?? detectedGit.branch ?? null;
|
|
713
|
+
const commit = options.commit !== void 0 ? options.commit : envCommit ?? detectedGit.commit ?? null;
|
|
714
|
+
const detectedCi = doDetectCi();
|
|
715
|
+
const ciProvider = options.ciProvider ?? detectedCi.ciProvider;
|
|
716
|
+
const ciBuildNumber = options.ciBuildNumber ?? detectedCi.ciBuildNumber;
|
|
717
|
+
const ciRunUrl = options.ciRunUrl ?? detectedCi.ciRunUrl;
|
|
718
|
+
const ciPrNumber = options.ciPrNumber ?? detectedCi.ciPrNumber;
|
|
719
|
+
return {
|
|
720
|
+
token,
|
|
721
|
+
apiEndpoint: options.apiEndpoint ?? firstEnv2("QUALFLARE_API_ENDPOINT") ?? "https://api.qualflare.com",
|
|
722
|
+
// `||` (truthy check), not `??`, for these three REQUIRED-non-empty wire
|
|
723
|
+
// fields — matching `collect-builder.ts`'s `resolveOs`/`resolveBrowser`,
|
|
724
|
+
// which already correctly treat an explicit `''` option as "not set."
|
|
725
|
+
// `??` only falls back on `null`/`undefined`, so `environment: ''` would
|
|
726
|
+
// previously win outright over the `'development'` default, silently
|
|
727
|
+
// 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.
|
|
731
|
+
environment: (options.environment || void 0) ?? firstEnv2("QUALFLARE_ENVIRONMENT", "QF_ENVIRONMENT") ?? "development",
|
|
732
|
+
language: (options.language || void 0) ?? firstEnv2("QUALFLARE_LANGUAGE", "QF_LANGUAGE") ?? "en-US",
|
|
733
|
+
milestone,
|
|
734
|
+
branch,
|
|
735
|
+
commit,
|
|
736
|
+
platform: options.platform ?? "web",
|
|
737
|
+
framework: options.framework || "cypress",
|
|
738
|
+
os: options.os,
|
|
739
|
+
browser: options.browser,
|
|
740
|
+
properties: options.properties,
|
|
741
|
+
ciProvider,
|
|
742
|
+
ciBuildNumber,
|
|
743
|
+
ciRunUrl,
|
|
744
|
+
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,
|
|
752
|
+
attachScreenshots: options.attachScreenshots ?? envBool("QUALFLARE_ATTACH_SCREENSHOTS") ?? true,
|
|
753
|
+
maxAttachmentBytes: options.maxAttachmentBytes ?? envInt("QUALFLARE_MAX_ATTACHMENT_BYTES") ?? 15e5,
|
|
754
|
+
maxTotalAttachmentBytes: options.maxTotalAttachmentBytes ?? envInt("QUALFLARE_MAX_TOTAL_ATTACHMENT_BYTES") ?? 75e4,
|
|
755
|
+
debug: options.debug ?? envBool("QUALFLARE_DEBUG", "QF_DEBUG") ?? false,
|
|
756
|
+
enabled
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// src/plugin/tasks.ts
|
|
761
|
+
var CaseBuffer = class {
|
|
762
|
+
cases = [];
|
|
763
|
+
truncated = false;
|
|
764
|
+
add(testCase) {
|
|
765
|
+
if (this.cases.length >= MAX_CASES_PER_SUITE) {
|
|
766
|
+
if (!this.truncated) {
|
|
767
|
+
this.truncated = true;
|
|
768
|
+
logger.warn(
|
|
769
|
+
`reached the server's ${MAX_CASES_PER_SUITE}-case-per-suite cap \u2014 further test results in this spec file will not be uploaded.`
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
this.cases.push(testCase);
|
|
775
|
+
}
|
|
776
|
+
/** Returns the buffered cases and clears the buffer (including the
|
|
777
|
+
* truncation-warned flag, so a later spec that also hits the cap warns
|
|
778
|
+
* again rather than staying silent for the rest of the run). */
|
|
779
|
+
drain() {
|
|
780
|
+
const drained = this.cases;
|
|
781
|
+
this.cases = [];
|
|
782
|
+
this.truncated = false;
|
|
783
|
+
return drained;
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
function registerTasks(on, buffer, attachmentConfig, attachmentBudget, pendingAttachments, testPhaseGate) {
|
|
787
|
+
on("task", {
|
|
788
|
+
[TASK_REPORT_CASE](testCase) {
|
|
789
|
+
const attachments = [...testCase.attachments ?? [], ...pendingAttachments.drain()];
|
|
790
|
+
const resolved = resolveAttachments(
|
|
791
|
+
attachments.length > 0 ? attachments : void 0,
|
|
792
|
+
attachmentConfig,
|
|
793
|
+
attachmentBudget
|
|
794
|
+
);
|
|
795
|
+
buffer.add({ ...testCase, attachments: resolved });
|
|
796
|
+
return null;
|
|
797
|
+
},
|
|
798
|
+
// One-shot signal from the browser side (see TestPhaseGate's doc
|
|
799
|
+
// comment in state.ts) — fired from a root-level beforeEach right
|
|
800
|
+
// before the first test's body runs.
|
|
801
|
+
[TASK_MARK_TEST_PHASE_STARTED]() {
|
|
802
|
+
testPhaseGate.markStarted();
|
|
803
|
+
return null;
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// src/plugin/index.ts
|
|
809
|
+
function qualflareCypress(on, config, options = {}) {
|
|
810
|
+
const resolved = resolveConfig(options);
|
|
811
|
+
const buffer = new CaseBuffer();
|
|
812
|
+
const pendingAttachments = new PendingAttachmentQueue();
|
|
813
|
+
const testPhaseGate = new TestPhaseGate();
|
|
814
|
+
const attachmentBudget = new AttachmentBudget(resolved.maxTotalAttachmentBytes);
|
|
815
|
+
registerTasks(on, buffer, resolved, attachmentBudget, pendingAttachments, testPhaseGate);
|
|
816
|
+
if (resolved.enabled) {
|
|
817
|
+
registerEvents(on, resolved, buffer, pendingAttachments, testPhaseGate);
|
|
818
|
+
}
|
|
819
|
+
return config;
|
|
820
|
+
}
|
|
821
|
+
export {
|
|
822
|
+
QualflareConfigError,
|
|
823
|
+
qualflareCypress
|
|
824
|
+
};
|
|
825
|
+
//# sourceMappingURL=index.js.map
|