@qualflare/cucumberjs 0.2.0 → 0.4.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 CHANGED
@@ -106,11 +106,17 @@ Every option can be set either as a `formatOptions` entry or via a `QUALFLARE_*`
106
106
  variable. Full table, precedence rules, and auto-detection behavior (git branch/commit, CI
107
107
  provider/build/PR) in [`docs/CONFIGURATION.md`](./docs/CONFIGURATION.md).
108
108
 
109
+ One option is worth calling out because it fails late: `environment` is matched against the
110
+ environment's **uid (slug)**, not its display name, so **Staging** in the UI is `staging` here. A
111
+ wrong value cannot fail at run time — this package makes no network calls — so the run succeeds and
112
+ `collect` 404s afterwards. See
113
+ [the note in the configuration docs](./docs/CONFIGURATION.md#environment-is-matched-by-uid-not-display-name).
114
+
109
115
  ## Known limitations
110
116
 
111
- - **`outputDir` is merged blindly** — `qf collect` uploads every report file it finds, with no
112
- run-identity check, so a directory left over from a previous run is silently merged into the
113
- current one. Clear or freshly create `outputDir` at the start of each run.
117
+ - **A stale `outputDir` is refused, not merged** — each report carries a `runId`, and `qf collect`
118
+ errors rather than merging files from two different runs. Needs `@qualflare/cli` v0.1.19+; older
119
+ CLIs merge as before.
114
120
  - **`shardIndex` is best-effort** — cucumber-js routes its own `--shard` flag somewhere a formatter
115
121
  cannot read, so it is recovered from `QUALFLARE_SHARD_INDEX` or by scanning `process.argv`. It is
116
122
  only an attribution label; merging never depends on it. See
@@ -35,16 +35,21 @@ __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");
38
+ var import_node_crypto3 = require("crypto");
39
39
  var fs3 = __toESM(require("fs"), 1);
40
40
  var path4 = __toESM(require("path"), 1);
41
41
  var import_cucumber = require("@cucumber/cucumber");
42
42
 
43
+ // src/config/resolve-config.ts
44
+ var import_node_crypto = require("crypto");
45
+
43
46
  // src/shared/constants.ts
44
47
  var RESERVED_MESSAGE_MEDIA_TYPE = "application/vnd.qualflare.message+json";
45
48
  var MAX_SUITES_PER_LAUNCH = 2e3;
46
49
  var MAX_CASES_PER_SUITE = 5e3;
47
50
  var MAX_TAGS_PER_CASE = 64;
51
+ var MAX_ATTEMPTS_PER_CASE = 50;
52
+ var MAX_ATTEMPT_MESSAGE_RUNES = 8192;
48
53
  var MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;
49
54
  var MAX_STEPS_PER_TEST_ATTEMPT = 300;
50
55
 
@@ -65,6 +70,7 @@ var PROVIDERS = [
65
70
  detect: (env) => env.GITHUB_ACTIONS === "true",
66
71
  providerName: "GitHub Actions",
67
72
  buildNumber: (env) => nonEmpty(env.GITHUB_RUN_NUMBER),
73
+ runId: (env) => nonEmpty(env.GITHUB_RUN_ID),
68
74
  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,
69
75
  prNumber: (env) => {
70
76
  const match = /^refs\/pull\/(\d+)\/merge$/.exec(env.GITHUB_REF ?? "");
@@ -75,6 +81,7 @@ var PROVIDERS = [
75
81
  detect: (env) => env.GITLAB_CI === "true",
76
82
  providerName: "GitLab CI",
77
83
  buildNumber: (env) => nonEmpty(env.CI_PIPELINE_IID),
84
+ runId: (env) => nonEmpty(env.CI_PIPELINE_ID),
78
85
  runUrl: (env) => nonEmpty(env.CI_PIPELINE_URL),
79
86
  prNumber: (env) => parsePositiveInt(env.CI_MERGE_REQUEST_IID)
80
87
  },
@@ -82,6 +89,7 @@ var PROVIDERS = [
82
89
  detect: (env) => env.CIRCLECI === "true",
83
90
  providerName: "CircleCI",
84
91
  buildNumber: (env) => nonEmpty(env.CIRCLE_BUILD_NUM),
92
+ runId: (env) => nonEmpty(env.CIRCLE_WORKFLOW_ID ?? env.CIRCLE_BUILD_NUM),
85
93
  runUrl: (env) => nonEmpty(env.CIRCLE_BUILD_URL),
86
94
  prNumber: (env) => parsePositiveInt(env.CIRCLE_PR_NUMBER)
87
95
  },
@@ -89,6 +97,7 @@ var PROVIDERS = [
89
97
  detect: (env) => env.BUILDKITE === "true",
90
98
  providerName: "Buildkite",
91
99
  buildNumber: (env) => nonEmpty(env.BUILDKITE_BUILD_NUMBER),
100
+ runId: (env) => nonEmpty(env.BUILDKITE_BUILD_ID),
92
101
  runUrl: (env) => nonEmpty(env.BUILDKITE_BUILD_URL),
93
102
  prNumber: (env) => {
94
103
  const raw = env.BUILDKITE_PULL_REQUEST;
@@ -104,6 +113,7 @@ var PROVIDERS = [
104
113
  detect: (env) => Boolean(env.JENKINS_URL),
105
114
  providerName: "Jenkins",
106
115
  buildNumber: (env) => nonEmpty(env.BUILD_NUMBER),
116
+ runId: (env) => nonEmpty(env.BUILD_TAG ?? env.BUILD_NUMBER),
107
117
  runUrl: (env) => nonEmpty(env.BUILD_URL)
108
118
  // Jenkins has no standardized PR-number env var across its many PR
109
119
  // plugins (Multibranch, GitHub Branch Source, etc.) — deliberately
@@ -113,6 +123,7 @@ var PROVIDERS = [
113
123
  detect: (env) => env.TF_BUILD === "True" || env.TF_BUILD === "true",
114
124
  providerName: "Azure Pipelines",
115
125
  buildNumber: (env) => nonEmpty(env.BUILD_BUILDID),
126
+ runId: (env) => nonEmpty(env.BUILD_BUILDID),
116
127
  runUrl: (env) => {
117
128
  const collectionUri = env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI;
118
129
  const project = env.SYSTEM_TEAMPROJECT;
@@ -128,6 +139,7 @@ var PROVIDERS = [
128
139
  detect: (env) => Boolean(env.BITBUCKET_BUILD_NUMBER),
129
140
  providerName: "Bitbucket Pipelines",
130
141
  buildNumber: (env) => nonEmpty(env.BITBUCKET_BUILD_NUMBER),
142
+ runId: (env) => nonEmpty(env.BITBUCKET_BUILD_NUMBER),
131
143
  runUrl: (env) => {
132
144
  const origin = env.BITBUCKET_GIT_HTTP_ORIGIN;
133
145
  if (!origin) {
@@ -149,6 +161,8 @@ function detectCi(env = process.env) {
149
161
  if (runUrl !== void 0) result.ciRunUrl = runUrl;
150
162
  const prNumber = provider.prNumber?.(env);
151
163
  if (prNumber !== void 0) result.ciPrNumber = prNumber;
164
+ const runId = provider.runId?.(env);
165
+ if (runId !== void 0) result.ciRunId = runId;
152
166
  return result;
153
167
  }
154
168
  if (ciInfo.name) {
@@ -256,6 +270,7 @@ function resolveConfig(options, deps = {}) {
256
270
  const ciBuildNumber = options.ciBuildNumber ?? detectedCi.ciBuildNumber;
257
271
  const ciRunUrl = options.ciRunUrl ?? detectedCi.ciRunUrl;
258
272
  const ciPrNumber = options.ciPrNumber ?? detectedCi.ciPrNumber;
273
+ const runId = options.runId ?? firstEnv2("QUALFLARE_RUN_ID") ?? detectedCi.ciRunId ?? (0, import_node_crypto.randomUUID)();
259
274
  return {
260
275
  // `||` (truthy check), not `??`, for these three REQUIRED-non-empty wire
261
276
  // fields — an explicit `''` option must not silently win over the
@@ -275,6 +290,7 @@ function resolveConfig(options, deps = {}) {
275
290
  ciBuildNumber,
276
291
  ciRunUrl,
277
292
  ciPrNumber,
293
+ runId,
278
294
  attachScreenshots: options.attachScreenshots ?? envBool("QUALFLARE_ATTACH_SCREENSHOTS") ?? true,
279
295
  includeStepHooks: options.includeStepHooks ?? envBool("QUALFLARE_INCLUDE_STEP_HOOKS") ?? false,
280
296
  maxAttachmentBytes: options.maxAttachmentBytes ?? envInt("QUALFLARE_MAX_ATTACHMENT_BYTES") ?? 15e5,
@@ -308,8 +324,8 @@ var logger = {
308
324
  var fs2 = __toESM(require("fs"), 1);
309
325
  var path2 = __toESM(require("path"), 1);
310
326
 
311
- // src/formatter/video-uploader.ts
312
- var import_node_crypto = require("crypto");
327
+ // src/formatter/video-writer.ts
328
+ var import_node_crypto2 = require("crypto");
313
329
  var fs = __toESM(require("fs"), 1);
314
330
  var path = __toESM(require("path"), 1);
315
331
  var VIDEO_MIME_TYPES_BY_EXTENSION = {
@@ -338,7 +354,7 @@ function writeVideoAttachment(pending, outputDir, maxVideoBytes) {
338
354
  logger.warn(`skipping video attachment "${pending.name}": unsupported video format.`);
339
355
  return void 0;
340
356
  }
341
- const localVideoPath = `${(0, import_node_crypto.randomUUID)()}${resolved.extension}`;
357
+ const localVideoPath = `${(0, import_node_crypto2.randomUUID)()}${resolved.extension}`;
342
358
  const destination = path.join(outputDir, localVideoPath);
343
359
  if (pending.path !== void 0) {
344
360
  let fileSize;
@@ -512,6 +528,18 @@ function messageDurationToNs(duration) {
512
528
  return total > 0 ? Math.round(total) : 0;
513
529
  }
514
530
 
531
+ // src/shared/text.ts
532
+ function truncateRunes(value, maxRunes) {
533
+ if (value.length <= maxRunes) {
534
+ return value;
535
+ }
536
+ const runes = Array.from(value);
537
+ if (runes.length <= maxRunes) {
538
+ return value;
539
+ }
540
+ return runes.slice(0, maxRunes).join("");
541
+ }
542
+
515
543
  // src/formatter/case-builder.ts
516
544
  function combineSteps(realSteps, manualSteps) {
517
545
  if (realSteps.length === 0 && manualSteps.length === 0) {
@@ -530,6 +558,26 @@ function combineSteps(realSteps, manualSteps) {
530
558
  });
531
559
  return [...realSteps, ...offsetManual];
532
560
  }
561
+ function buildAttempts(attempts) {
562
+ if (attempts.length < 2) {
563
+ return void 0;
564
+ }
565
+ let kept = attempts;
566
+ if (attempts.length > MAX_ATTEMPTS_PER_CASE) {
567
+ kept = [...attempts.slice(0, MAX_ATTEMPTS_PER_CASE - 1), attempts[attempts.length - 1]];
568
+ }
569
+ return kept.map((a, i) => {
570
+ const attempt = {
571
+ attempt: i + 1,
572
+ status: a.status,
573
+ duration: a.duration
574
+ };
575
+ if (a.error) {
576
+ attempt.message = truncateRunes(a.error, MAX_ATTEMPT_MESSAGE_RUNES);
577
+ }
578
+ return attempt;
579
+ });
580
+ }
533
581
  function collapseAttempts(attempts) {
534
582
  if (attempts.length === 0) {
535
583
  throw new Error("collapseAttempts: at least one attempt is required");
@@ -538,11 +586,13 @@ function collapseAttempts(attempts) {
538
586
  const retryCount = attempts.length - 1;
539
587
  const isFlaky = retryCount > 0 && final.status === "passed" && attempts.some((a) => a.status !== "passed");
540
588
  const duration = attempts.reduce((sum, a) => sum + a.duration, 0);
589
+ const attemptHistory = buildAttempts(attempts);
541
590
  return {
542
591
  status: final.status,
543
592
  duration,
544
593
  retryCount,
545
594
  isFlaky,
595
+ ...attemptHistory ? { attempts: attemptHistory } : {},
546
596
  error: final.status === "passed" ? void 0 : final.error,
547
597
  steps: combineSteps(final.steps, final.manualSteps),
548
598
  labels: final.labels,
@@ -582,6 +632,7 @@ function buildCase(uri, pickle, collapsed, gherkin) {
582
632
  duration: collapsed.duration,
583
633
  retryCount: collapsed.retryCount || void 0,
584
634
  isFlaky: collapsed.isFlaky || void 0,
635
+ attempts: collapsed.attempts,
585
636
  error: collapsed.error,
586
637
  tags: tags.length > 0 ? tags : void 0,
587
638
  steps: collapsed.steps,
@@ -987,7 +1038,7 @@ function timestampMs(ts) {
987
1038
  var os = __toESM(require("os"), 1);
988
1039
 
989
1040
  // src/config/version.ts
990
- var PACKAGE_VERSION = "0.2.0";
1041
+ var PACKAGE_VERSION = "0.4.0";
991
1042
 
992
1043
  // src/formatter/collect-builder.ts
993
1044
  function resolveOs(config) {
@@ -1010,7 +1061,8 @@ function buildCollectPayload(suites, config) {
1010
1061
  metadata: {
1011
1062
  version: PACKAGE_VERSION,
1012
1063
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1013
- cliName: "qualflare-cucumberjs"
1064
+ cliName: "qualflare-cucumberjs",
1065
+ runId: config.runId
1014
1066
  },
1015
1067
  properties: config.properties,
1016
1068
  suites,
@@ -1343,7 +1395,7 @@ var QualflareCucumberFormatter = class extends import_cucumber.Formatter {
1343
1395
  }
1344
1396
  }
1345
1397
  fs3.mkdirSync(this.config.outputDir, { recursive: true });
1346
- const outputPath = path4.join(this.config.outputDir, `${(0, import_node_crypto2.randomUUID)()}.json`);
1398
+ const outputPath = path4.join(this.config.outputDir, `${(0, import_node_crypto3.randomUUID)()}.json`);
1347
1399
  fs3.writeFileSync(outputPath, JSON.stringify(payload));
1348
1400
  logger.info(
1349
1401
  `wrote Collect payload to ${outputPath} \u2014 run \`qualflare-cli collect ${this.config.outputDir}\` to upload it.`