@qualflare/cucumberjs 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 +49 -14
- package/dist/formatter/index.cjs +214 -302
- package/dist/formatter/index.cjs.map +1 -1
- package/dist/formatter/index.d.cts +14 -1
- package/dist/formatter/index.d.ts +14 -1
- package/dist/formatter/index.js +214 -302
- package/dist/formatter/index.js.map +1 -1
- package/dist/index.cjs +1 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +25 -2
- package/dist/index.d.ts +25 -2
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/formatter/index.cjs
CHANGED
|
@@ -35,8 +35,19 @@ __export(formatter_exports, {
|
|
|
35
35
|
module.exports = __toCommonJS(formatter_exports);
|
|
36
36
|
|
|
37
37
|
// src/formatter/formatter.ts
|
|
38
|
+
var import_node_crypto2 = require("crypto");
|
|
39
|
+
var fs3 = __toESM(require("fs"), 1);
|
|
40
|
+
var path4 = __toESM(require("path"), 1);
|
|
38
41
|
var import_cucumber = require("@cucumber/cucumber");
|
|
39
42
|
|
|
43
|
+
// src/shared/constants.ts
|
|
44
|
+
var RESERVED_MESSAGE_MEDIA_TYPE = "application/vnd.qualflare.message+json";
|
|
45
|
+
var MAX_SUITES_PER_LAUNCH = 2e3;
|
|
46
|
+
var MAX_CASES_PER_SUITE = 5e3;
|
|
47
|
+
var MAX_TAGS_PER_CASE = 64;
|
|
48
|
+
var MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
49
|
+
var MAX_STEPS_PER_TEST_ATTEMPT = 300;
|
|
50
|
+
|
|
40
51
|
// src/config/ci-detect.ts
|
|
41
52
|
var ciInfo = __toESM(require("ci-info"), 1);
|
|
42
53
|
function parsePositiveInt(raw) {
|
|
@@ -208,22 +219,30 @@ function envInt(...names) {
|
|
|
208
219
|
const parsed = Number.parseInt(raw, 10);
|
|
209
220
|
return Number.isFinite(parsed) ? parsed : void 0;
|
|
210
221
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
222
|
+
function argvShardIndex(argv = process.argv) {
|
|
223
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
224
|
+
const arg = argv[i];
|
|
225
|
+
if (arg === void 0) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const raw = arg === "--shard" ? argv[i + 1] : arg.startsWith("--shard=") ? arg.slice("--shard=".length) : void 0;
|
|
229
|
+
if (raw === void 0) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (!/^\d+\/\d+$/.test(raw)) {
|
|
233
|
+
return void 0;
|
|
234
|
+
}
|
|
235
|
+
const oneBased = Number.parseInt(raw.split("/")[0] ?? "", 10);
|
|
236
|
+
return Number.isFinite(oneBased) && oneBased >= 1 ? oneBased - 1 : void 0;
|
|
215
237
|
}
|
|
216
|
-
|
|
238
|
+
return void 0;
|
|
239
|
+
}
|
|
217
240
|
function resolveConfig(options, deps = {}) {
|
|
218
241
|
const doDetectGit = deps.detectGit ?? detectGit;
|
|
219
242
|
const doDetectCi = deps.detectCi ?? detectCi;
|
|
220
243
|
const enabled = options.enabled ?? envBool("QUALFLARE_ENABLED") ?? true;
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
throw new QualflareConfigError(
|
|
224
|
-
"qualflare-cucumberjs: no token configured. Set the `token` format option or the QUALFLARE_TOKEN (or QF_TOKEN) environment variable, or pass `enabled: false` to disable this formatter."
|
|
225
|
-
);
|
|
226
|
-
}
|
|
244
|
+
const outputDir = options.outputDir || firstEnv2("QUALFLARE_OUTPUT_DIR") || "./qualflare-results";
|
|
245
|
+
const shardIndex = options.shardIndex ?? envInt("QUALFLARE_SHARD_INDEX") ?? argvShardIndex();
|
|
227
246
|
const milestoneRaw = options.milestone !== void 0 ? options.milestone : envInt("QUALFLARE_MILESTONE", "QF_MILESTONE");
|
|
228
247
|
const milestone = milestoneRaw !== void 0 && milestoneRaw !== null && milestoneRaw >= 1 ? milestoneRaw : null;
|
|
229
248
|
const envBranch = firstEnv2("QUALFLARE_BRANCH", "QF_BRANCH");
|
|
@@ -238,13 +257,9 @@ function resolveConfig(options, deps = {}) {
|
|
|
238
257
|
const ciRunUrl = options.ciRunUrl ?? detectedCi.ciRunUrl;
|
|
239
258
|
const ciPrNumber = options.ciPrNumber ?? detectedCi.ciPrNumber;
|
|
240
259
|
return {
|
|
241
|
-
token,
|
|
242
|
-
apiEndpoint: options.apiEndpoint ?? firstEnv2("QUALFLARE_API_ENDPOINT") ?? "https://api.qualflare.com",
|
|
243
260
|
// `||` (truthy check), not `??`, for these three REQUIRED-non-empty wire
|
|
244
261
|
// fields — an explicit `''` option must not silently win over the
|
|
245
|
-
// default (the server rejects an empty `environment
|
|
246
|
-
// `failOnUploadError` defaults `false`, that would fail the entire
|
|
247
|
-
// upload with no visible error by default). Ported verbatim from
|
|
262
|
+
// default (the server rejects an empty `environment`). Ported verbatim from
|
|
248
263
|
// qualflare-cypress, where this was found via deep adversarial review.
|
|
249
264
|
environment: (options.environment || void 0) ?? firstEnv2("QUALFLARE_ENVIRONMENT", "QF_ENVIRONMENT") ?? "development",
|
|
250
265
|
language: (options.language || void 0) ?? firstEnv2("QUALFLARE_LANGUAGE", "QF_LANGUAGE") ?? "en-US",
|
|
@@ -260,38 +275,18 @@ function resolveConfig(options, deps = {}) {
|
|
|
260
275
|
ciBuildNumber,
|
|
261
276
|
ciRunUrl,
|
|
262
277
|
ciPrNumber,
|
|
263
|
-
timeoutMs: options.timeoutMs ?? envInt("QUALFLARE_TIMEOUT_MS") ?? 12e4,
|
|
264
|
-
retry: {
|
|
265
|
-
max: options.retry?.max ?? envInt("QUALFLARE_RETRY_MAX", "QF_RETRY_MAX") ?? 3,
|
|
266
|
-
baseDelayMs: options.retry?.baseDelayMs ?? envInt("QUALFLARE_RETRY_BASE_DELAY_MS") ?? 1e3,
|
|
267
|
-
maxDelayMs: options.retry?.maxDelayMs ?? envInt("QUALFLARE_RETRY_MAX_DELAY_MS") ?? 3e4
|
|
268
|
-
},
|
|
269
|
-
failOnUploadError: options.failOnUploadError ?? envBool("QUALFLARE_FAIL_ON_UPLOAD_ERROR") ?? false,
|
|
270
278
|
attachScreenshots: options.attachScreenshots ?? envBool("QUALFLARE_ATTACH_SCREENSHOTS") ?? true,
|
|
271
279
|
includeStepHooks: options.includeStepHooks ?? envBool("QUALFLARE_INCLUDE_STEP_HOOKS") ?? false,
|
|
272
280
|
maxAttachmentBytes: options.maxAttachmentBytes ?? envInt("QUALFLARE_MAX_ATTACHMENT_BYTES") ?? 15e5,
|
|
273
281
|
maxTotalAttachmentBytes: options.maxTotalAttachmentBytes ?? envInt("QUALFLARE_MAX_TOTAL_ATTACHMENT_BYTES") ?? 75e4,
|
|
282
|
+
maxVideoBytes: options.maxVideoBytes ?? envInt("QUALFLARE_MAX_VIDEO_BYTES") ?? MAX_VIDEO_UPLOAD_BYTES,
|
|
274
283
|
debug: options.debug ?? envBool("QUALFLARE_DEBUG", "QF_DEBUG") ?? false,
|
|
275
|
-
enabled
|
|
284
|
+
enabled,
|
|
285
|
+
outputDir,
|
|
286
|
+
shardIndex
|
|
276
287
|
};
|
|
277
288
|
}
|
|
278
289
|
|
|
279
|
-
// src/http/client.ts
|
|
280
|
-
var import_undici = require("undici");
|
|
281
|
-
|
|
282
|
-
// src/shared/constants.ts
|
|
283
|
-
var RESERVED_MESSAGE_MEDIA_TYPE = "application/vnd.qualflare.message+json";
|
|
284
|
-
var HEADER_TOKEN = "QF_TOKEN";
|
|
285
|
-
var HEADER_IDEMPOTENCY_KEY = "Idempotency-Key";
|
|
286
|
-
var HEADER_CONTENT_TYPE = "Content-Type";
|
|
287
|
-
var HEADER_ACCEPT = "Accept";
|
|
288
|
-
var HEADER_USER_AGENT = "User-Agent";
|
|
289
|
-
var MAX_SUITES_PER_LAUNCH = 2e3;
|
|
290
|
-
var MAX_CASES_PER_SUITE = 5e3;
|
|
291
|
-
var MAX_TAGS_PER_CASE = 64;
|
|
292
|
-
var MAX_IDEMPOTENCY_KEY_CHARS = 255;
|
|
293
|
-
var MAX_STEPS_PER_TEST_ATTEMPT = 300;
|
|
294
|
-
|
|
295
290
|
// src/shared/logger.ts
|
|
296
291
|
var PREFIX = "[qualflare-cucumberjs]";
|
|
297
292
|
var logger = {
|
|
@@ -309,220 +304,86 @@ var logger = {
|
|
|
309
304
|
}
|
|
310
305
|
};
|
|
311
306
|
|
|
312
|
-
// src/
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const jittered = Math.random() * Math.min(exponential, maxDelayMs);
|
|
316
|
-
const floor = retryAfterMs !== void 0 ? retryAfterMs : 0;
|
|
317
|
-
return Math.min(Math.max(jittered, floor), maxDelayMs);
|
|
318
|
-
}
|
|
307
|
+
// src/formatter/attachment-budget.ts
|
|
308
|
+
var fs2 = __toESM(require("fs"), 1);
|
|
309
|
+
var path2 = __toESM(require("path"), 1);
|
|
319
310
|
|
|
320
|
-
// src/
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
}
|
|
311
|
+
// src/formatter/video-uploader.ts
|
|
312
|
+
var import_node_crypto = require("crypto");
|
|
313
|
+
var fs = __toESM(require("fs"), 1);
|
|
314
|
+
var path = __toESM(require("path"), 1);
|
|
315
|
+
var VIDEO_MIME_TYPES_BY_EXTENSION = {
|
|
316
|
+
".mp4": "video/mp4",
|
|
317
|
+
".webm": "video/webm",
|
|
318
|
+
".mov": "video/quicktime"
|
|
319
|
+
};
|
|
320
|
+
var EXTENSION_BY_VIDEO_MIME_TYPE = {
|
|
321
|
+
"video/mp4": ".mp4",
|
|
322
|
+
"video/webm": ".webm",
|
|
323
|
+
"video/quicktime": ".mov"
|
|
324
|
+
};
|
|
325
|
+
function resolveVideoMimeType(mimeType, filePath) {
|
|
326
|
+
if (filePath) {
|
|
327
|
+
const extension2 = path.extname(filePath).toLowerCase();
|
|
328
|
+
const resolvedMimeType = VIDEO_MIME_TYPES_BY_EXTENSION[extension2];
|
|
329
|
+
return resolvedMimeType ? { mimeType: resolvedMimeType, extension: extension2 } : void 0;
|
|
330
|
+
}
|
|
331
|
+
const normalized = mimeType?.toLowerCase();
|
|
332
|
+
const extension = normalized ? EXTENSION_BY_VIDEO_MIME_TYPE[normalized] : void 0;
|
|
333
|
+
return normalized && extension ? { mimeType: normalized, extension } : void 0;
|
|
344
334
|
}
|
|
345
|
-
function
|
|
346
|
-
|
|
335
|
+
function writeVideoAttachment(pending, outputDir, maxVideoBytes) {
|
|
336
|
+
const resolved = resolveVideoMimeType(pending.mimeType, pending.path);
|
|
337
|
+
if (!resolved) {
|
|
338
|
+
logger.warn(`skipping video attachment "${pending.name}": unsupported video format.`);
|
|
347
339
|
return void 0;
|
|
348
340
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
requestId;
|
|
359
|
-
fields;
|
|
360
|
-
constructor(init) {
|
|
361
|
-
const parts = [init.message];
|
|
362
|
-
const fieldsRendered = renderFields(init.fields);
|
|
363
|
-
if (fieldsRendered) {
|
|
364
|
-
parts.push(`fields: ${fieldsRendered}`);
|
|
341
|
+
const localVideoPath = `${(0, import_node_crypto.randomUUID)()}${resolved.extension}`;
|
|
342
|
+
const destination = path.join(outputDir, localVideoPath);
|
|
343
|
+
if (pending.path !== void 0) {
|
|
344
|
+
let fileSize;
|
|
345
|
+
try {
|
|
346
|
+
fileSize = fs.statSync(pending.path).size;
|
|
347
|
+
} catch (err) {
|
|
348
|
+
logger.warn(`skipping video attachment "${pending.path}": could not stat file: ${err.message}`);
|
|
349
|
+
return void 0;
|
|
365
350
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
351
|
+
if (fileSize > maxVideoBytes) {
|
|
352
|
+
logger.warn(
|
|
353
|
+
`skipping video attachment "${pending.path}": ${fileSize} bytes exceeds the configured maxVideoBytes cap of ${maxVideoBytes} bytes.`
|
|
354
|
+
);
|
|
355
|
+
return void 0;
|
|
369
356
|
}
|
|
370
|
-
|
|
371
|
-
|
|
357
|
+
try {
|
|
358
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
359
|
+
fs.copyFileSync(pending.path, destination);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
logger.warn(`skipping video attachment "${pending.path}": could not copy file: ${err.message}`);
|
|
362
|
+
return void 0;
|
|
372
363
|
}
|
|
373
|
-
|
|
374
|
-
this.name = "QualflareApiError";
|
|
375
|
-
this.code = init.code;
|
|
376
|
-
this.statusCode = init.statusCode;
|
|
377
|
-
this.requestId = init.requestId;
|
|
378
|
-
this.fields = init.fields;
|
|
364
|
+
return { localVideoPath, fileSize, mimeType: resolved.mimeType };
|
|
379
365
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
message,
|
|
386
|
-
code,
|
|
387
|
-
statusCode,
|
|
388
|
-
requestId: body?.request_id,
|
|
389
|
-
fields: body?.fields
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// src/http/idempotency.ts
|
|
394
|
-
var import_node_crypto = require("crypto");
|
|
395
|
-
function newIdempotencyKey() {
|
|
396
|
-
const key = (0, import_node_crypto.randomUUID)();
|
|
397
|
-
if (key.length > MAX_IDEMPOTENCY_KEY_CHARS) {
|
|
398
|
-
return key.slice(0, MAX_IDEMPOTENCY_KEY_CHARS);
|
|
399
|
-
}
|
|
400
|
-
return key;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// src/http/client.ts
|
|
404
|
-
var RETRYABLE_STATUS_CODES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
405
|
-
function redactToken(token) {
|
|
406
|
-
return token.length > 0 ? "***REDACTED***" : "(none)";
|
|
407
|
-
}
|
|
408
|
-
var QualflareHttpClient = class {
|
|
409
|
-
constructor(opts) {
|
|
410
|
-
this.opts = opts;
|
|
411
|
-
}
|
|
412
|
-
opts;
|
|
413
|
-
async send(collect) {
|
|
414
|
-
const url = `${this.opts.endpoint.replace(/\/+$/, "")}/api/v1/collect`;
|
|
415
|
-
const idempotencyKey = newIdempotencyKey();
|
|
416
|
-
const body = JSON.stringify(collect);
|
|
417
|
-
const maxAttempts = Math.max(1, this.opts.retry.max + 1);
|
|
418
|
-
let lastError;
|
|
419
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
420
|
-
if (this.opts.debug) {
|
|
421
|
-
logger.debug(
|
|
422
|
-
`POST ${url} (attempt ${attempt}/${maxAttempts}, QF_TOKEN: ${redactToken(this.opts.token)})`
|
|
423
|
-
);
|
|
424
|
-
}
|
|
425
|
-
let statusCode;
|
|
426
|
-
let responseBody;
|
|
427
|
-
let responseHeaders;
|
|
428
|
-
try {
|
|
429
|
-
const res = await (0, import_undici.request)(url, {
|
|
430
|
-
method: "POST",
|
|
431
|
-
headers: {
|
|
432
|
-
[HEADER_TOKEN]: this.opts.token,
|
|
433
|
-
[HEADER_CONTENT_TYPE]: "application/json",
|
|
434
|
-
[HEADER_ACCEPT]: "application/json",
|
|
435
|
-
[HEADER_USER_AGENT]: this.opts.userAgent,
|
|
436
|
-
[HEADER_IDEMPOTENCY_KEY]: idempotencyKey
|
|
437
|
-
},
|
|
438
|
-
body,
|
|
439
|
-
maxRedirections: 0,
|
|
440
|
-
signal: AbortSignal.timeout(this.opts.timeoutMs)
|
|
441
|
-
});
|
|
442
|
-
statusCode = res.statusCode;
|
|
443
|
-
responseHeaders = res.headers;
|
|
444
|
-
responseBody = await res.body.text();
|
|
445
|
-
} catch (err) {
|
|
446
|
-
lastError = err;
|
|
447
|
-
if (this.opts.debug) {
|
|
448
|
-
logger.debug(`attempt ${attempt} transport error: ${err?.message ?? err}`);
|
|
449
|
-
}
|
|
450
|
-
if (attempt < maxAttempts) {
|
|
451
|
-
await sleep(computeDelay(attempt, this.opts.retry.baseDelayMs, this.opts.retry.maxDelayMs));
|
|
452
|
-
continue;
|
|
453
|
-
}
|
|
454
|
-
throw new QualflareApiError({
|
|
455
|
-
message: `failed to send request to ${url}`,
|
|
456
|
-
cause: err
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
if (this.opts.debug) {
|
|
460
|
-
logger.debug(`attempt ${attempt} response: ${statusCode}`);
|
|
461
|
-
}
|
|
462
|
-
if (statusCode >= 200 && statusCode < 300) {
|
|
463
|
-
return parseSuccess(responseBody);
|
|
464
|
-
}
|
|
465
|
-
const parsedError = parseErrorBody(responseBody);
|
|
466
|
-
if (!RETRYABLE_STATUS_CODES.has(statusCode) || attempt === maxAttempts) {
|
|
467
|
-
throw buildApiError(statusCode, parsedError);
|
|
468
|
-
}
|
|
469
|
-
const retryAfterMs = parseRetryAfter(responseHeaders["retry-after"]);
|
|
470
|
-
const delay = computeDelay(
|
|
471
|
-
attempt,
|
|
472
|
-
this.opts.retry.baseDelayMs,
|
|
473
|
-
this.opts.retry.maxDelayMs,
|
|
474
|
-
retryAfterMs
|
|
366
|
+
if (pending.content !== void 0) {
|
|
367
|
+
const fileSize = Buffer.byteLength(pending.content, "base64");
|
|
368
|
+
if (fileSize > maxVideoBytes) {
|
|
369
|
+
logger.warn(
|
|
370
|
+
`skipping video attachment "${pending.name}": ${fileSize} bytes exceeds the configured maxVideoBytes cap of ${maxVideoBytes} bytes.`
|
|
475
371
|
);
|
|
476
|
-
|
|
477
|
-
logger.debug(`retrying after ${Math.round(delay)}ms (status ${statusCode})`);
|
|
478
|
-
}
|
|
479
|
-
await sleep(delay);
|
|
372
|
+
return void 0;
|
|
480
373
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
if (typeof parsed.seq !== "number") {
|
|
488
|
-
throw new Error('response body missing numeric "seq"');
|
|
374
|
+
try {
|
|
375
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
376
|
+
fs.writeFileSync(destination, Buffer.from(pending.content, "base64"));
|
|
377
|
+
} catch (err) {
|
|
378
|
+
logger.warn(`skipping video attachment "${pending.name}": could not write file: ${err.message}`);
|
|
379
|
+
return void 0;
|
|
489
380
|
}
|
|
490
|
-
return
|
|
491
|
-
} catch (err) {
|
|
492
|
-
throw new QualflareApiError({
|
|
493
|
-
message: "server returned a success status but an unparseable body",
|
|
494
|
-
cause: err
|
|
495
|
-
});
|
|
381
|
+
return { localVideoPath, fileSize, mimeType: resolved.mimeType };
|
|
496
382
|
}
|
|
383
|
+
return void 0;
|
|
497
384
|
}
|
|
498
|
-
function parseErrorBody(responseBody) {
|
|
499
|
-
if (!responseBody) {
|
|
500
|
-
return void 0;
|
|
501
|
-
}
|
|
502
|
-
try {
|
|
503
|
-
return JSON.parse(responseBody);
|
|
504
|
-
} catch {
|
|
505
|
-
return void 0;
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
function parseRetryAfter(value) {
|
|
509
|
-
const raw = Array.isArray(value) ? value[0] : value;
|
|
510
|
-
if (!raw) {
|
|
511
|
-
return void 0;
|
|
512
|
-
}
|
|
513
|
-
const seconds = Number(raw);
|
|
514
|
-
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : void 0;
|
|
515
|
-
}
|
|
516
|
-
function sleep(ms) {
|
|
517
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
// src/config/version.ts
|
|
521
|
-
var PACKAGE_VERSION = "0.1.0";
|
|
522
385
|
|
|
523
386
|
// src/formatter/attachment-budget.ts
|
|
524
|
-
var fs = __toESM(require("fs"), 1);
|
|
525
|
-
var path = __toESM(require("path"), 1);
|
|
526
387
|
var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([".mp4", ".webm", ".mov", ".avi", ".mkv"]);
|
|
527
388
|
var AttachmentBudget = class {
|
|
528
389
|
constructor(maxTotalBytes) {
|
|
@@ -547,7 +408,7 @@ function isVideoLike(mimeType, filePath) {
|
|
|
547
408
|
if (mimeType?.toLowerCase().startsWith("video/")) {
|
|
548
409
|
return true;
|
|
549
410
|
}
|
|
550
|
-
if (filePath && VIDEO_EXTENSIONS.has(
|
|
411
|
+
if (filePath && VIDEO_EXTENSIONS.has(path2.extname(filePath).toLowerCase())) {
|
|
551
412
|
return true;
|
|
552
413
|
}
|
|
553
414
|
return false;
|
|
@@ -555,7 +416,7 @@ function isVideoLike(mimeType, filePath) {
|
|
|
555
416
|
function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
|
|
556
417
|
let size;
|
|
557
418
|
try {
|
|
558
|
-
size =
|
|
419
|
+
size = fs2.statSync(filePath).size;
|
|
559
420
|
} catch (err) {
|
|
560
421
|
return { skipped: true, reason: `could not stat file: ${err.message}` };
|
|
561
422
|
}
|
|
@@ -572,7 +433,7 @@ function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
|
|
|
572
433
|
};
|
|
573
434
|
}
|
|
574
435
|
try {
|
|
575
|
-
const content =
|
|
436
|
+
const content = fs2.readFileSync(filePath).toString("base64");
|
|
576
437
|
return { skipped: false, content };
|
|
577
438
|
} catch (err) {
|
|
578
439
|
return { skipped: true, reason: `could not read file: ${err.message}` };
|
|
@@ -582,12 +443,6 @@ function resolvePendingAttachment(pending, config, budget) {
|
|
|
582
443
|
if (!config.attachScreenshots) {
|
|
583
444
|
return void 0;
|
|
584
445
|
}
|
|
585
|
-
if (isVideoLike(pending.mimeType, pending.path)) {
|
|
586
|
-
logger.warn(
|
|
587
|
-
`refusing to attach "${pending.name}": video attachments are not supported yet (${pending.path ?? pending.mimeType ?? "unknown"}).`
|
|
588
|
-
);
|
|
589
|
-
return void 0;
|
|
590
|
-
}
|
|
591
446
|
if (pending.content !== void 0) {
|
|
592
447
|
const bytes = Buffer.byteLength(pending.content, "base64");
|
|
593
448
|
if (bytes > config.maxAttachmentBytes) {
|
|
@@ -620,6 +475,22 @@ function resolvePendingAttachment(pending, config, budget) {
|
|
|
620
475
|
}
|
|
621
476
|
return void 0;
|
|
622
477
|
}
|
|
478
|
+
async function resolveVideoAttachment(pending, config) {
|
|
479
|
+
if (!config.attachScreenshots) {
|
|
480
|
+
return void 0;
|
|
481
|
+
}
|
|
482
|
+
const written = writeVideoAttachment(pending, config.outputDir, config.maxVideoBytes);
|
|
483
|
+
if (!written) {
|
|
484
|
+
return void 0;
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
name: pending.name,
|
|
488
|
+
mimeType: written.mimeType,
|
|
489
|
+
localVideoPath: written.localVideoPath,
|
|
490
|
+
fileSize: written.fileSize,
|
|
491
|
+
stepIndex: pending.stepIndex
|
|
492
|
+
};
|
|
493
|
+
}
|
|
623
494
|
|
|
624
495
|
// src/formatter/attempt-tracker.ts
|
|
625
496
|
var import_messages2 = require("@cucumber/messages");
|
|
@@ -866,7 +737,8 @@ var AttemptTracker = class {
|
|
|
866
737
|
tags: [],
|
|
867
738
|
properties: {},
|
|
868
739
|
attachments: [],
|
|
869
|
-
stepCapWarned: false
|
|
740
|
+
stepCapWarned: false,
|
|
741
|
+
pendingVideoWrites: []
|
|
870
742
|
};
|
|
871
743
|
this.byTestCaseStartedId.set(e.id, record);
|
|
872
744
|
const attempts = this.byTestCaseId.get(testCase.id) ?? [];
|
|
@@ -944,11 +816,25 @@ var AttemptTracker = class {
|
|
|
944
816
|
}
|
|
945
817
|
const stepIndex = this.resolveStepIndex(record, e.testStepId);
|
|
946
818
|
const content = e.contentEncoding === "BASE64" ? e.body : Buffer.from(e.body, "utf8").toString("base64");
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
819
|
+
this.resolveAttachment(record, { name: e.fileName || "attachment", mimeType: e.mediaType, content, stepIndex });
|
|
820
|
+
}
|
|
821
|
+
/** Resolves one pending attachment, routing a video-like one through the
|
|
822
|
+
* write-to-`outputDir` flow (tracked in `record.pendingVideoWrites` so
|
|
823
|
+
* `finish()` can wait for it) and everything else through the synchronous
|
|
824
|
+
* inline path — shared by the real `World.attach()` handler above and both
|
|
825
|
+
* `qualflare.attachment()`/`attachmentFromFile()` runtime-message cases
|
|
826
|
+
* below. */
|
|
827
|
+
resolveAttachment(record, pending) {
|
|
828
|
+
if (isVideoLike(pending.mimeType, pending.path)) {
|
|
829
|
+
const write = resolveVideoAttachment(pending, this.config).then((resolved2) => {
|
|
830
|
+
if (resolved2) {
|
|
831
|
+
record.attachments.push(resolved2);
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
record.pendingVideoWrites.push(write);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
const resolved = resolvePendingAttachment(pending, this.config, this.attachmentBudget);
|
|
952
838
|
if (resolved) {
|
|
953
839
|
record.attachments.push(resolved);
|
|
954
840
|
}
|
|
@@ -1005,26 +891,12 @@ var AttemptTracker = class {
|
|
|
1005
891
|
}
|
|
1006
892
|
case "attachment": {
|
|
1007
893
|
const stepIndex = testStepId !== void 0 ? record.stepIndexByTestStepId.get(testStepId) : void 0;
|
|
1008
|
-
|
|
1009
|
-
{ name: message.name, mimeType: message.mimeType, content: message.contentBase64, stepIndex },
|
|
1010
|
-
this.config,
|
|
1011
|
-
this.attachmentBudget
|
|
1012
|
-
);
|
|
1013
|
-
if (resolved) {
|
|
1014
|
-
record.attachments.push(resolved);
|
|
1015
|
-
}
|
|
894
|
+
this.resolveAttachment(record, { name: message.name, mimeType: message.mimeType, content: message.contentBase64, stepIndex });
|
|
1016
895
|
return;
|
|
1017
896
|
}
|
|
1018
897
|
case "attachment_from_file": {
|
|
1019
898
|
const stepIndex = testStepId !== void 0 ? record.stepIndexByTestStepId.get(testStepId) : void 0;
|
|
1020
|
-
|
|
1021
|
-
{ name: message.name, mimeType: message.mimeType, path: message.path, stepIndex },
|
|
1022
|
-
this.config,
|
|
1023
|
-
this.attachmentBudget
|
|
1024
|
-
);
|
|
1025
|
-
if (resolved) {
|
|
1026
|
-
record.attachments.push(resolved);
|
|
1027
|
-
}
|
|
899
|
+
this.resolveAttachment(record, { name: message.name, mimeType: message.mimeType, path: message.path, stepIndex });
|
|
1028
900
|
return;
|
|
1029
901
|
}
|
|
1030
902
|
case "step_start": {
|
|
@@ -1049,8 +921,21 @@ var AttemptTracker = class {
|
|
|
1049
921
|
}
|
|
1050
922
|
/** Returns the collapsed result once all attempts of this logical
|
|
1051
923
|
* scenario have arrived, or `undefined` if more attempts are coming
|
|
1052
|
-
* (`willBeRetried === true`).
|
|
1053
|
-
|
|
924
|
+
* (`willBeRetried === true`).
|
|
925
|
+
*
|
|
926
|
+
* Async because it must first await every attempt's own
|
|
927
|
+
* `pendingVideoWrites` (any video attached anywhere across every retry
|
|
928
|
+
* of this scenario). This is load-bearing, not just tidiness:
|
|
929
|
+
* `collapseAttempts`/`buildCase` read `attachments` by REFERENCE, not by
|
|
930
|
+
* copy, and `buildCase` runs synchronously right after this resolves — a
|
|
931
|
+
* scenario whose ONLY attachment is a still-uploading video would have an
|
|
932
|
+
* EMPTY `attachments` array at that instant, and `buildCase` captures
|
|
933
|
+
* `attachments.length > 0 ? attachments : undefined` as a plain
|
|
934
|
+
* `undefined` VALUE right then, permanently — a later push onto the
|
|
935
|
+
* (still-live) array reference would no longer be visible through
|
|
936
|
+
* `undefined`. Awaiting here first guarantees `attachments` is complete
|
|
937
|
+
* before `buildCase` ever reads it. */
|
|
938
|
+
async finish(e) {
|
|
1054
939
|
const record = this.byTestCaseStartedId.get(e.testCaseStartedId);
|
|
1055
940
|
this.byTestCaseStartedId.delete(e.testCaseStartedId);
|
|
1056
941
|
if (!record) {
|
|
@@ -1062,6 +947,10 @@ var AttemptTracker = class {
|
|
|
1062
947
|
const testCaseId = record.testCase.id;
|
|
1063
948
|
const attempts = this.byTestCaseId.get(testCaseId) ?? [record];
|
|
1064
949
|
this.byTestCaseId.delete(testCaseId);
|
|
950
|
+
const pendingWrites = attempts.flatMap((a) => a.pendingVideoWrites);
|
|
951
|
+
if (pendingWrites.length > 0) {
|
|
952
|
+
await Promise.all(pendingWrites);
|
|
953
|
+
}
|
|
1065
954
|
const snapshots = attempts.map((a) => {
|
|
1066
955
|
const worst = a.stepResults.length > 0 ? (0, import_messages2.getWorstTestStepResult)(a.stepResults) : void 0;
|
|
1067
956
|
const duration = a.stepResults.reduce((sum, r) => sum + messageDurationToNs(r.duration), 0);
|
|
@@ -1096,6 +985,11 @@ function timestampMs(ts) {
|
|
|
1096
985
|
|
|
1097
986
|
// src/formatter/collect-builder.ts
|
|
1098
987
|
var os = __toESM(require("os"), 1);
|
|
988
|
+
|
|
989
|
+
// src/config/version.ts
|
|
990
|
+
var PACKAGE_VERSION = "0.2.0";
|
|
991
|
+
|
|
992
|
+
// src/formatter/collect-builder.ts
|
|
1099
993
|
function resolveOs(config) {
|
|
1100
994
|
if (config.os) {
|
|
1101
995
|
return config.os;
|
|
@@ -1255,7 +1149,7 @@ var RunHookTracker = class {
|
|
|
1255
1149
|
}
|
|
1256
1150
|
return {
|
|
1257
1151
|
name: "(global hooks)",
|
|
1258
|
-
category: "
|
|
1152
|
+
category: "cucumber",
|
|
1259
1153
|
duration: this.failed.reduce((sum, c) => sum + c.duration, 0),
|
|
1260
1154
|
cases: this.failed
|
|
1261
1155
|
};
|
|
@@ -1263,7 +1157,7 @@ var RunHookTracker = class {
|
|
|
1263
1157
|
};
|
|
1264
1158
|
|
|
1265
1159
|
// src/formatter/suite-builder.ts
|
|
1266
|
-
var
|
|
1160
|
+
var path3 = __toESM(require("path"), 1);
|
|
1267
1161
|
function groupIntoSuites(cases, cwd, extraSuite) {
|
|
1268
1162
|
const byUri = /* @__PURE__ */ new Map();
|
|
1269
1163
|
for (const { uri, case: kase } of cases) {
|
|
@@ -1283,7 +1177,7 @@ function groupIntoSuites(cases, cwd, extraSuite) {
|
|
|
1283
1177
|
}
|
|
1284
1178
|
suites.push({
|
|
1285
1179
|
name: relativizeUri(uri, cwd),
|
|
1286
|
-
category: "
|
|
1180
|
+
category: "cucumber",
|
|
1287
1181
|
duration: kases.reduce((sum, c) => sum + c.duration, 0),
|
|
1288
1182
|
cases: kases.slice(0, MAX_CASES_PER_SUITE)
|
|
1289
1183
|
});
|
|
@@ -1303,10 +1197,10 @@ function relativizeUri(uri, cwd) {
|
|
|
1303
1197
|
if (normalized.startsWith("file://")) {
|
|
1304
1198
|
normalized = new URL(normalized).pathname;
|
|
1305
1199
|
}
|
|
1306
|
-
if (
|
|
1307
|
-
normalized =
|
|
1200
|
+
if (path3.isAbsolute(normalized)) {
|
|
1201
|
+
normalized = path3.relative(cwd, normalized);
|
|
1308
1202
|
}
|
|
1309
|
-
return normalized.split(
|
|
1203
|
+
return normalized.split(path3.sep).join("/");
|
|
1310
1204
|
}
|
|
1311
1205
|
|
|
1312
1206
|
// src/formatter/formatter.ts
|
|
@@ -1320,12 +1214,25 @@ var QualflareCucumberFormatter = class extends import_cucumber.Formatter {
|
|
|
1320
1214
|
attemptTracker;
|
|
1321
1215
|
runHookTracker = new RunHookTracker();
|
|
1322
1216
|
finishedCases = [];
|
|
1217
|
+
/** One promise per `testCaseFinished` envelope, resolving once that
|
|
1218
|
+
* scenario's `AttemptTracker.finish()` (which itself awaits any pending
|
|
1219
|
+
* video uploads — see its doc comment) has settled and, if it produced a
|
|
1220
|
+
* result, been pushed into `finishedCases`. `finished()` awaits all of
|
|
1221
|
+
* these before building/uploading the Collect payload, so a scenario
|
|
1222
|
+
* whose only attachment is a still-uploading video is never silently
|
|
1223
|
+
* dropped from the report. */
|
|
1224
|
+
pendingCaseBuilds = [];
|
|
1323
1225
|
constructor(options) {
|
|
1324
1226
|
super(options);
|
|
1325
1227
|
this.config = resolveConfig(options.parsedArgvOptions);
|
|
1326
1228
|
this.hookIndex = buildHookIndex(options.supportCodeLibrary);
|
|
1327
1229
|
this.attachmentBudget = new AttachmentBudget(this.config.maxTotalAttachmentBytes);
|
|
1328
|
-
this.attemptTracker = new AttemptTracker(
|
|
1230
|
+
this.attemptTracker = new AttemptTracker(
|
|
1231
|
+
this.hookIndex,
|
|
1232
|
+
this.gherkin,
|
|
1233
|
+
this.config,
|
|
1234
|
+
this.attachmentBudget
|
|
1235
|
+
);
|
|
1329
1236
|
if (!this.config.enabled) {
|
|
1330
1237
|
return;
|
|
1331
1238
|
}
|
|
@@ -1376,15 +1283,20 @@ var QualflareCucumberFormatter = class extends import_cucumber.Formatter {
|
|
|
1376
1283
|
return;
|
|
1377
1284
|
}
|
|
1378
1285
|
if (envelope.testCaseFinished) {
|
|
1379
|
-
const
|
|
1380
|
-
|
|
1286
|
+
const pending = this.attemptTracker.finish(envelope.testCaseFinished).then((finished) => {
|
|
1287
|
+
if (!finished) {
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1381
1290
|
const pickle = this.pickleIndex.get(finished.pickleId);
|
|
1382
1291
|
if (pickle) {
|
|
1383
1292
|
this.finishedCases.push(buildCase(finished.uri, pickle, finished.collapsed, this.gherkin));
|
|
1384
1293
|
} else {
|
|
1385
1294
|
logger.warn(`could not resolve pickle "${finished.pickleId}" for a finished scenario \u2014 it will not be uploaded.`);
|
|
1386
1295
|
}
|
|
1387
|
-
}
|
|
1296
|
+
}).catch((err) => {
|
|
1297
|
+
logger.error("failed to process a cucumber-js event:", err);
|
|
1298
|
+
});
|
|
1299
|
+
this.pendingCaseBuilds.push(pending);
|
|
1388
1300
|
return;
|
|
1389
1301
|
}
|
|
1390
1302
|
if (envelope.testRunHookStarted) {
|
|
@@ -1401,41 +1313,41 @@ var QualflareCucumberFormatter = class extends import_cucumber.Formatter {
|
|
|
1401
1313
|
}
|
|
1402
1314
|
async finished() {
|
|
1403
1315
|
try {
|
|
1316
|
+
await Promise.all(this.pendingCaseBuilds);
|
|
1404
1317
|
if (this.config.enabled) {
|
|
1405
|
-
|
|
1318
|
+
this.writeResults();
|
|
1406
1319
|
}
|
|
1407
1320
|
} finally {
|
|
1408
1321
|
await super.finished();
|
|
1409
1322
|
}
|
|
1410
1323
|
}
|
|
1411
|
-
|
|
1324
|
+
/** Writes this process's Collect payload into `outputDir` under a unique
|
|
1325
|
+
* filename. Never uploads: `qualflare-cli collect <outputDir>` does that,
|
|
1326
|
+
* merging every file it finds there into one Launch. Multiple shards can
|
|
1327
|
+
* therefore share one directory safely — the UUID filename is what keeps
|
|
1328
|
+
* them from overwriting each other. */
|
|
1329
|
+
writeResults() {
|
|
1412
1330
|
const suites = groupIntoSuites(this.finishedCases, this.cwd, this.runHookTracker.buildSuite());
|
|
1413
1331
|
if (suites.length === 0) {
|
|
1414
1332
|
if (this.config.debug) {
|
|
1415
|
-
logger.debug("no scenarios reported \u2014 skipping
|
|
1333
|
+
logger.debug("no scenarios reported \u2014 skipping file write.");
|
|
1416
1334
|
}
|
|
1417
1335
|
return;
|
|
1418
1336
|
}
|
|
1419
1337
|
const payload = buildCollectPayload(suites, this.config);
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
userAgent: `qualflare-cucumberjs/${PACKAGE_VERSION}`,
|
|
1426
|
-
debug: this.config.debug
|
|
1427
|
-
});
|
|
1428
|
-
try {
|
|
1429
|
-
const result = await client.send(payload);
|
|
1430
|
-
if (this.config.debug) {
|
|
1431
|
-
logger.debug(`uploaded launch #${result.seq}.`);
|
|
1432
|
-
}
|
|
1433
|
-
} catch (err) {
|
|
1434
|
-
if (this.config.failOnUploadError) {
|
|
1435
|
-
throw err;
|
|
1338
|
+
if (this.config.shardIndex !== void 0) {
|
|
1339
|
+
for (const suite of payload.suites) {
|
|
1340
|
+
for (const c of suite.cases) {
|
|
1341
|
+
c.shardIndex = this.config.shardIndex;
|
|
1342
|
+
}
|
|
1436
1343
|
}
|
|
1437
|
-
logger.error("failed to upload results to Qualflare:", err);
|
|
1438
1344
|
}
|
|
1345
|
+
fs3.mkdirSync(this.config.outputDir, { recursive: true });
|
|
1346
|
+
const outputPath = path4.join(this.config.outputDir, `${(0, import_node_crypto2.randomUUID)()}.json`);
|
|
1347
|
+
fs3.writeFileSync(outputPath, JSON.stringify(payload));
|
|
1348
|
+
logger.info(
|
|
1349
|
+
`wrote Collect payload to ${outputPath} \u2014 run \`qualflare-cli collect ${this.config.outputDir}\` to upload it.`
|
|
1350
|
+
);
|
|
1439
1351
|
}
|
|
1440
1352
|
};
|
|
1441
1353
|
//# sourceMappingURL=index.cjs.map
|