@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.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
// src/formatter/formatter.ts
|
|
2
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
3
|
+
import * as fs3 from "fs";
|
|
4
|
+
import * as path4 from "path";
|
|
2
5
|
import { Formatter } from "@cucumber/cucumber";
|
|
3
6
|
|
|
7
|
+
// src/shared/constants.ts
|
|
8
|
+
var RESERVED_MESSAGE_MEDIA_TYPE = "application/vnd.qualflare.message+json";
|
|
9
|
+
var MAX_SUITES_PER_LAUNCH = 2e3;
|
|
10
|
+
var MAX_CASES_PER_SUITE = 5e3;
|
|
11
|
+
var MAX_TAGS_PER_CASE = 64;
|
|
12
|
+
var MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
13
|
+
var MAX_STEPS_PER_TEST_ATTEMPT = 300;
|
|
14
|
+
|
|
4
15
|
// src/config/ci-detect.ts
|
|
5
16
|
import * as ciInfo from "ci-info";
|
|
6
17
|
function parsePositiveInt(raw) {
|
|
@@ -172,22 +183,30 @@ function envInt(...names) {
|
|
|
172
183
|
const parsed = Number.parseInt(raw, 10);
|
|
173
184
|
return Number.isFinite(parsed) ? parsed : void 0;
|
|
174
185
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
186
|
+
function argvShardIndex(argv = process.argv) {
|
|
187
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
188
|
+
const arg = argv[i];
|
|
189
|
+
if (arg === void 0) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const raw = arg === "--shard" ? argv[i + 1] : arg.startsWith("--shard=") ? arg.slice("--shard=".length) : void 0;
|
|
193
|
+
if (raw === void 0) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (!/^\d+\/\d+$/.test(raw)) {
|
|
197
|
+
return void 0;
|
|
198
|
+
}
|
|
199
|
+
const oneBased = Number.parseInt(raw.split("/")[0] ?? "", 10);
|
|
200
|
+
return Number.isFinite(oneBased) && oneBased >= 1 ? oneBased - 1 : void 0;
|
|
179
201
|
}
|
|
180
|
-
|
|
202
|
+
return void 0;
|
|
203
|
+
}
|
|
181
204
|
function resolveConfig(options, deps = {}) {
|
|
182
205
|
const doDetectGit = deps.detectGit ?? detectGit;
|
|
183
206
|
const doDetectCi = deps.detectCi ?? detectCi;
|
|
184
207
|
const enabled = options.enabled ?? envBool("QUALFLARE_ENABLED") ?? true;
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
throw new QualflareConfigError(
|
|
188
|
-
"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."
|
|
189
|
-
);
|
|
190
|
-
}
|
|
208
|
+
const outputDir = options.outputDir || firstEnv2("QUALFLARE_OUTPUT_DIR") || "./qualflare-results";
|
|
209
|
+
const shardIndex = options.shardIndex ?? envInt("QUALFLARE_SHARD_INDEX") ?? argvShardIndex();
|
|
191
210
|
const milestoneRaw = options.milestone !== void 0 ? options.milestone : envInt("QUALFLARE_MILESTONE", "QF_MILESTONE");
|
|
192
211
|
const milestone = milestoneRaw !== void 0 && milestoneRaw !== null && milestoneRaw >= 1 ? milestoneRaw : null;
|
|
193
212
|
const envBranch = firstEnv2("QUALFLARE_BRANCH", "QF_BRANCH");
|
|
@@ -202,13 +221,9 @@ function resolveConfig(options, deps = {}) {
|
|
|
202
221
|
const ciRunUrl = options.ciRunUrl ?? detectedCi.ciRunUrl;
|
|
203
222
|
const ciPrNumber = options.ciPrNumber ?? detectedCi.ciPrNumber;
|
|
204
223
|
return {
|
|
205
|
-
token,
|
|
206
|
-
apiEndpoint: options.apiEndpoint ?? firstEnv2("QUALFLARE_API_ENDPOINT") ?? "https://api.qualflare.com",
|
|
207
224
|
// `||` (truthy check), not `??`, for these three REQUIRED-non-empty wire
|
|
208
225
|
// fields — an explicit `''` option must not silently win over the
|
|
209
|
-
// default (the server rejects an empty `environment
|
|
210
|
-
// `failOnUploadError` defaults `false`, that would fail the entire
|
|
211
|
-
// upload with no visible error by default). Ported verbatim from
|
|
226
|
+
// default (the server rejects an empty `environment`). Ported verbatim from
|
|
212
227
|
// qualflare-cypress, where this was found via deep adversarial review.
|
|
213
228
|
environment: (options.environment || void 0) ?? firstEnv2("QUALFLARE_ENVIRONMENT", "QF_ENVIRONMENT") ?? "development",
|
|
214
229
|
language: (options.language || void 0) ?? firstEnv2("QUALFLARE_LANGUAGE", "QF_LANGUAGE") ?? "en-US",
|
|
@@ -224,38 +239,18 @@ function resolveConfig(options, deps = {}) {
|
|
|
224
239
|
ciBuildNumber,
|
|
225
240
|
ciRunUrl,
|
|
226
241
|
ciPrNumber,
|
|
227
|
-
timeoutMs: options.timeoutMs ?? envInt("QUALFLARE_TIMEOUT_MS") ?? 12e4,
|
|
228
|
-
retry: {
|
|
229
|
-
max: options.retry?.max ?? envInt("QUALFLARE_RETRY_MAX", "QF_RETRY_MAX") ?? 3,
|
|
230
|
-
baseDelayMs: options.retry?.baseDelayMs ?? envInt("QUALFLARE_RETRY_BASE_DELAY_MS") ?? 1e3,
|
|
231
|
-
maxDelayMs: options.retry?.maxDelayMs ?? envInt("QUALFLARE_RETRY_MAX_DELAY_MS") ?? 3e4
|
|
232
|
-
},
|
|
233
|
-
failOnUploadError: options.failOnUploadError ?? envBool("QUALFLARE_FAIL_ON_UPLOAD_ERROR") ?? false,
|
|
234
242
|
attachScreenshots: options.attachScreenshots ?? envBool("QUALFLARE_ATTACH_SCREENSHOTS") ?? true,
|
|
235
243
|
includeStepHooks: options.includeStepHooks ?? envBool("QUALFLARE_INCLUDE_STEP_HOOKS") ?? false,
|
|
236
244
|
maxAttachmentBytes: options.maxAttachmentBytes ?? envInt("QUALFLARE_MAX_ATTACHMENT_BYTES") ?? 15e5,
|
|
237
245
|
maxTotalAttachmentBytes: options.maxTotalAttachmentBytes ?? envInt("QUALFLARE_MAX_TOTAL_ATTACHMENT_BYTES") ?? 75e4,
|
|
246
|
+
maxVideoBytes: options.maxVideoBytes ?? envInt("QUALFLARE_MAX_VIDEO_BYTES") ?? MAX_VIDEO_UPLOAD_BYTES,
|
|
238
247
|
debug: options.debug ?? envBool("QUALFLARE_DEBUG", "QF_DEBUG") ?? false,
|
|
239
|
-
enabled
|
|
248
|
+
enabled,
|
|
249
|
+
outputDir,
|
|
250
|
+
shardIndex
|
|
240
251
|
};
|
|
241
252
|
}
|
|
242
253
|
|
|
243
|
-
// src/http/client.ts
|
|
244
|
-
import { request } from "undici";
|
|
245
|
-
|
|
246
|
-
// src/shared/constants.ts
|
|
247
|
-
var RESERVED_MESSAGE_MEDIA_TYPE = "application/vnd.qualflare.message+json";
|
|
248
|
-
var HEADER_TOKEN = "QF_TOKEN";
|
|
249
|
-
var HEADER_IDEMPOTENCY_KEY = "Idempotency-Key";
|
|
250
|
-
var HEADER_CONTENT_TYPE = "Content-Type";
|
|
251
|
-
var HEADER_ACCEPT = "Accept";
|
|
252
|
-
var HEADER_USER_AGENT = "User-Agent";
|
|
253
|
-
var MAX_SUITES_PER_LAUNCH = 2e3;
|
|
254
|
-
var MAX_CASES_PER_SUITE = 5e3;
|
|
255
|
-
var MAX_TAGS_PER_CASE = 64;
|
|
256
|
-
var MAX_IDEMPOTENCY_KEY_CHARS = 255;
|
|
257
|
-
var MAX_STEPS_PER_TEST_ATTEMPT = 300;
|
|
258
|
-
|
|
259
254
|
// src/shared/logger.ts
|
|
260
255
|
var PREFIX = "[qualflare-cucumberjs]";
|
|
261
256
|
var logger = {
|
|
@@ -273,220 +268,86 @@ var logger = {
|
|
|
273
268
|
}
|
|
274
269
|
};
|
|
275
270
|
|
|
276
|
-
// src/
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const jittered = Math.random() * Math.min(exponential, maxDelayMs);
|
|
280
|
-
const floor = retryAfterMs !== void 0 ? retryAfterMs : 0;
|
|
281
|
-
return Math.min(Math.max(jittered, floor), maxDelayMs);
|
|
282
|
-
}
|
|
271
|
+
// src/formatter/attachment-budget.ts
|
|
272
|
+
import * as fs2 from "fs";
|
|
273
|
+
import * as path2 from "path";
|
|
283
274
|
|
|
284
|
-
// src/
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
275
|
+
// src/formatter/video-uploader.ts
|
|
276
|
+
import { randomUUID } from "crypto";
|
|
277
|
+
import * as fs from "fs";
|
|
278
|
+
import * as path from "path";
|
|
279
|
+
var VIDEO_MIME_TYPES_BY_EXTENSION = {
|
|
280
|
+
".mp4": "video/mp4",
|
|
281
|
+
".webm": "video/webm",
|
|
282
|
+
".mov": "video/quicktime"
|
|
283
|
+
};
|
|
284
|
+
var EXTENSION_BY_VIDEO_MIME_TYPE = {
|
|
285
|
+
"video/mp4": ".mp4",
|
|
286
|
+
"video/webm": ".webm",
|
|
287
|
+
"video/quicktime": ".mov"
|
|
288
|
+
};
|
|
289
|
+
function resolveVideoMimeType(mimeType, filePath) {
|
|
290
|
+
if (filePath) {
|
|
291
|
+
const extension2 = path.extname(filePath).toLowerCase();
|
|
292
|
+
const resolvedMimeType = VIDEO_MIME_TYPES_BY_EXTENSION[extension2];
|
|
293
|
+
return resolvedMimeType ? { mimeType: resolvedMimeType, extension: extension2 } : void 0;
|
|
294
|
+
}
|
|
295
|
+
const normalized = mimeType?.toLowerCase();
|
|
296
|
+
const extension = normalized ? EXTENSION_BY_VIDEO_MIME_TYPE[normalized] : void 0;
|
|
297
|
+
return normalized && extension ? { mimeType: normalized, extension } : void 0;
|
|
308
298
|
}
|
|
309
|
-
function
|
|
310
|
-
|
|
299
|
+
function writeVideoAttachment(pending, outputDir, maxVideoBytes) {
|
|
300
|
+
const resolved = resolveVideoMimeType(pending.mimeType, pending.path);
|
|
301
|
+
if (!resolved) {
|
|
302
|
+
logger.warn(`skipping video attachment "${pending.name}": unsupported video format.`);
|
|
311
303
|
return void 0;
|
|
312
304
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
requestId;
|
|
323
|
-
fields;
|
|
324
|
-
constructor(init) {
|
|
325
|
-
const parts = [init.message];
|
|
326
|
-
const fieldsRendered = renderFields(init.fields);
|
|
327
|
-
if (fieldsRendered) {
|
|
328
|
-
parts.push(`fields: ${fieldsRendered}`);
|
|
305
|
+
const localVideoPath = `${randomUUID()}${resolved.extension}`;
|
|
306
|
+
const destination = path.join(outputDir, localVideoPath);
|
|
307
|
+
if (pending.path !== void 0) {
|
|
308
|
+
let fileSize;
|
|
309
|
+
try {
|
|
310
|
+
fileSize = fs.statSync(pending.path).size;
|
|
311
|
+
} catch (err) {
|
|
312
|
+
logger.warn(`skipping video attachment "${pending.path}": could not stat file: ${err.message}`);
|
|
313
|
+
return void 0;
|
|
329
314
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
315
|
+
if (fileSize > maxVideoBytes) {
|
|
316
|
+
logger.warn(
|
|
317
|
+
`skipping video attachment "${pending.path}": ${fileSize} bytes exceeds the configured maxVideoBytes cap of ${maxVideoBytes} bytes.`
|
|
318
|
+
);
|
|
319
|
+
return void 0;
|
|
333
320
|
}
|
|
334
|
-
|
|
335
|
-
|
|
321
|
+
try {
|
|
322
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
323
|
+
fs.copyFileSync(pending.path, destination);
|
|
324
|
+
} catch (err) {
|
|
325
|
+
logger.warn(`skipping video attachment "${pending.path}": could not copy file: ${err.message}`);
|
|
326
|
+
return void 0;
|
|
336
327
|
}
|
|
337
|
-
|
|
338
|
-
this.name = "QualflareApiError";
|
|
339
|
-
this.code = init.code;
|
|
340
|
-
this.statusCode = init.statusCode;
|
|
341
|
-
this.requestId = init.requestId;
|
|
342
|
-
this.fields = init.fields;
|
|
328
|
+
return { localVideoPath, fileSize, mimeType: resolved.mimeType };
|
|
343
329
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
message,
|
|
350
|
-
code,
|
|
351
|
-
statusCode,
|
|
352
|
-
requestId: body?.request_id,
|
|
353
|
-
fields: body?.fields
|
|
354
|
-
});
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// src/http/idempotency.ts
|
|
358
|
-
import { randomUUID } from "crypto";
|
|
359
|
-
function newIdempotencyKey() {
|
|
360
|
-
const key = randomUUID();
|
|
361
|
-
if (key.length > MAX_IDEMPOTENCY_KEY_CHARS) {
|
|
362
|
-
return key.slice(0, MAX_IDEMPOTENCY_KEY_CHARS);
|
|
363
|
-
}
|
|
364
|
-
return key;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// src/http/client.ts
|
|
368
|
-
var RETRYABLE_STATUS_CODES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
369
|
-
function redactToken(token) {
|
|
370
|
-
return token.length > 0 ? "***REDACTED***" : "(none)";
|
|
371
|
-
}
|
|
372
|
-
var QualflareHttpClient = class {
|
|
373
|
-
constructor(opts) {
|
|
374
|
-
this.opts = opts;
|
|
375
|
-
}
|
|
376
|
-
opts;
|
|
377
|
-
async send(collect) {
|
|
378
|
-
const url = `${this.opts.endpoint.replace(/\/+$/, "")}/api/v1/collect`;
|
|
379
|
-
const idempotencyKey = newIdempotencyKey();
|
|
380
|
-
const body = JSON.stringify(collect);
|
|
381
|
-
const maxAttempts = Math.max(1, this.opts.retry.max + 1);
|
|
382
|
-
let lastError;
|
|
383
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
384
|
-
if (this.opts.debug) {
|
|
385
|
-
logger.debug(
|
|
386
|
-
`POST ${url} (attempt ${attempt}/${maxAttempts}, QF_TOKEN: ${redactToken(this.opts.token)})`
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
let statusCode;
|
|
390
|
-
let responseBody;
|
|
391
|
-
let responseHeaders;
|
|
392
|
-
try {
|
|
393
|
-
const res = await request(url, {
|
|
394
|
-
method: "POST",
|
|
395
|
-
headers: {
|
|
396
|
-
[HEADER_TOKEN]: this.opts.token,
|
|
397
|
-
[HEADER_CONTENT_TYPE]: "application/json",
|
|
398
|
-
[HEADER_ACCEPT]: "application/json",
|
|
399
|
-
[HEADER_USER_AGENT]: this.opts.userAgent,
|
|
400
|
-
[HEADER_IDEMPOTENCY_KEY]: idempotencyKey
|
|
401
|
-
},
|
|
402
|
-
body,
|
|
403
|
-
maxRedirections: 0,
|
|
404
|
-
signal: AbortSignal.timeout(this.opts.timeoutMs)
|
|
405
|
-
});
|
|
406
|
-
statusCode = res.statusCode;
|
|
407
|
-
responseHeaders = res.headers;
|
|
408
|
-
responseBody = await res.body.text();
|
|
409
|
-
} catch (err) {
|
|
410
|
-
lastError = err;
|
|
411
|
-
if (this.opts.debug) {
|
|
412
|
-
logger.debug(`attempt ${attempt} transport error: ${err?.message ?? err}`);
|
|
413
|
-
}
|
|
414
|
-
if (attempt < maxAttempts) {
|
|
415
|
-
await sleep(computeDelay(attempt, this.opts.retry.baseDelayMs, this.opts.retry.maxDelayMs));
|
|
416
|
-
continue;
|
|
417
|
-
}
|
|
418
|
-
throw new QualflareApiError({
|
|
419
|
-
message: `failed to send request to ${url}`,
|
|
420
|
-
cause: err
|
|
421
|
-
});
|
|
422
|
-
}
|
|
423
|
-
if (this.opts.debug) {
|
|
424
|
-
logger.debug(`attempt ${attempt} response: ${statusCode}`);
|
|
425
|
-
}
|
|
426
|
-
if (statusCode >= 200 && statusCode < 300) {
|
|
427
|
-
return parseSuccess(responseBody);
|
|
428
|
-
}
|
|
429
|
-
const parsedError = parseErrorBody(responseBody);
|
|
430
|
-
if (!RETRYABLE_STATUS_CODES.has(statusCode) || attempt === maxAttempts) {
|
|
431
|
-
throw buildApiError(statusCode, parsedError);
|
|
432
|
-
}
|
|
433
|
-
const retryAfterMs = parseRetryAfter(responseHeaders["retry-after"]);
|
|
434
|
-
const delay = computeDelay(
|
|
435
|
-
attempt,
|
|
436
|
-
this.opts.retry.baseDelayMs,
|
|
437
|
-
this.opts.retry.maxDelayMs,
|
|
438
|
-
retryAfterMs
|
|
330
|
+
if (pending.content !== void 0) {
|
|
331
|
+
const fileSize = Buffer.byteLength(pending.content, "base64");
|
|
332
|
+
if (fileSize > maxVideoBytes) {
|
|
333
|
+
logger.warn(
|
|
334
|
+
`skipping video attachment "${pending.name}": ${fileSize} bytes exceeds the configured maxVideoBytes cap of ${maxVideoBytes} bytes.`
|
|
439
335
|
);
|
|
440
|
-
|
|
441
|
-
logger.debug(`retrying after ${Math.round(delay)}ms (status ${statusCode})`);
|
|
442
|
-
}
|
|
443
|
-
await sleep(delay);
|
|
336
|
+
return void 0;
|
|
444
337
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
if (typeof parsed.seq !== "number") {
|
|
452
|
-
throw new Error('response body missing numeric "seq"');
|
|
338
|
+
try {
|
|
339
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
340
|
+
fs.writeFileSync(destination, Buffer.from(pending.content, "base64"));
|
|
341
|
+
} catch (err) {
|
|
342
|
+
logger.warn(`skipping video attachment "${pending.name}": could not write file: ${err.message}`);
|
|
343
|
+
return void 0;
|
|
453
344
|
}
|
|
454
|
-
return
|
|
455
|
-
} catch (err) {
|
|
456
|
-
throw new QualflareApiError({
|
|
457
|
-
message: "server returned a success status but an unparseable body",
|
|
458
|
-
cause: err
|
|
459
|
-
});
|
|
345
|
+
return { localVideoPath, fileSize, mimeType: resolved.mimeType };
|
|
460
346
|
}
|
|
347
|
+
return void 0;
|
|
461
348
|
}
|
|
462
|
-
function parseErrorBody(responseBody) {
|
|
463
|
-
if (!responseBody) {
|
|
464
|
-
return void 0;
|
|
465
|
-
}
|
|
466
|
-
try {
|
|
467
|
-
return JSON.parse(responseBody);
|
|
468
|
-
} catch {
|
|
469
|
-
return void 0;
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
function parseRetryAfter(value) {
|
|
473
|
-
const raw = Array.isArray(value) ? value[0] : value;
|
|
474
|
-
if (!raw) {
|
|
475
|
-
return void 0;
|
|
476
|
-
}
|
|
477
|
-
const seconds = Number(raw);
|
|
478
|
-
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : void 0;
|
|
479
|
-
}
|
|
480
|
-
function sleep(ms) {
|
|
481
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// src/config/version.ts
|
|
485
|
-
var PACKAGE_VERSION = "0.1.0";
|
|
486
349
|
|
|
487
350
|
// src/formatter/attachment-budget.ts
|
|
488
|
-
import * as fs from "fs";
|
|
489
|
-
import * as path from "path";
|
|
490
351
|
var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([".mp4", ".webm", ".mov", ".avi", ".mkv"]);
|
|
491
352
|
var AttachmentBudget = class {
|
|
492
353
|
constructor(maxTotalBytes) {
|
|
@@ -511,7 +372,7 @@ function isVideoLike(mimeType, filePath) {
|
|
|
511
372
|
if (mimeType?.toLowerCase().startsWith("video/")) {
|
|
512
373
|
return true;
|
|
513
374
|
}
|
|
514
|
-
if (filePath && VIDEO_EXTENSIONS.has(
|
|
375
|
+
if (filePath && VIDEO_EXTENSIONS.has(path2.extname(filePath).toLowerCase())) {
|
|
515
376
|
return true;
|
|
516
377
|
}
|
|
517
378
|
return false;
|
|
@@ -519,7 +380,7 @@ function isVideoLike(mimeType, filePath) {
|
|
|
519
380
|
function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
|
|
520
381
|
let size;
|
|
521
382
|
try {
|
|
522
|
-
size =
|
|
383
|
+
size = fs2.statSync(filePath).size;
|
|
523
384
|
} catch (err) {
|
|
524
385
|
return { skipped: true, reason: `could not stat file: ${err.message}` };
|
|
525
386
|
}
|
|
@@ -536,7 +397,7 @@ function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
|
|
|
536
397
|
};
|
|
537
398
|
}
|
|
538
399
|
try {
|
|
539
|
-
const content =
|
|
400
|
+
const content = fs2.readFileSync(filePath).toString("base64");
|
|
540
401
|
return { skipped: false, content };
|
|
541
402
|
} catch (err) {
|
|
542
403
|
return { skipped: true, reason: `could not read file: ${err.message}` };
|
|
@@ -546,12 +407,6 @@ function resolvePendingAttachment(pending, config, budget) {
|
|
|
546
407
|
if (!config.attachScreenshots) {
|
|
547
408
|
return void 0;
|
|
548
409
|
}
|
|
549
|
-
if (isVideoLike(pending.mimeType, pending.path)) {
|
|
550
|
-
logger.warn(
|
|
551
|
-
`refusing to attach "${pending.name}": video attachments are not supported yet (${pending.path ?? pending.mimeType ?? "unknown"}).`
|
|
552
|
-
);
|
|
553
|
-
return void 0;
|
|
554
|
-
}
|
|
555
410
|
if (pending.content !== void 0) {
|
|
556
411
|
const bytes = Buffer.byteLength(pending.content, "base64");
|
|
557
412
|
if (bytes > config.maxAttachmentBytes) {
|
|
@@ -584,6 +439,22 @@ function resolvePendingAttachment(pending, config, budget) {
|
|
|
584
439
|
}
|
|
585
440
|
return void 0;
|
|
586
441
|
}
|
|
442
|
+
async function resolveVideoAttachment(pending, config) {
|
|
443
|
+
if (!config.attachScreenshots) {
|
|
444
|
+
return void 0;
|
|
445
|
+
}
|
|
446
|
+
const written = writeVideoAttachment(pending, config.outputDir, config.maxVideoBytes);
|
|
447
|
+
if (!written) {
|
|
448
|
+
return void 0;
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
name: pending.name,
|
|
452
|
+
mimeType: written.mimeType,
|
|
453
|
+
localVideoPath: written.localVideoPath,
|
|
454
|
+
fileSize: written.fileSize,
|
|
455
|
+
stepIndex: pending.stepIndex
|
|
456
|
+
};
|
|
457
|
+
}
|
|
587
458
|
|
|
588
459
|
// src/formatter/attempt-tracker.ts
|
|
589
460
|
import {
|
|
@@ -832,7 +703,8 @@ var AttemptTracker = class {
|
|
|
832
703
|
tags: [],
|
|
833
704
|
properties: {},
|
|
834
705
|
attachments: [],
|
|
835
|
-
stepCapWarned: false
|
|
706
|
+
stepCapWarned: false,
|
|
707
|
+
pendingVideoWrites: []
|
|
836
708
|
};
|
|
837
709
|
this.byTestCaseStartedId.set(e.id, record);
|
|
838
710
|
const attempts = this.byTestCaseId.get(testCase.id) ?? [];
|
|
@@ -910,11 +782,25 @@ var AttemptTracker = class {
|
|
|
910
782
|
}
|
|
911
783
|
const stepIndex = this.resolveStepIndex(record, e.testStepId);
|
|
912
784
|
const content = e.contentEncoding === "BASE64" ? e.body : Buffer.from(e.body, "utf8").toString("base64");
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
785
|
+
this.resolveAttachment(record, { name: e.fileName || "attachment", mimeType: e.mediaType, content, stepIndex });
|
|
786
|
+
}
|
|
787
|
+
/** Resolves one pending attachment, routing a video-like one through the
|
|
788
|
+
* write-to-`outputDir` flow (tracked in `record.pendingVideoWrites` so
|
|
789
|
+
* `finish()` can wait for it) and everything else through the synchronous
|
|
790
|
+
* inline path — shared by the real `World.attach()` handler above and both
|
|
791
|
+
* `qualflare.attachment()`/`attachmentFromFile()` runtime-message cases
|
|
792
|
+
* below. */
|
|
793
|
+
resolveAttachment(record, pending) {
|
|
794
|
+
if (isVideoLike(pending.mimeType, pending.path)) {
|
|
795
|
+
const write = resolveVideoAttachment(pending, this.config).then((resolved2) => {
|
|
796
|
+
if (resolved2) {
|
|
797
|
+
record.attachments.push(resolved2);
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
record.pendingVideoWrites.push(write);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
const resolved = resolvePendingAttachment(pending, this.config, this.attachmentBudget);
|
|
918
804
|
if (resolved) {
|
|
919
805
|
record.attachments.push(resolved);
|
|
920
806
|
}
|
|
@@ -971,26 +857,12 @@ var AttemptTracker = class {
|
|
|
971
857
|
}
|
|
972
858
|
case "attachment": {
|
|
973
859
|
const stepIndex = testStepId !== void 0 ? record.stepIndexByTestStepId.get(testStepId) : void 0;
|
|
974
|
-
|
|
975
|
-
{ name: message.name, mimeType: message.mimeType, content: message.contentBase64, stepIndex },
|
|
976
|
-
this.config,
|
|
977
|
-
this.attachmentBudget
|
|
978
|
-
);
|
|
979
|
-
if (resolved) {
|
|
980
|
-
record.attachments.push(resolved);
|
|
981
|
-
}
|
|
860
|
+
this.resolveAttachment(record, { name: message.name, mimeType: message.mimeType, content: message.contentBase64, stepIndex });
|
|
982
861
|
return;
|
|
983
862
|
}
|
|
984
863
|
case "attachment_from_file": {
|
|
985
864
|
const stepIndex = testStepId !== void 0 ? record.stepIndexByTestStepId.get(testStepId) : void 0;
|
|
986
|
-
|
|
987
|
-
{ name: message.name, mimeType: message.mimeType, path: message.path, stepIndex },
|
|
988
|
-
this.config,
|
|
989
|
-
this.attachmentBudget
|
|
990
|
-
);
|
|
991
|
-
if (resolved) {
|
|
992
|
-
record.attachments.push(resolved);
|
|
993
|
-
}
|
|
865
|
+
this.resolveAttachment(record, { name: message.name, mimeType: message.mimeType, path: message.path, stepIndex });
|
|
994
866
|
return;
|
|
995
867
|
}
|
|
996
868
|
case "step_start": {
|
|
@@ -1015,8 +887,21 @@ var AttemptTracker = class {
|
|
|
1015
887
|
}
|
|
1016
888
|
/** Returns the collapsed result once all attempts of this logical
|
|
1017
889
|
* scenario have arrived, or `undefined` if more attempts are coming
|
|
1018
|
-
* (`willBeRetried === true`).
|
|
1019
|
-
|
|
890
|
+
* (`willBeRetried === true`).
|
|
891
|
+
*
|
|
892
|
+
* Async because it must first await every attempt's own
|
|
893
|
+
* `pendingVideoWrites` (any video attached anywhere across every retry
|
|
894
|
+
* of this scenario). This is load-bearing, not just tidiness:
|
|
895
|
+
* `collapseAttempts`/`buildCase` read `attachments` by REFERENCE, not by
|
|
896
|
+
* copy, and `buildCase` runs synchronously right after this resolves — a
|
|
897
|
+
* scenario whose ONLY attachment is a still-uploading video would have an
|
|
898
|
+
* EMPTY `attachments` array at that instant, and `buildCase` captures
|
|
899
|
+
* `attachments.length > 0 ? attachments : undefined` as a plain
|
|
900
|
+
* `undefined` VALUE right then, permanently — a later push onto the
|
|
901
|
+
* (still-live) array reference would no longer be visible through
|
|
902
|
+
* `undefined`. Awaiting here first guarantees `attachments` is complete
|
|
903
|
+
* before `buildCase` ever reads it. */
|
|
904
|
+
async finish(e) {
|
|
1020
905
|
const record = this.byTestCaseStartedId.get(e.testCaseStartedId);
|
|
1021
906
|
this.byTestCaseStartedId.delete(e.testCaseStartedId);
|
|
1022
907
|
if (!record) {
|
|
@@ -1028,6 +913,10 @@ var AttemptTracker = class {
|
|
|
1028
913
|
const testCaseId = record.testCase.id;
|
|
1029
914
|
const attempts = this.byTestCaseId.get(testCaseId) ?? [record];
|
|
1030
915
|
this.byTestCaseId.delete(testCaseId);
|
|
916
|
+
const pendingWrites = attempts.flatMap((a) => a.pendingVideoWrites);
|
|
917
|
+
if (pendingWrites.length > 0) {
|
|
918
|
+
await Promise.all(pendingWrites);
|
|
919
|
+
}
|
|
1031
920
|
const snapshots = attempts.map((a) => {
|
|
1032
921
|
const worst = a.stepResults.length > 0 ? getWorstTestStepResult(a.stepResults) : void 0;
|
|
1033
922
|
const duration = a.stepResults.reduce((sum, r) => sum + messageDurationToNs(r.duration), 0);
|
|
@@ -1062,6 +951,11 @@ function timestampMs(ts) {
|
|
|
1062
951
|
|
|
1063
952
|
// src/formatter/collect-builder.ts
|
|
1064
953
|
import * as os from "os";
|
|
954
|
+
|
|
955
|
+
// src/config/version.ts
|
|
956
|
+
var PACKAGE_VERSION = "0.2.0";
|
|
957
|
+
|
|
958
|
+
// src/formatter/collect-builder.ts
|
|
1065
959
|
function resolveOs(config) {
|
|
1066
960
|
if (config.os) {
|
|
1067
961
|
return config.os;
|
|
@@ -1221,7 +1115,7 @@ var RunHookTracker = class {
|
|
|
1221
1115
|
}
|
|
1222
1116
|
return {
|
|
1223
1117
|
name: "(global hooks)",
|
|
1224
|
-
category: "
|
|
1118
|
+
category: "cucumber",
|
|
1225
1119
|
duration: this.failed.reduce((sum, c) => sum + c.duration, 0),
|
|
1226
1120
|
cases: this.failed
|
|
1227
1121
|
};
|
|
@@ -1229,7 +1123,7 @@ var RunHookTracker = class {
|
|
|
1229
1123
|
};
|
|
1230
1124
|
|
|
1231
1125
|
// src/formatter/suite-builder.ts
|
|
1232
|
-
import * as
|
|
1126
|
+
import * as path3 from "path";
|
|
1233
1127
|
function groupIntoSuites(cases, cwd, extraSuite) {
|
|
1234
1128
|
const byUri = /* @__PURE__ */ new Map();
|
|
1235
1129
|
for (const { uri, case: kase } of cases) {
|
|
@@ -1249,7 +1143,7 @@ function groupIntoSuites(cases, cwd, extraSuite) {
|
|
|
1249
1143
|
}
|
|
1250
1144
|
suites.push({
|
|
1251
1145
|
name: relativizeUri(uri, cwd),
|
|
1252
|
-
category: "
|
|
1146
|
+
category: "cucumber",
|
|
1253
1147
|
duration: kases.reduce((sum, c) => sum + c.duration, 0),
|
|
1254
1148
|
cases: kases.slice(0, MAX_CASES_PER_SUITE)
|
|
1255
1149
|
});
|
|
@@ -1269,10 +1163,10 @@ function relativizeUri(uri, cwd) {
|
|
|
1269
1163
|
if (normalized.startsWith("file://")) {
|
|
1270
1164
|
normalized = new URL(normalized).pathname;
|
|
1271
1165
|
}
|
|
1272
|
-
if (
|
|
1273
|
-
normalized =
|
|
1166
|
+
if (path3.isAbsolute(normalized)) {
|
|
1167
|
+
normalized = path3.relative(cwd, normalized);
|
|
1274
1168
|
}
|
|
1275
|
-
return normalized.split(
|
|
1169
|
+
return normalized.split(path3.sep).join("/");
|
|
1276
1170
|
}
|
|
1277
1171
|
|
|
1278
1172
|
// src/formatter/formatter.ts
|
|
@@ -1286,12 +1180,25 @@ var QualflareCucumberFormatter = class extends Formatter {
|
|
|
1286
1180
|
attemptTracker;
|
|
1287
1181
|
runHookTracker = new RunHookTracker();
|
|
1288
1182
|
finishedCases = [];
|
|
1183
|
+
/** One promise per `testCaseFinished` envelope, resolving once that
|
|
1184
|
+
* scenario's `AttemptTracker.finish()` (which itself awaits any pending
|
|
1185
|
+
* video uploads — see its doc comment) has settled and, if it produced a
|
|
1186
|
+
* result, been pushed into `finishedCases`. `finished()` awaits all of
|
|
1187
|
+
* these before building/uploading the Collect payload, so a scenario
|
|
1188
|
+
* whose only attachment is a still-uploading video is never silently
|
|
1189
|
+
* dropped from the report. */
|
|
1190
|
+
pendingCaseBuilds = [];
|
|
1289
1191
|
constructor(options) {
|
|
1290
1192
|
super(options);
|
|
1291
1193
|
this.config = resolveConfig(options.parsedArgvOptions);
|
|
1292
1194
|
this.hookIndex = buildHookIndex(options.supportCodeLibrary);
|
|
1293
1195
|
this.attachmentBudget = new AttachmentBudget(this.config.maxTotalAttachmentBytes);
|
|
1294
|
-
this.attemptTracker = new AttemptTracker(
|
|
1196
|
+
this.attemptTracker = new AttemptTracker(
|
|
1197
|
+
this.hookIndex,
|
|
1198
|
+
this.gherkin,
|
|
1199
|
+
this.config,
|
|
1200
|
+
this.attachmentBudget
|
|
1201
|
+
);
|
|
1295
1202
|
if (!this.config.enabled) {
|
|
1296
1203
|
return;
|
|
1297
1204
|
}
|
|
@@ -1342,15 +1249,20 @@ var QualflareCucumberFormatter = class extends Formatter {
|
|
|
1342
1249
|
return;
|
|
1343
1250
|
}
|
|
1344
1251
|
if (envelope.testCaseFinished) {
|
|
1345
|
-
const
|
|
1346
|
-
|
|
1252
|
+
const pending = this.attemptTracker.finish(envelope.testCaseFinished).then((finished) => {
|
|
1253
|
+
if (!finished) {
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1347
1256
|
const pickle = this.pickleIndex.get(finished.pickleId);
|
|
1348
1257
|
if (pickle) {
|
|
1349
1258
|
this.finishedCases.push(buildCase(finished.uri, pickle, finished.collapsed, this.gherkin));
|
|
1350
1259
|
} else {
|
|
1351
1260
|
logger.warn(`could not resolve pickle "${finished.pickleId}" for a finished scenario \u2014 it will not be uploaded.`);
|
|
1352
1261
|
}
|
|
1353
|
-
}
|
|
1262
|
+
}).catch((err) => {
|
|
1263
|
+
logger.error("failed to process a cucumber-js event:", err);
|
|
1264
|
+
});
|
|
1265
|
+
this.pendingCaseBuilds.push(pending);
|
|
1354
1266
|
return;
|
|
1355
1267
|
}
|
|
1356
1268
|
if (envelope.testRunHookStarted) {
|
|
@@ -1367,41 +1279,41 @@ var QualflareCucumberFormatter = class extends Formatter {
|
|
|
1367
1279
|
}
|
|
1368
1280
|
async finished() {
|
|
1369
1281
|
try {
|
|
1282
|
+
await Promise.all(this.pendingCaseBuilds);
|
|
1370
1283
|
if (this.config.enabled) {
|
|
1371
|
-
|
|
1284
|
+
this.writeResults();
|
|
1372
1285
|
}
|
|
1373
1286
|
} finally {
|
|
1374
1287
|
await super.finished();
|
|
1375
1288
|
}
|
|
1376
1289
|
}
|
|
1377
|
-
|
|
1290
|
+
/** Writes this process's Collect payload into `outputDir` under a unique
|
|
1291
|
+
* filename. Never uploads: `qualflare-cli collect <outputDir>` does that,
|
|
1292
|
+
* merging every file it finds there into one Launch. Multiple shards can
|
|
1293
|
+
* therefore share one directory safely — the UUID filename is what keeps
|
|
1294
|
+
* them from overwriting each other. */
|
|
1295
|
+
writeResults() {
|
|
1378
1296
|
const suites = groupIntoSuites(this.finishedCases, this.cwd, this.runHookTracker.buildSuite());
|
|
1379
1297
|
if (suites.length === 0) {
|
|
1380
1298
|
if (this.config.debug) {
|
|
1381
|
-
logger.debug("no scenarios reported \u2014 skipping
|
|
1299
|
+
logger.debug("no scenarios reported \u2014 skipping file write.");
|
|
1382
1300
|
}
|
|
1383
1301
|
return;
|
|
1384
1302
|
}
|
|
1385
1303
|
const payload = buildCollectPayload(suites, this.config);
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
userAgent: `qualflare-cucumberjs/${PACKAGE_VERSION}`,
|
|
1392
|
-
debug: this.config.debug
|
|
1393
|
-
});
|
|
1394
|
-
try {
|
|
1395
|
-
const result = await client.send(payload);
|
|
1396
|
-
if (this.config.debug) {
|
|
1397
|
-
logger.debug(`uploaded launch #${result.seq}.`);
|
|
1398
|
-
}
|
|
1399
|
-
} catch (err) {
|
|
1400
|
-
if (this.config.failOnUploadError) {
|
|
1401
|
-
throw err;
|
|
1304
|
+
if (this.config.shardIndex !== void 0) {
|
|
1305
|
+
for (const suite of payload.suites) {
|
|
1306
|
+
for (const c of suite.cases) {
|
|
1307
|
+
c.shardIndex = this.config.shardIndex;
|
|
1308
|
+
}
|
|
1402
1309
|
}
|
|
1403
|
-
logger.error("failed to upload results to Qualflare:", err);
|
|
1404
1310
|
}
|
|
1311
|
+
fs3.mkdirSync(this.config.outputDir, { recursive: true });
|
|
1312
|
+
const outputPath = path4.join(this.config.outputDir, `${randomUUID2()}.json`);
|
|
1313
|
+
fs3.writeFileSync(outputPath, JSON.stringify(payload));
|
|
1314
|
+
logger.info(
|
|
1315
|
+
`wrote Collect payload to ${outputPath} \u2014 run \`qualflare-cli collect ${this.config.outputDir}\` to upload it.`
|
|
1316
|
+
);
|
|
1405
1317
|
}
|
|
1406
1318
|
};
|
|
1407
1319
|
export {
|