@qualflare/cucumberjs 0.3.0 → 0.5.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
@@ -5,7 +5,7 @@
5
5
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](./LICENSE)
6
6
 
7
7
  A native CucumberJS reporter for [Qualflare](https://qualflare.com) — captures test results directly
8
- from your `cucumber-js` run: Feature/Scenario status, real retry counts, screenshots, videos,
8
+ from your `cucumber-js` run: Feature/Scenario status, per-attempt retry history, screenshots, videos,
9
9
  Given/When/Then step traces, Scenario Outline rows, and author-facing metadata (labels, links, tags,
10
10
  custom attachments).
11
11
 
@@ -125,6 +125,21 @@ wrong value cannot fail at run time — this package makes no network calls —
125
125
  (workaround, not a first-class rendering).
126
126
  - **`BeforeStep`/`AfterStep` hooks are off by default** (`includeStepHooks`) — noisy for suites with
127
127
  global per-step instrumentation.
128
+ - **`BeforeAll`/`AfterAll` attachments need the hook to fail** — a failed global hook becomes a
129
+ synthetic Case and its attachments land there; a passing one produces no Case, so they are
130
+ dropped.
131
+ - **`parameter()` outside a step is not masked** — `masked` is a display hint for the UI; the
132
+ server never redacts the value, so never put a real secret in one. See
133
+ [`docs/LIMITATIONS.md`](./docs/LIMITATIONS.md#qualflareparameter-outside-a-step-has-no-masking).
134
+ - **Attachment caps are two budgets, not one pool** — `maxAttachmentBytes` bounds a single
135
+ attachment and `maxTotalAttachmentBytes` the whole run; anything over either is dropped
136
+ outright rather than truncated. Raising them is the easiest way to push a request past
137
+ `/collect`'s body limit. See
138
+ [`docs/LIMITATIONS.md`](./docs/LIMITATIONS.md#per-caseper-attachment-caps-are-independent-not-pooled).
139
+ - **Retries carry per-attempt errors, but everything else is the final attempt** — `Case.attempts`
140
+ records each attempt's status, duration and error; steps, labels, links, tags, priority,
141
+ properties and attachments come from the last attempt only, so an abandoned attempt's step trace
142
+ is discarded rather than replayed alongside the final one.
128
143
 
129
144
  Full details in [`docs/LIMITATIONS.md`](./docs/LIMITATIONS.md).
130
145
 
@@ -48,6 +48,8 @@ var RESERVED_MESSAGE_MEDIA_TYPE = "application/vnd.qualflare.message+json";
48
48
  var MAX_SUITES_PER_LAUNCH = 2e3;
49
49
  var MAX_CASES_PER_SUITE = 5e3;
50
50
  var MAX_TAGS_PER_CASE = 64;
51
+ var MAX_ATTEMPTS_PER_CASE = 50;
52
+ var MAX_ATTEMPT_MESSAGE_RUNES = 8192;
51
53
  var MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;
52
54
  var MAX_STEPS_PER_TEST_ATTEMPT = 300;
53
55
 
@@ -526,6 +528,18 @@ function messageDurationToNs(duration) {
526
528
  return total > 0 ? Math.round(total) : 0;
527
529
  }
528
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
+
529
543
  // src/formatter/case-builder.ts
530
544
  function combineSteps(realSteps, manualSteps) {
531
545
  if (realSteps.length === 0 && manualSteps.length === 0) {
@@ -544,6 +558,26 @@ function combineSteps(realSteps, manualSteps) {
544
558
  });
545
559
  return [...realSteps, ...offsetManual];
546
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
+ }
547
581
  function collapseAttempts(attempts) {
548
582
  if (attempts.length === 0) {
549
583
  throw new Error("collapseAttempts: at least one attempt is required");
@@ -552,11 +586,13 @@ function collapseAttempts(attempts) {
552
586
  const retryCount = attempts.length - 1;
553
587
  const isFlaky = retryCount > 0 && final.status === "passed" && attempts.some((a) => a.status !== "passed");
554
588
  const duration = attempts.reduce((sum, a) => sum + a.duration, 0);
589
+ const attemptHistory = buildAttempts(attempts);
555
590
  return {
556
591
  status: final.status,
557
592
  duration,
558
593
  retryCount,
559
594
  isFlaky,
595
+ ...attemptHistory ? { attempts: attemptHistory } : {},
560
596
  error: final.status === "passed" ? void 0 : final.error,
561
597
  steps: combineSteps(final.steps, final.manualSteps),
562
598
  labels: final.labels,
@@ -596,6 +632,7 @@ function buildCase(uri, pickle, collapsed, gherkin) {
596
632
  duration: collapsed.duration,
597
633
  retryCount: collapsed.retryCount || void 0,
598
634
  isFlaky: collapsed.isFlaky || void 0,
635
+ attempts: collapsed.attempts,
599
636
  error: collapsed.error,
600
637
  tags: tags.length > 0 ? tags : void 0,
601
638
  steps: collapsed.steps,
@@ -1001,7 +1038,7 @@ function timestampMs(ts) {
1001
1038
  var os = __toESM(require("os"), 1);
1002
1039
 
1003
1040
  // src/config/version.ts
1004
- var PACKAGE_VERSION = "0.3.0";
1041
+ var PACKAGE_VERSION = "0.5.0";
1005
1042
 
1006
1043
  // src/formatter/collect-builder.ts
1007
1044
  function resolveOs(config) {
@@ -1128,23 +1165,52 @@ function buildHookIndex(supportCodeLibrary) {
1128
1165
 
1129
1166
  // src/formatter/run-hook-tracker.ts
1130
1167
  var RunHookTracker = class {
1168
+ constructor(config, budget) {
1169
+ this.config = config;
1170
+ this.budget = budget;
1171
+ }
1172
+ config;
1173
+ budget;
1131
1174
  /** `testRunHookStartedId` -> the hook it started, so `finish()` can look
1132
1175
  * up its kind/name. `TestRunHookFinished.result.duration` already gives
1133
1176
  * an accurate duration directly — no start/finish timestamp delta needed. */
1134
1177
  started = /* @__PURE__ */ new Map();
1135
1178
  failed = [];
1179
+ /** `testRunHookStartedId` -> attachments made during that hook, unresolved. */
1180
+ pending = /* @__PURE__ */ new Map();
1136
1181
  start(e) {
1137
1182
  this.started.set(e.id, e.hookId);
1138
1183
  }
1184
+ /**
1185
+ * Buffers one attachment made from inside a `BeforeAll`/`AfterAll`. Returns
1186
+ * whether it was claimed, so the caller can tell a run-hook attachment from
1187
+ * one that simply has no home.
1188
+ *
1189
+ * `testRunHookStartedId` is optional in the message schema and only populated
1190
+ * by newer cucumber-js; when it is absent this returns false and the
1191
+ * attachment is dropped exactly as it was before.
1192
+ */
1193
+ attachment(e) {
1194
+ if (!e.testRunHookStartedId) {
1195
+ return false;
1196
+ }
1197
+ const content = e.contentEncoding === "BASE64" ? e.body : Buffer.from(e.body, "utf8").toString("base64");
1198
+ const list = this.pending.get(e.testRunHookStartedId) ?? [];
1199
+ list.push({ name: e.fileName || "attachment", mimeType: e.mediaType, content });
1200
+ this.pending.set(e.testRunHookStartedId, list);
1201
+ return true;
1202
+ }
1139
1203
  finish(e, hookIndex) {
1140
1204
  const hookId = this.started.get(e.testRunHookStartedId);
1141
1205
  this.started.delete(e.testRunHookStartedId);
1142
1206
  const status = mapStatus(e.result.status);
1143
1207
  if (status === "passed" || status === "skipped") {
1208
+ this.pending.delete(e.testRunHookStartedId);
1144
1209
  return;
1145
1210
  }
1146
1211
  const hook = hookId ? hookIndex.get(hookId) : void 0;
1147
1212
  const label = hook?.kind === "afterAll" ? "AfterAll hook" : "BeforeAll hook";
1213
+ const attachments = this.takeAttachments(e.testRunHookStartedId);
1148
1214
  this.failed.push({
1149
1215
  id: `global-hook:${e.testRunHookStartedId}`,
1150
1216
  name: hook?.name || label,
@@ -1153,9 +1219,17 @@ var RunHookTracker = class {
1153
1219
  // `result.message` first — see `step-mapper.ts`'s `formatError()` doc
1154
1220
  // comment for why (verified empirically to be the version-safe field
1155
1221
  // across the peer-dependency range; `exception.stackTrace` is not).
1156
- error: e.result.message || e.result.exception?.stackTrace || e.result.exception?.message
1222
+ error: e.result.message || e.result.exception?.stackTrace || e.result.exception?.message,
1223
+ ...attachments.length > 0 ? { attachments } : {}
1157
1224
  });
1158
1225
  }
1226
+ /** Resolves and clears the buffer for one hook. Called only for a FAILED
1227
+ * hook, so the budget is never spent on a passing one's attachments. */
1228
+ takeAttachments(testRunHookStartedId) {
1229
+ const buffered = this.pending.get(testRunHookStartedId) ?? [];
1230
+ this.pending.delete(testRunHookStartedId);
1231
+ return buffered.map((pending) => resolvePendingAttachment(pending, this.config, this.budget)).filter((a) => a !== void 0);
1232
+ }
1159
1233
  /** Returns `undefined` if no run-hook failed — see the class doc comment
1160
1234
  * for why a passing BeforeAll/AfterAll produces no Case at all. */
1161
1235
  buildSuite() {
@@ -1227,7 +1301,7 @@ var QualflareCucumberFormatter = class extends import_cucumber.Formatter {
1227
1301
  testCaseIndex = /* @__PURE__ */ new Map();
1228
1302
  attachmentBudget;
1229
1303
  attemptTracker;
1230
- runHookTracker = new RunHookTracker();
1304
+ runHookTracker;
1231
1305
  finishedCases = [];
1232
1306
  /** One promise per `testCaseFinished` envelope, resolving once that
1233
1307
  * scenario's `AttemptTracker.finish()` (which itself awaits any pending
@@ -1242,6 +1316,7 @@ var QualflareCucumberFormatter = class extends import_cucumber.Formatter {
1242
1316
  this.config = resolveConfig(options.parsedArgvOptions);
1243
1317
  this.hookIndex = buildHookIndex(options.supportCodeLibrary);
1244
1318
  this.attachmentBudget = new AttachmentBudget(this.config.maxTotalAttachmentBytes);
1319
+ this.runHookTracker = new RunHookTracker(this.config, this.attachmentBudget);
1245
1320
  this.attemptTracker = new AttemptTracker(
1246
1321
  this.hookIndex,
1247
1322
  this.gherkin,