@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/dist/index.cjs ADDED
@@ -0,0 +1,931 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ qualflare: () => qualflare
24
+ });
25
+ module.exports = __toCommonJS(src_exports);
26
+
27
+ // src/browser/browser-integration-guard.ts
28
+ function initializeBrowserIntegration(register) {
29
+ const target = typeof Cypress === "undefined" ? void 0 : Cypress;
30
+ if (target?.__qualflareBrowserRegistered) {
31
+ return;
32
+ }
33
+ if (target) {
34
+ target.__qualflareBrowserRegistered = true;
35
+ }
36
+ register();
37
+ }
38
+
39
+ // src/shared/constants.ts
40
+ var TASK_REPORT_CASE = "qualflareReportCase";
41
+ var TASK_MARK_TEST_PHASE_STARTED = "qualflareMarkTestPhaseStarted";
42
+ var MAX_PARAMETERS_PER_STEP = 50;
43
+ var MAX_ATTACHMENTS_PER_CASE = 50;
44
+ var MAX_LABELS_PER_CASE = 100;
45
+ var MAX_LINKS_PER_CASE = 20;
46
+ var MAX_TAGS_PER_CASE = 64;
47
+ var MAX_TAG_LENGTH = 255;
48
+ var MAX_STEPS_PER_TEST_ATTEMPT = 300;
49
+
50
+ // src/shared/duration.ts
51
+ var NS_PER_MS = 1e6;
52
+ function msToNs(ms) {
53
+ if (!Number.isFinite(ms) || ms <= 0) {
54
+ return 0;
55
+ }
56
+ return Math.round(ms * NS_PER_MS);
57
+ }
58
+
59
+ // src/shared/logger.ts
60
+ var PREFIX = "[qualflare-cypress]";
61
+ var logger = {
62
+ debug(...args) {
63
+ console.debug(PREFIX, ...args);
64
+ },
65
+ info(...args) {
66
+ console.log(PREFIX, ...args);
67
+ },
68
+ warn(...args) {
69
+ console.warn(PREFIX, ...args);
70
+ },
71
+ error(...args) {
72
+ console.error(PREFIX, ...args);
73
+ }
74
+ };
75
+
76
+ // src/browser/console-props.ts
77
+ var MAX_VALUE_CHARS = 500;
78
+ var MAX_STRINGIFY_DEPTH = 3;
79
+ function safeStringify(value, depth = 0, seen = /* @__PURE__ */ new WeakSet()) {
80
+ if (value === null) return "null";
81
+ if (value === void 0) return "undefined";
82
+ const type = typeof value;
83
+ if (type === "string") {
84
+ return truncate(value);
85
+ }
86
+ if (type === "number" || type === "boolean" || type === "bigint") {
87
+ return truncate(String(value));
88
+ }
89
+ if (type === "function") {
90
+ return "[function]";
91
+ }
92
+ if (type === "symbol") {
93
+ return value.toString();
94
+ }
95
+ const obj = value;
96
+ if (seen.has(obj)) {
97
+ return "[circular]";
98
+ }
99
+ seen.add(obj);
100
+ try {
101
+ if (isDomElementLike(obj)) {
102
+ return describeDomElementLike(obj);
103
+ }
104
+ if (depth >= MAX_STRINGIFY_DEPTH) {
105
+ return Array.isArray(obj) ? "[array]" : "[object]";
106
+ }
107
+ if (Array.isArray(obj)) {
108
+ const items = obj.slice(0, 20).map((item) => safeStringify(item, depth + 1, seen));
109
+ const suffix = obj.length > 20 ? `, \u2026(${obj.length - 20} more)` : "";
110
+ return truncate(`[${items.join(", ")}${suffix}]`);
111
+ }
112
+ if (obj instanceof Error) {
113
+ return truncate(obj.message ? `${obj.name}: ${obj.message}` : obj.name);
114
+ }
115
+ const entries = Object.entries(obj).slice(0, 20);
116
+ const rendered = entries.map(([key, val]) => `${key}: ${safeStringify(val, depth + 1, seen)}`);
117
+ return truncate(`{${rendered.join(", ")}}`);
118
+ } catch {
119
+ return "[unserializable]";
120
+ } finally {
121
+ seen.delete(obj);
122
+ }
123
+ }
124
+ function truncate(text) {
125
+ return text.length > MAX_VALUE_CHARS ? `${text.slice(0, MAX_VALUE_CHARS)}\u2026` : text;
126
+ }
127
+ function isDomElementLike(obj) {
128
+ return typeof obj.tagName === "string";
129
+ }
130
+ function describeDomElementLike(el) {
131
+ const tag = el.tagName.toLowerCase();
132
+ const id = el.id ? `#${el.id}` : "";
133
+ const cls = typeof el.className === "string" && el.className ? `.${el.className.split(/\s+/).join(".")}` : "";
134
+ return `<${tag}${id}${cls}>`;
135
+ }
136
+ function buildParametersFromConsoleProps(consoleProps) {
137
+ let resolved = consoleProps;
138
+ if (typeof resolved === "function") {
139
+ try {
140
+ resolved = resolved();
141
+ } catch {
142
+ return [];
143
+ }
144
+ }
145
+ if (resolved === null || typeof resolved !== "object") {
146
+ return [];
147
+ }
148
+ const outer = resolved;
149
+ const nestedProps = outer.props;
150
+ const source = nestedProps !== null && typeof nestedProps === "object" && !Array.isArray(nestedProps) ? nestedProps : outer;
151
+ const parameters = [];
152
+ for (const [name, value] of Object.entries(source)) {
153
+ if (typeof value === "function") {
154
+ continue;
155
+ }
156
+ parameters.push({ name, value: safeStringify(value) });
157
+ if (parameters.length >= MAX_PARAMETERS_PER_STEP) {
158
+ break;
159
+ }
160
+ }
161
+ return parameters;
162
+ }
163
+
164
+ // src/browser/command-log-listener.ts
165
+ var MAX_STEP_MESSAGE_CHARS = 200;
166
+ function describeMessage(message) {
167
+ if (typeof message !== "string") {
168
+ return void 0;
169
+ }
170
+ const trimmed = message.trim();
171
+ if (trimmed.length === 0) {
172
+ return void 0;
173
+ }
174
+ return trimmed.length > MAX_STEP_MESSAGE_CHARS ? `${trimmed.slice(0, MAX_STEP_MESSAGE_CHARS)}\u2026` : trimmed;
175
+ }
176
+ function formatLogError(err) {
177
+ if (err === void 0 || err === null) {
178
+ return void 0;
179
+ }
180
+ if (typeof err === "string") {
181
+ return err;
182
+ }
183
+ if (err instanceof Error) {
184
+ return err.stack ? `${err.message}
185
+ ${err.stack}` : err.message;
186
+ }
187
+ return err.stack ? `${err.message ?? ""}
188
+ ${err.stack}`.trim() : err.message;
189
+ }
190
+ function mapLogState(state) {
191
+ if (state === "failed") return "failed";
192
+ if (state === "passed") return "passed";
193
+ return "pending";
194
+ }
195
+ var CommandLogBuffer = class {
196
+ records = [];
197
+ indexById = /* @__PURE__ */ new Map();
198
+ lastParentIndex;
199
+ capWarned = false;
200
+ handleAdded(attrs, now = Date.now()) {
201
+ if (!attrs.name) {
202
+ return;
203
+ }
204
+ if (this.records.length >= MAX_STEPS_PER_TEST_ATTEMPT) {
205
+ if (!this.capWarned) {
206
+ this.capWarned = true;
207
+ logger.warn(
208
+ `reached the ${MAX_STEPS_PER_TEST_ATTEMPT}-step-per-test soft cap \u2014 further command-log entries for this test attempt will not be recorded.`
209
+ );
210
+ }
211
+ return;
212
+ }
213
+ const index = this.records.length;
214
+ const parentIndex = attrs.type === "child" ? this.lastParentIndex : void 0;
215
+ if (attrs.type === "parent") {
216
+ this.lastParentIndex = index;
217
+ }
218
+ const message = describeMessage(attrs.message);
219
+ const name = message ? `${attrs.name} ${message}` : attrs.name;
220
+ this.records.push({
221
+ name,
222
+ keyword: attrs.displayName && attrs.displayName !== attrs.name ? attrs.displayName : void 0,
223
+ status: mapLogState(attrs.state),
224
+ error: formatLogError(attrs.err),
225
+ parentIndex,
226
+ startedAt: now,
227
+ lastSeenAt: now,
228
+ consoleProps: attrs.consoleProps
229
+ });
230
+ if (attrs.id) {
231
+ this.indexById.set(attrs.id, index);
232
+ }
233
+ }
234
+ handleChanged(attrs, now = Date.now()) {
235
+ const index = attrs.id ? this.indexById.get(attrs.id) : void 0;
236
+ if (index === void 0) {
237
+ return;
238
+ }
239
+ const record = this.records[index];
240
+ if (!record) {
241
+ return;
242
+ }
243
+ record.lastSeenAt = now;
244
+ if (attrs.state !== void 0) {
245
+ record.status = mapLogState(attrs.state);
246
+ }
247
+ if (attrs.err !== void 0) {
248
+ record.error = formatLogError(attrs.err);
249
+ }
250
+ if (attrs.consoleProps !== void 0) {
251
+ record.consoleProps = attrs.consoleProps;
252
+ }
253
+ }
254
+ /** Returns this attempt's steps as wire-shaped `Step[]` (durations already
255
+ * converted to nanoseconds — unlike `Case`-level duration, which stays in
256
+ * milliseconds until `queue.ts`, `Step` has no separate internal/ms-shaped
257
+ * representation elsewhere in this codebase, so there's no benefit to
258
+ * threading one through here only to convert it later) and clears the
259
+ * buffer for the next attempt. */
260
+ drain() {
261
+ const steps = this.records.map((record) => {
262
+ const step = {
263
+ name: record.name,
264
+ status: record.status,
265
+ duration: msToNs(Math.max(0, record.lastSeenAt - record.startedAt))
266
+ };
267
+ if (record.keyword) step.keyword = record.keyword;
268
+ if (record.error) step.error = record.error;
269
+ if (record.location) step.location = record.location;
270
+ if (record.parentIndex !== void 0) step.parentIndex = record.parentIndex;
271
+ const parameters = buildParametersFromConsoleProps(record.consoleProps);
272
+ if (parameters.length > 0) step.parameters = parameters;
273
+ return step;
274
+ });
275
+ this.reset();
276
+ return steps;
277
+ }
278
+ /** Starts a fresh attempt: clears all recorded steps and nesting state.
279
+ * Steps from an abandoned (retried) attempt are discarded, never merged
280
+ * into the next attempt's buffer — see `mocha-listener.ts`, which calls
281
+ * this on every `runner.on('test', ...)` (fired once per attempt,
282
+ * including retries) so only the FINAL attempt's steps ever reach
283
+ * `drain()`. */
284
+ reset() {
285
+ this.records = [];
286
+ this.indexById.clear();
287
+ this.lastParentIndex = void 0;
288
+ this.capWarned = false;
289
+ }
290
+ };
291
+ function registerCommandLogListener(buffer) {
292
+ Cypress.on("log:added", (attributes) => {
293
+ buffer.handleAdded(attributes);
294
+ });
295
+ Cypress.on("log:changed", (attributes) => {
296
+ buffer.handleChanged(attributes);
297
+ });
298
+ }
299
+
300
+ // src/browser/case-builder.ts
301
+ function combineSteps(autoSteps, manualSteps) {
302
+ const auto = autoSteps ?? [];
303
+ const manual = manualSteps ?? [];
304
+ if (auto.length === 0 && manual.length === 0) {
305
+ return void 0;
306
+ }
307
+ const offsetManual = manual.map((record) => {
308
+ const step = { name: record.name, status: record.status, duration: msToNs(record.durationMs ?? 0) };
309
+ if (record.error) step.error = record.error;
310
+ if (record.parentIndex !== void 0) step.parentIndex = record.parentIndex + auto.length;
311
+ if (record.parameters && record.parameters.length > 0) step.parameters = record.parameters;
312
+ return step;
313
+ });
314
+ return [...auto, ...offsetManual];
315
+ }
316
+ function collapseAttempts(attempts) {
317
+ if (attempts.length === 0) {
318
+ throw new Error("collapseAttempts: at least one attempt is required");
319
+ }
320
+ const final = attempts[attempts.length - 1];
321
+ const retryCount = attempts.length - 1;
322
+ const isFlaky = retryCount > 0 && final.status === "passed" && attempts.some((a) => a.status !== "passed");
323
+ const duration = attempts.reduce((sum, a) => sum + a.duration, 0);
324
+ return {
325
+ status: final.status,
326
+ duration,
327
+ retryCount,
328
+ isFlaky,
329
+ error: final.status === "passed" ? void 0 : final.error,
330
+ steps: combineSteps(final.steps, final.manualSteps),
331
+ labels: final.labels,
332
+ links: final.links,
333
+ tags: final.tags,
334
+ description: final.description,
335
+ priority: final.priority,
336
+ properties: final.properties,
337
+ attachments: final.attachments
338
+ };
339
+ }
340
+
341
+ // src/browser/queue.ts
342
+ function flushCase(test, attempts) {
343
+ if (attempts.length === 0) {
344
+ return;
345
+ }
346
+ const collapsed = collapseAttempts(attempts);
347
+ const testCase = {
348
+ id: test.fullTitle(),
349
+ name: test.title,
350
+ className: test.parent?.fullTitle() || void 0,
351
+ status: collapsed.status,
352
+ duration: msToNs(collapsed.duration),
353
+ retryCount: collapsed.retryCount,
354
+ isFlaky: collapsed.isFlaky,
355
+ error: collapsed.error,
356
+ steps: collapsed.steps,
357
+ // qualflare.* author-facing metadata API calls (labels/links/tags/
358
+ // description/priority/properties from qualflare.label()/link()/tag()/
359
+ // description()/priority()/parameter(); attachments from
360
+ // qualflare.attachment()/attachmentFromFile() — the latter carry only
361
+ // a `path`, resolved into inline content Node-side by the existing
362
+ // screenshot-attachment pipeline, see tasks.ts/attachment-reader.ts)
363
+ // from the FINAL attempt only, same "abandoned attempts are discarded"
364
+ // rule as `steps`.
365
+ labels: collapsed.labels,
366
+ links: collapsed.links,
367
+ tags: collapsed.tags,
368
+ description: collapsed.description,
369
+ priority: collapsed.priority,
370
+ properties: collapsed.properties,
371
+ attachments: collapsed.attachments
372
+ };
373
+ cy.task(TASK_REPORT_CASE, testCase, { log: false });
374
+ }
375
+
376
+ // src/browser/test-metadata-buffer.ts
377
+ var TestMetadataBuffer = class {
378
+ active = false;
379
+ labels = [];
380
+ links = [];
381
+ tags = [];
382
+ descriptionText;
383
+ priorityValue;
384
+ properties = {};
385
+ attachments = [];
386
+ manualSteps = [];
387
+ manualStepStack = [];
388
+ cappedWarnings = /* @__PURE__ */ new Set();
389
+ isActive() {
390
+ return this.active;
391
+ }
392
+ /** Starts a fresh attempt: clears all accumulated data and marks the
393
+ * buffer active. Called from `mocha-listener.ts`'s `runner.on('test', ...)`
394
+ * — fired once per attempt, including retries. */
395
+ reset() {
396
+ this.active = true;
397
+ this.labels = [];
398
+ this.links = [];
399
+ this.tags = [];
400
+ this.descriptionText = void 0;
401
+ this.priorityValue = void 0;
402
+ this.properties = {};
403
+ this.attachments = [];
404
+ this.manualSteps = [];
405
+ this.manualStepStack = [];
406
+ this.cappedWarnings.clear();
407
+ }
408
+ /** Ends the current attempt: returns everything accumulated (undefined for
409
+ * any field with nothing recorded, matching this codebase's
410
+ * omit-rather-than-empty-array convention elsewhere), marks the buffer
411
+ * inactive, and clears state. */
412
+ drain() {
413
+ const snapshot = {
414
+ labels: this.labels.length > 0 ? this.labels : void 0,
415
+ links: this.links.length > 0 ? this.links : void 0,
416
+ tags: this.tags.length > 0 ? this.tags : void 0,
417
+ description: this.descriptionText,
418
+ priority: this.priorityValue,
419
+ properties: Object.keys(this.properties).length > 0 ? this.properties : void 0,
420
+ attachments: this.attachments.length > 0 ? this.attachments : void 0,
421
+ manualSteps: this.manualSteps.length > 0 ? this.manualSteps : void 0
422
+ };
423
+ this.active = false;
424
+ this.labels = [];
425
+ this.links = [];
426
+ this.tags = [];
427
+ this.descriptionText = void 0;
428
+ this.priorityValue = void 0;
429
+ this.properties = {};
430
+ this.attachments = [];
431
+ this.manualSteps = [];
432
+ this.manualStepStack = [];
433
+ return snapshot;
434
+ }
435
+ warnInactive(fnName) {
436
+ logger.warn(
437
+ `qualflare.${fnName}() was called while no test is currently running (e.g. from a before/after hook, or at module-load time) \u2014 this call has no effect.`
438
+ );
439
+ }
440
+ /** Warns at most once per (buffer lifetime, cap-name) pair, so a loop that
441
+ * blows through a cap doesn't spam the log once per iteration. */
442
+ warnCappedOnce(capName, message) {
443
+ if (this.cappedWarnings.has(capName)) return;
444
+ this.cappedWarnings.add(capName);
445
+ logger.warn(message);
446
+ }
447
+ label(name, value) {
448
+ if (!this.active) return this.warnInactive("label");
449
+ if (this.labels.length >= MAX_LABELS_PER_CASE) {
450
+ return this.warnCappedOnce(
451
+ "labels",
452
+ `reached the ${MAX_LABELS_PER_CASE}-label-per-case cap \u2014 further qualflare.label() calls this test will be dropped.`
453
+ );
454
+ }
455
+ this.labels.push({ name, value });
456
+ }
457
+ link(url, opts) {
458
+ if (!this.active) return this.warnInactive("link");
459
+ if (this.links.length >= MAX_LINKS_PER_CASE) {
460
+ return this.warnCappedOnce(
461
+ "links",
462
+ `reached the ${MAX_LINKS_PER_CASE}-link-per-case cap \u2014 further qualflare.link() calls this test will be dropped.`
463
+ );
464
+ }
465
+ const link = { type: opts?.type ?? "custom", url };
466
+ if (opts?.name) link.name = opts.name;
467
+ this.links.push(link);
468
+ }
469
+ /** `Case.tags` is a REJECT-not-truncate field server-side (`max=64` items,
470
+ * `max=255` chars each) — unlike most other caps in this file, exceeding
471
+ * it 400s the whole launch, not just this one test's tags. Count is
472
+ * enforced the same warn-and-drop-excess way as `label()`/`link()`; an
473
+ * individual over-length tag is truncated (not dropped) instead, since a
474
+ * single long string is a shortenable formatting issue, not a structural
475
+ * one — matching how other length-only limits elsewhere in this codebase
476
+ * (e.g. `console-props.ts`'s `truncate()`) are handled. */
477
+ tag(...tags) {
478
+ if (!this.active) return this.warnInactive("tag");
479
+ for (const rawTag of tags) {
480
+ if (this.tags.length >= MAX_TAGS_PER_CASE) {
481
+ this.warnCappedOnce(
482
+ "tags",
483
+ `reached the ${MAX_TAGS_PER_CASE}-tag-per-case cap \u2014 further qualflare.tag() calls this test will be dropped.`
484
+ );
485
+ return;
486
+ }
487
+ let tag = rawTag;
488
+ if (tag.length > MAX_TAG_LENGTH) {
489
+ this.warnCappedOnce("tag-length", `a tag exceeded ${MAX_TAG_LENGTH} characters and was truncated.`);
490
+ tag = tag.slice(0, MAX_TAG_LENGTH);
491
+ }
492
+ this.tags.push(tag);
493
+ }
494
+ }
495
+ /** Last-write-wins if called more than once in one test — simpler than
496
+ * concatenation, and matches how most comparable metadata APIs (a single
497
+ * "set the description" call, not an accumulating log) behave. */
498
+ description(text) {
499
+ if (!this.active) return this.warnInactive("description");
500
+ this.descriptionText = text;
501
+ }
502
+ /** Last-write-wins if called more than once in one test, same as
503
+ * `description()`. Server-side, an unrecognized value is normalized/
504
+ * dropped rather than rejecting the request (`shared/types.ts`), so —
505
+ * like `link()`'s `type` option — this does no runtime validation of
506
+ * its own and simply takes the caller's word for it. */
507
+ priority(value) {
508
+ if (!this.active) return this.warnInactive("priority");
509
+ this.priorityValue = value;
510
+ }
511
+ /**
512
+ * Placement decision (the wire contract has no top-level `Parameter[]` on
513
+ * `Case` — only `Step.parameters` exists, see `shared/types.ts`): a call
514
+ * made while a `qualflare.step()` is currently open attaches to that
515
+ * step's `parameters[]` (capped at `MAX_PARAMETERS_PER_STEP`, shared with
516
+ * whatever `consoleProps`-derived parameters that step might separately
517
+ * accumulate — no, actually a manual step never has consoleProps, only
518
+ * auto-captured command-log steps do, so no sharing/collision is possible
519
+ * here). A call made OUTSIDE any step becomes a `Case.properties` entry
520
+ * instead, since that's the only test-level key/value bag the wire
521
+ * contract offers. `opts.masked` has no analog on `properties` (a plain
522
+ * `Record<string,string>`) and is silently ignored in that branch — real,
523
+ * documented limitation, not a bug: masking only has meaning for a
524
+ * step-level `Parameter`.
525
+ */
526
+ parameter(name, value, opts) {
527
+ if (!this.active) return this.warnInactive("parameter");
528
+ const openStepIndex = this.manualStepStack[this.manualStepStack.length - 1];
529
+ if (openStepIndex !== void 0) {
530
+ const step = this.manualSteps[openStepIndex];
531
+ if (!step) return;
532
+ step.parameters ??= [];
533
+ if (step.parameters.length >= MAX_PARAMETERS_PER_STEP) {
534
+ return this.warnCappedOnce(
535
+ "step-parameters",
536
+ `reached the ${MAX_PARAMETERS_PER_STEP}-parameter-per-step cap \u2014 further qualflare.parameter() calls within this step will be dropped.`
537
+ );
538
+ }
539
+ const parameter = { name };
540
+ if (value !== void 0) parameter.value = value;
541
+ if (opts?.masked) parameter.masked = true;
542
+ step.parameters.push(parameter);
543
+ return;
544
+ }
545
+ this.properties[name] = value ?? "";
546
+ }
547
+ /** `encoding` defaults to `'utf8'`: `content` is treated as plain text and
548
+ * base64-encoded before being placed into the wire format's
549
+ * always-base64 `Attachment.content` (see `shared/types.ts`). `'base64'`
550
+ * means the caller already has base64 text and it's passed through
551
+ * unencoded. Uses `TextEncoder`/`btoa` rather than Node's `Buffer` — this
552
+ * module runs in the browser (Cypress's actual browser context, not
553
+ * Node), where `Buffer` does not exist; both are standard Web APIs
554
+ * available in every browser Cypress supports. */
555
+ attachment(name, content, opts) {
556
+ if (!this.active) return this.warnInactive("attachment");
557
+ if (this.attachments.length >= MAX_ATTACHMENTS_PER_CASE) {
558
+ return this.warnCappedOnce(
559
+ "attachments",
560
+ `reached the ${MAX_ATTACHMENTS_PER_CASE}-attachment-per-case cap \u2014 further qualflare.attachment()/attachmentFromFile() calls this test will be dropped. Note this cap is enforced independently of any screenshots captured during the same test, which are merged in Node-side \u2014 the combined total is not currently capped.`
561
+ );
562
+ }
563
+ const base64 = opts?.encoding === "base64" ? content : utf8ToBase64(content);
564
+ const attachment = { name, content: base64 };
565
+ if (opts?.mimeType) attachment.mimeType = opts.mimeType;
566
+ this.attachments.push(attachment);
567
+ }
568
+ /** Mirrors the screenshot flow (`plugin/attachment-reader.ts`): the file's
569
+ * bytes are never read here (this runs browser-side, with no filesystem
570
+ * access) — a path-only `Attachment{name, path, mimeType}` is queued, and
571
+ * the EXISTING Node-side `resolveAttachments()` pipeline (already
572
+ * generic — it reads and size-guards any attachment that has a `path` but
573
+ * no `content`) resolves it once this test's `Case` reaches
574
+ * `TASK_REPORT_CASE`. No new Node-side code needed for this to work. */
575
+ attachmentFromFile(name, path, opts) {
576
+ if (!this.active) return this.warnInactive("attachmentFromFile");
577
+ if (this.attachments.length >= MAX_ATTACHMENTS_PER_CASE) {
578
+ return this.warnCappedOnce(
579
+ "attachments",
580
+ `reached the ${MAX_ATTACHMENTS_PER_CASE}-attachment-per-case cap \u2014 further qualflare.attachment()/attachmentFromFile() calls this test will be dropped.`
581
+ );
582
+ }
583
+ const attachment = { name, path };
584
+ if (opts?.mimeType) attachment.mimeType = opts.mimeType;
585
+ this.attachments.push(attachment);
586
+ }
587
+ /** Starts a manually-declared step, nested under whatever manual step (if
588
+ * any) is currently open — an independent nesting stack from
589
+ * `CommandLogBuffer`'s command-log-derived parent/child tracking (see this
590
+ * file's header comment for why the two aren't unified). Returns an index
591
+ * to pass back to `endStep()`. Soft-capped at `MAX_STEPS_PER_TEST_ATTEMPT`,
592
+ * same limit `CommandLogBuffer` uses (the two counts aren't combined
593
+ * against a single shared budget — a deliberate, documented simplification;
594
+ * either buffer alone can reach its own cap independently). Returns
595
+ * `undefined` if inactive or capped — `endStep(undefined, ...)` is a
596
+ * documented no-op, so callers don't need to branch on this themselves. */
597
+ beginStep(name, now = Date.now()) {
598
+ if (!this.active) {
599
+ this.warnInactive("step");
600
+ return void 0;
601
+ }
602
+ if (this.manualSteps.length >= MAX_STEPS_PER_TEST_ATTEMPT) {
603
+ this.warnCappedOnce(
604
+ "manual-steps",
605
+ `reached the ${MAX_STEPS_PER_TEST_ATTEMPT}-step-per-test soft cap \u2014 further qualflare.step() calls this test attempt will still run their wrapped commands, but will not be recorded as steps.`
606
+ );
607
+ return void 0;
608
+ }
609
+ const index = this.manualSteps.length;
610
+ const parentIndex = this.manualStepStack[this.manualStepStack.length - 1];
611
+ const record = { name, status: "pending", startedAt: now };
612
+ if (parentIndex !== void 0) record.parentIndex = parentIndex;
613
+ this.manualSteps.push(record);
614
+ this.manualStepStack.push(index);
615
+ return index;
616
+ }
617
+ /** Finalizes a step started by `beginStep()`, recording its real
618
+ * wall-clock duration. A no-op if `index` is `undefined` (the documented
619
+ * signal from `beginStep()` that nothing was actually recorded —
620
+ * inactive buffer or step-count cap reached). If a step's wrapped
621
+ * commands fail/throw, this never runs — see `metadata-api.ts`'s
622
+ * `step()` doc comment — so `durationMs` stays `undefined` and
623
+ * `combineSteps` (`case-builder.ts`) falls back to 0 for that step. */
624
+ endStep(index, status, error, now = Date.now()) {
625
+ if (index === void 0) return;
626
+ const record = this.manualSteps[index];
627
+ if (record) {
628
+ record.status = status;
629
+ if (error) record.error = error;
630
+ record.durationMs = Math.max(0, now - record.startedAt);
631
+ }
632
+ const stackPos = this.manualStepStack.lastIndexOf(index);
633
+ if (stackPos !== -1) {
634
+ this.manualStepStack.splice(stackPos, 1);
635
+ }
636
+ }
637
+ };
638
+ function utf8ToBase64(text) {
639
+ const bytes = new TextEncoder().encode(text);
640
+ let binary = "";
641
+ for (const byte of bytes) {
642
+ binary += String.fromCharCode(byte);
643
+ }
644
+ return btoa(binary);
645
+ }
646
+ var fallbackBuffer;
647
+ function getDefaultMetadataBuffer() {
648
+ if (typeof Cypress === "undefined") {
649
+ fallbackBuffer ??= new TestMetadataBuffer();
650
+ return fallbackBuffer;
651
+ }
652
+ const target = Cypress;
653
+ target.__qualflareMetadataBuffer ??= new TestMetadataBuffer();
654
+ return target.__qualflareMetadataBuffer;
655
+ }
656
+
657
+ // src/browser/mocha-listener.ts
658
+ function retryIndex(test) {
659
+ return test._currentRetry ?? 0;
660
+ }
661
+ function formatError(err) {
662
+ if (err === void 0 || err === null) {
663
+ return void 0;
664
+ }
665
+ if (err instanceof Error) {
666
+ return err.stack ? `${err.message}
667
+ ${err.stack}` : err.message;
668
+ }
669
+ return String(err);
670
+ }
671
+ var MochaAttemptTracker = class {
672
+ entries = /* @__PURE__ */ new Map();
673
+ entryFor(key, test) {
674
+ let entry = this.entries.get(key);
675
+ if (!entry) {
676
+ entry = { test, attempts: [], willRetry: false };
677
+ this.entries.set(key, entry);
678
+ }
679
+ return entry;
680
+ }
681
+ /** Records one attempt, deduped against only the immediately-preceding
682
+ * record for THIS test (never against any other test's history). More
683
+ * than one Mocha/Cypress event can legitimately fire for the same
684
+ * physical attempt (verified empirically: `runner.on('fail', ...)` AND
685
+ * `runner.on('retry', ...)` both fire for one failing-and-retried
686
+ * attempt) — a second call with the same `retryIndex` for the same key
687
+ * is a no-op rather than double-recording. */
688
+ record(key, retryIdx, test, snapshot) {
689
+ const entry = this.entryFor(key, test);
690
+ if (entry.lastRecordedRetryIndex === retryIdx) {
691
+ return;
692
+ }
693
+ entry.lastRecordedRetryIndex = retryIdx;
694
+ entry.attempts.push(snapshot);
695
+ }
696
+ /** Marks this test's most-recently-recorded attempt as non-final — more
697
+ * attempts are coming, so `takeIfFinal`/`drainOrphaned` must not flush
698
+ * yet. Only meaningful immediately after a `record()` call for the same
699
+ * attempt (Cypress's retry mechanism always records the failing attempt
700
+ * before emitting `'retry'`). A no-op if no entry exists yet for `key`. */
701
+ markWillRetry(key) {
702
+ const entry = this.entries.get(key);
703
+ if (entry) {
704
+ entry.willRetry = true;
705
+ }
706
+ }
707
+ /** The `afterEach`-driven path for the CURRENTLY-ending test: returns its
708
+ * attempts and forgets them — UNLESS `markWillRetry` was called for the
709
+ * attempt just recorded, in which case this consumes that flag and
710
+ * returns `undefined`, leaving the entry in place so the next attempt
711
+ * appends to the same array instead of starting fresh. Returns
712
+ * `undefined` (nothing to do) if no entry exists for `key` at all. */
713
+ takeIfFinal(key) {
714
+ const entry = this.entries.get(key);
715
+ if (!entry) {
716
+ return void 0;
717
+ }
718
+ if (entry.willRetry) {
719
+ entry.willRetry = false;
720
+ return void 0;
721
+ }
722
+ this.entries.delete(key);
723
+ return entry.attempts;
724
+ }
725
+ /**
726
+ * Sweeps every OTHER finalized-but-never-collected entry (excluding
727
+ * `excludeKey`, the test this `afterEach` firing is already handling via
728
+ * `takeIfFinal`), skipping anything still mid-retry. This exists for
729
+ * exactly one real scenario: a statically-skipped test (`it.skip(...)` or
730
+ * an inherited `.skip`) fires `'pending'` and gets `record()`ed, but
731
+ * Mocha's skip path never runs `afterEach` for it at all — so nothing
732
+ * else will ever collect it.
733
+ *
734
+ * The obvious-looking alternative — flush a skipped test immediately,
735
+ * right in the `'pending'` handler — was tried and is UNSAFE: verified
736
+ * empirically (a real `cypress run` against a fixture spec containing
737
+ * `it.skip(...)`) that calling `cy.task()` synchronously from that
738
+ * handler doesn't just silently no-op, it HANGS the entire run
739
+ * indefinitely (Cypress's command-queue machinery for a test whose body
740
+ * never executes at all appears to never reach a state where a
741
+ * newly-enqueued command can be processed). `drainOrphaned` instead waits
742
+ * until the NEXT real `afterEach` fires (a call site independently
743
+ * proven safe for `cy.task()`, both here and by every other flush in this
744
+ * file) and sweeps anything left over at that point.
745
+ *
746
+ * Residual, honestly-documented limitation: if EVERY test in a spec file
747
+ * is statically skipped, no real `afterEach` ever fires at all in that
748
+ * spec, so a lingering skip entry is never swept — the same outcome as
749
+ * before this fix, for that one narrower sub-case. The common case (at
750
+ * least one non-skipped test in the spec) is fully fixed.
751
+ */
752
+ drainOrphaned(excludeKey) {
753
+ const drained = [];
754
+ for (const [key, entry] of this.entries) {
755
+ if (key === excludeKey || entry.willRetry) {
756
+ continue;
757
+ }
758
+ drained.push({ test: entry.test, attempts: entry.attempts });
759
+ this.entries.delete(key);
760
+ }
761
+ return drained;
762
+ }
763
+ };
764
+ function registerMochaListener() {
765
+ const runner = Cypress.mocha.getRunner();
766
+ const tracker = new MochaAttemptTracker();
767
+ const stepBuffer = new CommandLogBuffer();
768
+ registerCommandLogListener(stepBuffer);
769
+ function buildSnapshot(test, status, err) {
770
+ const steps = stepBuffer.drain();
771
+ const metadata = getDefaultMetadataBuffer().drain();
772
+ return {
773
+ status,
774
+ duration: test.duration ?? 0,
775
+ error: formatError(err),
776
+ steps: steps.length > 0 ? steps : void 0,
777
+ ...metadata
778
+ };
779
+ }
780
+ runner.on("test", () => {
781
+ stepBuffer.reset();
782
+ getDefaultMetadataBuffer().reset();
783
+ });
784
+ runner.on("pass", (test) => {
785
+ tracker.record(test.fullTitle(), retryIndex(test), test, buildSnapshot(test, "passed"));
786
+ });
787
+ runner.on("fail", (test, err) => {
788
+ const runnable = test;
789
+ if (runnable.type === "test") {
790
+ tracker.record(test.fullTitle(), retryIndex(test), test, buildSnapshot(test, "failed", err));
791
+ return;
792
+ }
793
+ const guardedTest = runnable.ctx?.currentTest;
794
+ const isBeforeEachHook = (runnable.originalTitle ?? runnable.title ?? "").startsWith('"before each" hook');
795
+ if (guardedTest && isBeforeEachHook) {
796
+ tracker.record(guardedTest.fullTitle(), retryIndex(guardedTest), guardedTest, buildSnapshot(guardedTest, "failed", err));
797
+ }
798
+ });
799
+ runner.on("retry", (test, err) => {
800
+ const status = err ? "failed" : "passed";
801
+ tracker.record(test.fullTitle(), retryIndex(test), test, buildSnapshot(test, status, err));
802
+ tracker.markWillRetry(test.fullTitle());
803
+ });
804
+ runner.on("pending", (test) => {
805
+ tracker.record(test.fullTitle(), retryIndex(test), test, buildSnapshot(test, "skipped"));
806
+ });
807
+ Cypress.on("fail", (err, runnable) => {
808
+ if (runnable.type !== "test") {
809
+ throw err;
810
+ }
811
+ const test = runnable;
812
+ tracker.record(test.fullTitle(), retryIndex(test), test, buildSnapshot(test, "failed", err));
813
+ throw err;
814
+ });
815
+ afterEach(function flushCurrentTest() {
816
+ const test = this.currentTest;
817
+ if (!test) {
818
+ return;
819
+ }
820
+ const key = test.fullTitle();
821
+ const attempts = tracker.takeIfFinal(key);
822
+ if (attempts) {
823
+ flushCase(test, attempts);
824
+ }
825
+ for (const orphan of tracker.drainOrphaned(key)) {
826
+ flushCase(orphan.test, orphan.attempts);
827
+ }
828
+ });
829
+ }
830
+
831
+ // src/browser/test-phase-signal.ts
832
+ function registerTestPhaseSignal() {
833
+ let signaled = false;
834
+ beforeEach(function qualflareMarkTestPhaseStarted() {
835
+ if (signaled) {
836
+ return;
837
+ }
838
+ signaled = true;
839
+ cy.task(TASK_MARK_TEST_PHASE_STARTED, null, { log: false });
840
+ });
841
+ }
842
+
843
+ // src/browser/index.ts
844
+ initializeBrowserIntegration(() => {
845
+ registerMochaListener();
846
+ registerTestPhaseSignal();
847
+ });
848
+
849
+ // src/browser/metadata-api.ts
850
+ var qualflare = {
851
+ label(name, value) {
852
+ getDefaultMetadataBuffer().label(name, value);
853
+ },
854
+ link(url, opts) {
855
+ getDefaultMetadataBuffer().link(url, opts);
856
+ },
857
+ tag(...tags) {
858
+ getDefaultMetadataBuffer().tag(...tags);
859
+ },
860
+ description(text) {
861
+ getDefaultMetadataBuffer().description(text);
862
+ },
863
+ priority(value) {
864
+ getDefaultMetadataBuffer().priority(value);
865
+ },
866
+ parameter(name, value, opts) {
867
+ getDefaultMetadataBuffer().parameter(name, value, opts);
868
+ },
869
+ attachment(name, content, opts) {
870
+ getDefaultMetadataBuffer().attachment(name, content, opts);
871
+ },
872
+ attachmentFromFile(name, path, opts) {
873
+ getDefaultMetadataBuffer().attachmentFromFile(name, path, opts);
874
+ },
875
+ /**
876
+ * Wraps `fn` as a named, reportable step. Unlike every other function on
877
+ * this object, a step's start/end only has meaning relative to when its
878
+ * wrapped `cy.*()` commands actually EXECUTE — not when `step()` is
879
+ * textually called, which happens synchronously, before any queued
880
+ * command has run. So this interleaves `beginStep`/`endStep` INTO the
881
+ * command queue itself via `cy.then()`, at the exact point the wrapped
882
+ * commands run, rather than calling them eagerly.
883
+ *
884
+ * Verified directly against `node_modules/cypress/types/cypress.d.ts`
885
+ * (Cypress 14.5.4) before relying on any of this, rather than assumed:
886
+ * - `Cypress.isCy(obj: any): obj is Chainable` exists exactly as the
887
+ * plan's sketch expected.
888
+ * - `cy.wrap<S>(object: S, options?: Partial<Loggable & Timeoutable>)`
889
+ * accepts `{ log: false }` — this is properly typed.
890
+ * - `cy.then<S>(options: Partial<Timeoutable>, fn): ...` does NOT accept
891
+ * `Loggable` in its options type (only `wrap()` does) — passing
892
+ * `{ log: false }` to `.then()` is a genuine type error under this
893
+ * version's declarations. Cypress's own runtime DOES honor `log: false`
894
+ * on `.then()` in practice (a long-documented, widely-relied-upon
895
+ * behavior across the Cypress plugin ecosystem — every reporter that
896
+ * injects bookkeeping commands into the queue uses this), so `.then()`
897
+ * calls below pass it via a narrow, explicitly-commented type
898
+ * assertion rather than omitting it and cluttering every test's
899
+ * Command Log with reporter-internal entries.
900
+ */
901
+ step(name, fn) {
902
+ const stepIndex = getDefaultMetadataBuffer().beginStep(name);
903
+ let result;
904
+ try {
905
+ result = fn();
906
+ } catch (err) {
907
+ getDefaultMetadataBuffer().endStep(stepIndex, "failed", formatSyncStepError(err));
908
+ throw err;
909
+ }
910
+ const chained = Cypress.isCy(result) ? result : cy.wrap(result, { log: false });
911
+ return chained.then(withoutLog(), (value) => {
912
+ getDefaultMetadataBuffer().endStep(stepIndex, "passed");
913
+ return value;
914
+ });
915
+ }
916
+ };
917
+ function formatSyncStepError(err) {
918
+ if (err instanceof Error) {
919
+ return err.stack ? `${err.message}
920
+ ${err.stack}` : err.message;
921
+ }
922
+ return String(err);
923
+ }
924
+ function withoutLog() {
925
+ return { log: false };
926
+ }
927
+ // Annotate the CommonJS export names for ESM import in node:
928
+ 0 && (module.exports = {
929
+ qualflare
930
+ });
931
+ //# sourceMappingURL=index.cjs.map