executable-stories-formatters 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/dist/index.cjs CHANGED
@@ -35,6 +35,9 @@ __export(src_exports, {
35
35
  CucumberMessagesFormatter: () => CucumberMessagesFormatter,
36
36
  HtmlFormatter: () => HtmlFormatter,
37
37
  JUnitFormatter: () => JUnitFormatter,
38
+ MIN_FLAKINESS_SAMPLES: () => MIN_FLAKINESS_SAMPLES,
39
+ MIN_METRIC_SAMPLES: () => MIN_METRIC_SAMPLES,
40
+ MIN_PERF_SAMPLES: () => MIN_PERF_SAMPLES,
38
41
  MarkdownFormatter: () => MarkdownFormatter,
39
42
  ReportGenerator: () => ReportGenerator,
40
43
  STORY_META_KEY: () => STORY_META_KEY,
@@ -42,15 +45,21 @@ __export(src_exports, {
42
45
  adaptPlaywrightRun: () => adaptPlaywrightRun,
43
46
  adaptVitestRun: () => adaptVitestRun,
44
47
  assertValidRun: () => assertValidRun,
48
+ calculateFlakiness: () => calculateFlakiness,
49
+ calculateStability: () => calculateStability,
45
50
  canonicalizeRun: () => canonicalizeRun,
46
51
  clearVersionCache: () => clearVersionCache,
52
+ computeTestMetrics: () => computeTestMetrics,
47
53
  createReportGenerator: () => createReportGenerator,
48
54
  deriveStepResults: () => deriveStepResults,
49
55
  detectCI: () => detectCI4,
56
+ detectPerformanceTrend: () => detectPerformanceTrend,
50
57
  findGitDir: () => findGitDir,
51
- formatDuration: () => formatDuration,
58
+ formatDuration: () => formatDuration2,
52
59
  generateRunId: () => generateRunId,
53
60
  generateTestCaseId: () => generateTestCaseId,
61
+ hasSufficientHistory: () => hasSufficientHistory,
62
+ loadHistory: () => loadHistory,
54
63
  mergeStepResults: () => mergeStepResults,
55
64
  msToNanoseconds: () => msToNanoseconds,
56
65
  nanosecondsToMs: () => nanosecondsToMs,
@@ -66,8 +75,18 @@ __export(src_exports, {
66
75
  resolveAttachment: () => resolveAttachment,
67
76
  resolveAttachments: () => resolveAttachments,
68
77
  resolveTraceUrl: () => resolveTraceUrl,
78
+ saveHistory: () => saveHistory,
79
+ sendNotifications: () => sendNotifications,
80
+ sendSlackNotification: () => sendSlackNotification,
81
+ sendTeamsNotification: () => sendTeamsNotification,
82
+ sendWebhookNotification: () => sendWebhookNotification,
83
+ signBody: () => signBody,
69
84
  slugify: () => slugify,
85
+ stripAnsi: () => stripAnsi,
86
+ toCIInfo: () => toCIInfo,
87
+ toRawCIInfo: () => toRawCIInfo,
70
88
  tryGetActiveOtelContext: () => tryGetActiveOtelContext,
89
+ updateHistory: () => updateHistory,
71
90
  validateCanonicalRun: () => validateCanonicalRun
72
91
  });
73
92
  module.exports = __toCommonJS(src_exports);
@@ -970,17 +989,17 @@ function initCollapse() {
970
989
  }
971
990
 
972
991
  function expandAll() {
973
- document.querySelectorAll('.feature, .scenario').forEach(el => {
992
+ document.querySelectorAll('.feature, .scenario, .trace-view').forEach(el => {
974
993
  el.classList.remove('collapsed');
975
- const header = el.querySelector('.feature-header, .scenario-header');
994
+ const header = el.querySelector('.feature-header, .scenario-header, .trace-view-header');
976
995
  header?.setAttribute('aria-expanded', 'true');
977
996
  });
978
997
  }
979
998
 
980
999
  function collapseAll() {
981
- document.querySelectorAll('.feature, .scenario').forEach(el => {
1000
+ document.querySelectorAll('.feature, .scenario, .trace-view').forEach(el => {
982
1001
  el.classList.add('collapsed');
983
- const header = el.querySelector('.feature-header, .scenario-header');
1002
+ const header = el.querySelector('.feature-header, .scenario-header, .trace-view-header');
984
1003
  header?.setAttribute('aria-expanded', 'false');
985
1004
  });
986
1005
  }
@@ -2507,6 +2526,147 @@ body {
2507
2526
  background: none;
2508
2527
  }
2509
2528
 
2529
+ /* ============================================================================
2530
+ Trace View - OTel span waterfall
2531
+ ============================================================================ */
2532
+ .trace-view {
2533
+ margin-top: 0.75rem;
2534
+ border: 1px solid var(--border);
2535
+ border-radius: calc(var(--radius) - 2px);
2536
+ overflow: hidden;
2537
+ }
2538
+
2539
+ .trace-view-header {
2540
+ display: flex;
2541
+ align-items: center;
2542
+ gap: 0.5rem;
2543
+ padding: 0.5rem 0.75rem;
2544
+ background: var(--card);
2545
+ cursor: pointer;
2546
+ user-select: none;
2547
+ font-size: 0.8125rem;
2548
+ font-weight: 500;
2549
+ color: var(--foreground);
2550
+ transition: background-color 0.15s ease;
2551
+ }
2552
+
2553
+ .trace-view-header:hover {
2554
+ background: var(--accordion-header-hover);
2555
+ }
2556
+
2557
+ .trace-view-count {
2558
+ font-size: 0.6875rem;
2559
+ font-weight: 500;
2560
+ padding: 0.125rem 0.5rem;
2561
+ background: var(--success-light);
2562
+ color: var(--success);
2563
+ border: 1px solid var(--success-border);
2564
+ border-radius: 9999px;
2565
+ font-family: var(--font-mono);
2566
+ }
2567
+
2568
+ .trace-view-content {
2569
+ border-top: 1px solid var(--border);
2570
+ padding: 0.5rem 0.75rem;
2571
+ background: var(--accordion-content-bg);
2572
+ }
2573
+
2574
+ .trace-view.collapsed .trace-view-content {
2575
+ display: none;
2576
+ }
2577
+
2578
+ .trace-view-axis {
2579
+ display: flex;
2580
+ justify-content: space-between;
2581
+ font-size: 0.625rem;
2582
+ font-family: var(--font-mono);
2583
+ color: var(--muted-foreground);
2584
+ padding-bottom: 0.375rem;
2585
+ margin-bottom: 0.375rem;
2586
+ border-bottom: 1px solid var(--border);
2587
+ }
2588
+
2589
+ .trace-view-row {
2590
+ display: flex;
2591
+ align-items: center;
2592
+ gap: 0.5rem;
2593
+ padding: 0.1875rem 0;
2594
+ font-size: 0.75rem;
2595
+ }
2596
+
2597
+ .trace-view-name {
2598
+ width: 35%;
2599
+ flex-shrink: 0;
2600
+ display: flex;
2601
+ align-items: center;
2602
+ gap: 0.375rem;
2603
+ font-family: var(--font-mono);
2604
+ white-space: nowrap;
2605
+ overflow: hidden;
2606
+ text-overflow: ellipsis;
2607
+ color: var(--foreground);
2608
+ }
2609
+
2610
+ .trace-view-status-dot {
2611
+ width: 8px;
2612
+ height: 8px;
2613
+ border-radius: 50%;
2614
+ flex-shrink: 0;
2615
+ }
2616
+
2617
+ .trace-view-status-ok { background: var(--success); }
2618
+ .trace-view-status-error { background: var(--error); }
2619
+ .trace-view-status-unset { background: var(--muted-foreground); }
2620
+
2621
+ .trace-view-bar-container {
2622
+ flex: 1;
2623
+ position: relative;
2624
+ height: 1.25rem;
2625
+ background: var(--muted);
2626
+ border-radius: 2px;
2627
+ }
2628
+
2629
+ .trace-view-bar {
2630
+ position: absolute;
2631
+ top: 0;
2632
+ height: 100%;
2633
+ border-radius: 2px;
2634
+ min-width: 2px;
2635
+ display: flex;
2636
+ align-items: center;
2637
+ padding: 0 0.375rem;
2638
+ font-size: 0.625rem;
2639
+ font-family: var(--font-mono);
2640
+ color: white;
2641
+ white-space: nowrap;
2642
+ overflow: hidden;
2643
+ }
2644
+
2645
+ .trace-view-bar-ok { background: var(--success); }
2646
+ .trace-view-bar-error { background: var(--error); }
2647
+ .trace-view-bar-unset { background: var(--muted-foreground); }
2648
+
2649
+ @media print {
2650
+ .trace-view.collapsed .trace-view-content {
2651
+ display: block;
2652
+ }
2653
+ }
2654
+
2655
+ /* ============================================================================
2656
+ History metric badges
2657
+ ============================================================================ */
2658
+ .badge { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 0.75em; font-weight: 600; margin-left: 4px; vertical-align: middle; }
2659
+ .badge-grade { color: #fff; }
2660
+ .badge-grade-A { background: var(--success); }
2661
+ .badge-grade-B { background: #2196F3; }
2662
+ .badge-grade-C { background: #FF9800; }
2663
+ .badge-grade-D { background: #f44336; }
2664
+ .badge-grade-F { background: #9E0000; }
2665
+ .badge-flaky { background: #FF9800; color: #fff; }
2666
+ .badge-perf { font-size: 0.7em; }
2667
+ .badge-perf-improving { color: var(--success); }
2668
+ .badge-perf-regressing { color: var(--error); }
2669
+
2510
2670
  `;
2511
2671
 
2512
2672
  // src/formatters/html/renderers/status.ts
@@ -2540,7 +2700,22 @@ function renderMetaInfo(args, deps) {
2540
2700
  items.push(`<dt>Git:</dt><dd>${deps.escapeHtml(shortSha)}</dd>`);
2541
2701
  }
2542
2702
  if (args.ciName) {
2543
- items.push(`<dt>CI:</dt><dd>${deps.escapeHtml(args.ciName)}</dd>`);
2703
+ if (args.ciUrl && args.ciBuildNumber) {
2704
+ items.push(
2705
+ `<dt>CI:</dt><dd>${deps.escapeHtml(args.ciName)} <a href="${deps.escapeHtml(args.ciUrl)}">#${deps.escapeHtml(args.ciBuildNumber)}</a></dd>`
2706
+ );
2707
+ } else {
2708
+ items.push(`<dt>CI:</dt><dd>${deps.escapeHtml(args.ciName)}</dd>`);
2709
+ }
2710
+ }
2711
+ if (args.ciBranch) {
2712
+ items.push(`<dt>Branch:</dt><dd>${deps.escapeHtml(args.ciBranch)}</dd>`);
2713
+ }
2714
+ if (args.ciCommitSha) {
2715
+ const shortSha = args.ciCommitSha.length > 7 ? args.ciCommitSha.slice(0, 7) : args.ciCommitSha;
2716
+ items.push(
2717
+ `<dt>Commit:</dt><dd title="${deps.escapeHtml(args.ciCommitSha)}">${deps.escapeHtml(shortSha)}</dd>`
2718
+ );
2544
2719
  }
2545
2720
  return `<dl class="meta-info">${items.join("")}</dl>`;
2546
2721
  }
@@ -2791,6 +2966,14 @@ function highlightStepParams(text, deps) {
2791
2966
  return result;
2792
2967
  }
2793
2968
 
2969
+ // src/history/sample-policy.ts
2970
+ var MIN_PERF_SAMPLES = 6;
2971
+ var MIN_METRIC_SAMPLES = 5;
2972
+ var MIN_FLAKINESS_SAMPLES = 3;
2973
+ function hasSufficientHistory(entries, min) {
2974
+ return entries.length >= min;
2975
+ }
2976
+
2794
2977
  // src/formatters/html/renderers/scenario.ts
2795
2978
  function renderScenario(args, deps) {
2796
2979
  const { tc } = args;
@@ -2811,6 +2994,19 @@ function renderScenario(args, deps) {
2811
2994
  traceBadge = `<span class="tag trace-tag" title="${deps.escapeHtml(otelMeta.traceId)}">${deps.escapeHtml(shortId)}\u2026</span>`;
2812
2995
  }
2813
2996
  }
2997
+ let metricBadges = "";
2998
+ const { metrics } = args;
2999
+ if (metrics && metrics.sampleSize >= MIN_METRIC_SAMPLES) {
3000
+ const grade = metrics.stabilityGrade;
3001
+ metricBadges += `<span class="badge badge-grade badge-grade-${grade}" title="Pass rate: ${(metrics.passRate * 100).toFixed(0)}% (${metrics.sampleSize} runs)">${grade}</span>`;
3002
+ if (metrics.flakinessLevel !== "stable") {
3003
+ metricBadges += `<span class="badge badge-flaky">${metrics.flakinessLevel}</span>`;
3004
+ }
3005
+ if (metrics.performanceTrend !== "stable") {
3006
+ const arrow = metrics.performanceTrend === "improving" ? "\u2191" : "\u2193";
3007
+ metricBadges += `<span class="badge badge-perf badge-perf-${metrics.performanceTrend}">${arrow} ${metrics.performanceTrend}</span>`;
3008
+ }
3009
+ }
2814
3010
  const storyDocs = deps.renderDocs(tc.story.docs, "story-docs");
2815
3011
  const steps = deps.renderSteps(
2816
3012
  { steps: tc.story.steps, stepResults: tc.stepResults },
@@ -2831,6 +3027,10 @@ function renderScenario(args, deps) {
2831
3027
  embedScreenshots: deps.embedScreenshots
2832
3028
  }
2833
3029
  );
3030
+ const traceView = deps.renderTraceView(
3031
+ { spans: tc.story.otelSpans },
3032
+ { escapeHtml: deps.escapeHtml }
3033
+ );
2834
3034
  const collapsedClass = deps.startCollapsed ? " collapsed" : "";
2835
3035
  const ariaExpanded = !deps.startCollapsed;
2836
3036
  return `
@@ -2841,7 +3041,7 @@ function renderScenario(args, deps) {
2841
3041
  <span class="status-icon ${statusClass}">${statusIcon}</span>
2842
3042
  <span class="scenario-name">${deps.escapeHtml(tc.story.scenario)}</span>
2843
3043
  </div>
2844
- <div class="scenario-meta">${tags}${traceBadge}</div>
3044
+ <div class="scenario-meta">${tags}${traceBadge}${metricBadges}</div>
2845
3045
  </div>
2846
3046
  <span class="scenario-duration">${duration}</span>
2847
3047
  </div>
@@ -2850,6 +3050,193 @@ function renderScenario(args, deps) {
2850
3050
  ${steps}
2851
3051
  ${error}
2852
3052
  ${attachments}
3053
+ ${traceView}
3054
+ </div>
3055
+ </div>`;
3056
+ }
3057
+
3058
+ // src/formatters/html/renderers/trace-view.ts
3059
+ var VALID_STATUSES = /* @__PURE__ */ new Set(["ok", "error", "unset"]);
3060
+ var TOOLTIP_MAX_LENGTH = 4096;
3061
+ function safeStatus(status) {
3062
+ return VALID_STATUSES.has(status) ? status : "unset";
3063
+ }
3064
+ function formatDuration(ms) {
3065
+ if (ms >= 1e3) return `${(ms / 1e3).toFixed(2)}s`;
3066
+ return `${ms.toFixed(1)}ms`;
3067
+ }
3068
+ function clamp(value, min, max) {
3069
+ return Math.min(max, Math.max(min, value));
3070
+ }
3071
+ function normalizeSpans(spans) {
3072
+ const result = [];
3073
+ for (const span of spans) {
3074
+ if (!span || typeof span !== "object") continue;
3075
+ if (typeof span.spanId !== "string" || typeof span.name !== "string") continue;
3076
+ let startTimeMs;
3077
+ let durationMs;
3078
+ if (span.startTimeMs != null && span.durationMs != null) {
3079
+ startTimeMs = span.startTimeMs;
3080
+ durationMs = span.durationMs;
3081
+ } else if (span.startTimeUnixNano != null && span.endTimeUnixNano != null) {
3082
+ startTimeMs = span.startTimeUnixNano / 1e6;
3083
+ durationMs = (span.endTimeUnixNano - span.startTimeUnixNano) / 1e6;
3084
+ } else {
3085
+ continue;
3086
+ }
3087
+ durationMs = Math.max(0, durationMs);
3088
+ if (!isFinite(startTimeMs) || !isFinite(durationMs)) continue;
3089
+ result.push({
3090
+ spanId: span.spanId,
3091
+ parentSpanId: span.parentSpanId,
3092
+ name: span.name,
3093
+ startTimeMs,
3094
+ durationMs,
3095
+ status: safeStatus(span.status),
3096
+ statusMessage: span.statusMessage,
3097
+ attributes: span.attributes
3098
+ });
3099
+ }
3100
+ return result;
3101
+ }
3102
+ function buildTree(spans) {
3103
+ const byId = /* @__PURE__ */ new Map();
3104
+ for (const span of spans) {
3105
+ let key = span.spanId;
3106
+ if (byId.has(key)) {
3107
+ let suffix = 2;
3108
+ while (byId.has(`${span.spanId}__dup${suffix}`)) suffix++;
3109
+ key = `${span.spanId}__dup${suffix}`;
3110
+ }
3111
+ byId.set(key, { span: { ...span, spanId: key }, children: [], depth: 0 });
3112
+ }
3113
+ const roots = [];
3114
+ for (const node of byId.values()) {
3115
+ const parentId = node.span.parentSpanId;
3116
+ const parent = parentId ? byId.get(parentId) : void 0;
3117
+ if (parent && parent !== node) {
3118
+ parent.children.push(node);
3119
+ } else {
3120
+ roots.push(node);
3121
+ }
3122
+ }
3123
+ for (const node of byId.values()) {
3124
+ node.children.sort((a, b) => a.span.startTimeMs - b.span.startTimeMs);
3125
+ }
3126
+ roots.sort((a, b) => a.span.startTimeMs - b.span.startTimeMs);
3127
+ const visited = /* @__PURE__ */ new Set();
3128
+ function assignDepth(node, depth) {
3129
+ if (visited.has(node.span.spanId)) return;
3130
+ visited.add(node.span.spanId);
3131
+ node.depth = depth;
3132
+ for (const child of node.children) {
3133
+ assignDepth(child, depth + 1);
3134
+ }
3135
+ }
3136
+ for (const root of roots) {
3137
+ assignDepth(root, 0);
3138
+ }
3139
+ for (const node of byId.values()) {
3140
+ if (!visited.has(node.span.spanId)) {
3141
+ node.children = [];
3142
+ roots.push(node);
3143
+ assignDepth(node, 0);
3144
+ }
3145
+ }
3146
+ roots.sort((a, b) => a.span.startTimeMs - b.span.startTimeMs);
3147
+ return roots;
3148
+ }
3149
+ function flattenTree(roots) {
3150
+ const result = [];
3151
+ function walk(node) {
3152
+ result.push(node);
3153
+ for (const child of node.children) {
3154
+ walk(child);
3155
+ }
3156
+ }
3157
+ for (const root of roots) {
3158
+ walk(root);
3159
+ }
3160
+ return result;
3161
+ }
3162
+ function buildTooltip(span, escapeHtml2) {
3163
+ const parts = [];
3164
+ parts.push(`${span.name} (${formatDuration(span.durationMs)})`);
3165
+ if (span.statusMessage) {
3166
+ parts.push(`Status: ${span.statusMessage}`);
3167
+ }
3168
+ if (span.attributes) {
3169
+ const keys = Object.keys(span.attributes).sort();
3170
+ for (const key of keys) {
3171
+ const val = span.attributes[key];
3172
+ const formatted = Array.isArray(val) ? `[${val.map((v) => String(v)).join(", ")}]` : String(val);
3173
+ parts.push(`${key}=${formatted}`);
3174
+ }
3175
+ }
3176
+ let text = parts.join("\n");
3177
+ if (text.length > TOOLTIP_MAX_LENGTH) {
3178
+ text = text.slice(0, TOOLTIP_MAX_LENGTH - 3) + "...";
3179
+ }
3180
+ return escapeHtml2(text);
3181
+ }
3182
+ function renderTraceView(args, deps) {
3183
+ if (!args.spans || args.spans.length === 0) return "";
3184
+ const normalized = normalizeSpans(args.spans);
3185
+ if (normalized.length === 0) return "";
3186
+ const roots = buildTree(normalized);
3187
+ const flat = flattenTree(roots);
3188
+ let minStart = Infinity;
3189
+ let maxEnd = -Infinity;
3190
+ for (const node of flat) {
3191
+ const s = node.span.startTimeMs;
3192
+ const e = s + node.span.durationMs;
3193
+ if (s < minStart) minStart = s;
3194
+ if (e > maxEnd) maxEnd = e;
3195
+ }
3196
+ let totalDuration = maxEnd - minStart;
3197
+ if (totalDuration <= 0) totalDuration = 1;
3198
+ const rows = flat.map((node) => {
3199
+ const { span, depth } = node;
3200
+ const indent = depth * 16;
3201
+ const minWidth = 0.5;
3202
+ let spanLeft = clamp(
3203
+ (span.startTimeMs - minStart) / totalDuration * 100,
3204
+ 0,
3205
+ 100
3206
+ );
3207
+ if (spanLeft + minWidth > 100) {
3208
+ spanLeft = 100 - minWidth;
3209
+ }
3210
+ const spanWidth = clamp(
3211
+ span.durationMs / totalDuration * 100,
3212
+ minWidth,
3213
+ 100 - spanLeft
3214
+ );
3215
+ const tooltip = buildTooltip(span, deps.escapeHtml);
3216
+ const durationLabel = formatDuration(span.durationMs);
3217
+ return ` <div class="trace-view-row">
3218
+ <div class="trace-view-name" style="padding-left: ${indent}px" title="${deps.escapeHtml(span.name)}">
3219
+ <span class="trace-view-status-dot trace-view-status-${span.status}"></span>
3220
+ ${deps.escapeHtml(span.name)}
3221
+ </div>
3222
+ <div class="trace-view-bar-container">
3223
+ <div class="trace-view-bar trace-view-bar-${span.status}" style="left: ${spanLeft.toFixed(2)}%; width: ${spanWidth.toFixed(2)}%" title="${tooltip}">${durationLabel}</div>
3224
+ </div>
3225
+ </div>`;
3226
+ }).join("\n");
3227
+ const axisEnd = formatDuration(maxEnd - minStart);
3228
+ return `<div class="trace-view collapsed">
3229
+ <div class="trace-view-header" role="button" tabindex="0" aria-expanded="false">
3230
+ <span>Spans</span>
3231
+ <span class="trace-view-count">${flat.length}</span>
3232
+ <span class="chevron">&#9660;</span>
3233
+ </div>
3234
+ <div class="trace-view-content">
3235
+ <div class="trace-view-axis">
3236
+ <span>0ms</span>
3237
+ <span>${axisEnd}</span>
3238
+ </div>
3239
+ ${rows}
2853
3240
  </div>
2854
3241
  </div>`;
2855
3242
  }
@@ -2866,7 +3253,12 @@ function renderFeature(args, deps) {
2866
3253
  const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
2867
3254
  const collapsedClass = deps.startCollapsed ? " collapsed" : "";
2868
3255
  const ariaExpanded = !deps.startCollapsed;
2869
- const scenarios = testCases.map((tc) => deps.renderScenario({ tc }, deps.scenarioDeps)).join("\n");
3256
+ const scenarios = testCases.map(
3257
+ (tc) => deps.renderScenario(
3258
+ { tc, metrics: args.metricsMap?.get(tc.id) },
3259
+ deps.scenarioDeps
3260
+ )
3261
+ ).join("\n");
2870
3262
  return `
2871
3263
  <div class="feature${collapsedClass}">
2872
3264
  <div class="feature-header" role="button" tabindex="0" aria-expanded="${ariaExpanded}">
@@ -2911,7 +3303,11 @@ function buildBody(args, deps) {
2911
3303
  durationMs: run.durationMs,
2912
3304
  packageVersion: run.packageVersion,
2913
3305
  gitSha: run.gitSha,
2914
- ciName: run.ci?.name
3306
+ ciName: run.ci?.name,
3307
+ ciBranch: run.ci?.branch,
3308
+ ciUrl: run.ci?.url,
3309
+ ciCommitSha: run.ci?.commitSha,
3310
+ ciBuildNumber: run.ci?.buildNumber
2915
3311
  },
2916
3312
  deps.metaDeps
2917
3313
  )
@@ -2940,7 +3336,10 @@ function buildBody(args, deps) {
2940
3336
  const byFile = groupBy(run.testCases, (tc) => tc.sourceFile);
2941
3337
  for (const [file, testCases] of byFile) {
2942
3338
  parts.push(
2943
- deps.renderFeature({ file, testCases }, deps.featureDeps)
3339
+ deps.renderFeature(
3340
+ { file, testCases, metricsMap: args.metricsMap },
3341
+ deps.featureDeps
3342
+ )
2944
3343
  );
2945
3344
  }
2946
3345
  return parts.join("\n");
@@ -2986,6 +3385,7 @@ function createHtmlFormatter(options = {}) {
2986
3385
  renderDocs,
2987
3386
  renderErrorBox: (args, d) => renderErrorBox(args, d),
2988
3387
  renderAttachments: (args, d) => renderAttachments(args, d),
3388
+ renderTraceView: (args, d) => renderTraceView(args, d),
2989
3389
  embedScreenshots: opts.embedScreenshots
2990
3390
  };
2991
3391
  const featureDeps = {
@@ -5351,7 +5751,7 @@ function readBranchName(cwd = process.cwd()) {
5351
5751
  }
5352
5752
 
5353
5753
  // src/utils/duration.ts
5354
- function formatDuration(ms) {
5754
+ function formatDuration2(ms) {
5355
5755
  if (ms < 1e3) {
5356
5756
  return `${Math.round(ms)} ms`;
5357
5757
  }
@@ -5411,45 +5811,102 @@ function clearVersionCache() {
5411
5811
 
5412
5812
  // src/utils/ci-detect.ts
5413
5813
  function detectCI4(env = process.env) {
5814
+ if (env.TF_BUILD === "True") {
5815
+ const branch = env.BUILD_SOURCEBRANCH?.replace(/^refs\/heads\//, "");
5816
+ const serverUri = env.SYSTEM_TEAMFOUNDATIONSERVERURI;
5817
+ const teamProject = env.SYSTEM_TEAMPROJECT;
5818
+ const buildId = env.BUILD_BUILDID;
5819
+ const url = serverUri && teamProject && buildId ? `${serverUri}${teamProject}/_build/results?buildId=${buildId}` : void 0;
5820
+ return {
5821
+ name: "azure",
5822
+ provider: "azure",
5823
+ buildNumber: buildId,
5824
+ url,
5825
+ branch,
5826
+ commitSha: env.BUILD_SOURCEVERSION,
5827
+ prNumber: env.SYSTEM_PULLREQUEST_PULLREQUESTID
5828
+ };
5829
+ }
5830
+ if (env.BUILDKITE === "true") {
5831
+ const prRaw = env.BUILDKITE_PULL_REQUEST;
5832
+ const prNumber = prRaw && prRaw !== "false" ? prRaw : void 0;
5833
+ return {
5834
+ name: "buildkite",
5835
+ provider: "buildkite",
5836
+ buildNumber: env.BUILDKITE_BUILD_NUMBER,
5837
+ url: env.BUILDKITE_BUILD_URL,
5838
+ branch: env.BUILDKITE_BRANCH,
5839
+ commitSha: env.BUILDKITE_COMMIT,
5840
+ prNumber
5841
+ };
5842
+ }
5414
5843
  if (env.GITHUB_ACTIONS === "true") {
5415
5844
  const url = 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;
5845
+ const branch = env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME;
5846
+ const prMatch = env.GITHUB_REF?.match(/^refs\/pull\/(\d+)\/(merge|head)$/);
5847
+ const prNumber = prMatch ? prMatch[1] : void 0;
5416
5848
  return {
5417
5849
  name: "github",
5850
+ provider: "github",
5418
5851
  buildNumber: env.GITHUB_RUN_NUMBER,
5419
- url
5852
+ url,
5853
+ branch,
5854
+ commitSha: env.GITHUB_SHA,
5855
+ prNumber
5856
+ };
5857
+ }
5858
+ if (env.GITLAB_CI === "true") {
5859
+ return {
5860
+ name: "gitlab",
5861
+ provider: "gitlab",
5862
+ buildNumber: env.CI_PIPELINE_IID,
5863
+ url: env.CI_PIPELINE_URL,
5864
+ branch: env.CI_COMMIT_REF_NAME,
5865
+ commitSha: env.CI_COMMIT_SHA,
5866
+ prNumber: env.CI_MERGE_REQUEST_IID
5420
5867
  };
5421
5868
  }
5422
5869
  if (env.CIRCLECI === "true") {
5870
+ const prUrl = env.CIRCLE_PULL_REQUEST;
5871
+ const prMatch = prUrl?.match(/\/(\d+)$/);
5872
+ const prNumber = prMatch ? prMatch[1] : void 0;
5423
5873
  return {
5424
5874
  name: "circleci",
5875
+ provider: "circleci",
5425
5876
  buildNumber: env.CIRCLE_BUILD_NUM,
5426
- url: env.CIRCLE_BUILD_URL
5877
+ url: env.CIRCLE_BUILD_URL,
5878
+ branch: env.CIRCLE_BRANCH,
5879
+ commitSha: env.CIRCLE_SHA1,
5880
+ prNumber
5427
5881
  };
5428
5882
  }
5429
5883
  if (env.JENKINS_URL !== void 0) {
5430
5884
  return {
5431
5885
  name: "jenkins",
5886
+ provider: "jenkins",
5432
5887
  buildNumber: env.BUILD_NUMBER,
5433
- url: env.BUILD_URL
5888
+ url: env.BUILD_URL,
5889
+ branch: env.GIT_BRANCH,
5890
+ commitSha: env.GIT_COMMIT
5434
5891
  };
5435
5892
  }
5436
5893
  if (env.TRAVIS === "true") {
5894
+ const prRaw = env.TRAVIS_PULL_REQUEST;
5895
+ const prNumber = prRaw && prRaw !== "false" ? prRaw : void 0;
5437
5896
  return {
5438
5897
  name: "travis",
5898
+ provider: "travis",
5439
5899
  buildNumber: env.TRAVIS_BUILD_NUMBER,
5440
- url: env.TRAVIS_BUILD_WEB_URL
5441
- };
5442
- }
5443
- if (env.GITLAB_CI === "true") {
5444
- return {
5445
- name: "gitlab",
5446
- buildNumber: env.CI_PIPELINE_IID,
5447
- url: env.CI_PIPELINE_URL
5900
+ url: env.TRAVIS_BUILD_WEB_URL,
5901
+ branch: env.TRAVIS_BRANCH,
5902
+ commitSha: env.TRAVIS_COMMIT,
5903
+ prNumber
5448
5904
  };
5449
5905
  }
5450
5906
  if (env.CI === "true") {
5451
5907
  return {
5452
- name: "ci"
5908
+ name: "ci",
5909
+ provider: "unknown"
5453
5910
  };
5454
5911
  }
5455
5912
  return void 0;
@@ -5481,6 +5938,731 @@ function resolveTraceUrl(template, traceId) {
5481
5938
  return template.replace(/\{traceId\}/g, traceId);
5482
5939
  }
5483
5940
 
5941
+ // src/notifiers/ansi-strip.ts
5942
+ function stripAnsi(text) {
5943
+ return text.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
5944
+ }
5945
+
5946
+ // src/notifiers/slack.ts
5947
+ function truncate(text, maxLen) {
5948
+ if (text.length <= maxLen) return text;
5949
+ return text.slice(0, maxLen - 3) + "...";
5950
+ }
5951
+ function formatDuration3(ms) {
5952
+ const seconds = ms / 1e3;
5953
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
5954
+ const minutes = Math.floor(seconds / 60);
5955
+ const remainingSeconds = seconds % 60;
5956
+ return `${minutes}m ${remainingSeconds.toFixed(0)}s`;
5957
+ }
5958
+ function buildSlackPayload(summary, maxFailedTests) {
5959
+ const allPassed = summary.failed === 0;
5960
+ const emoji = allPassed ? ":white_check_mark:" : ":x:";
5961
+ const statusText = allPassed ? "Passed" : "Failed";
5962
+ const blocks = [];
5963
+ blocks.push({
5964
+ type: "header",
5965
+ text: {
5966
+ type: "plain_text",
5967
+ text: `${emoji} Test Results: ${summary.passed} passed, ${summary.failed} failed`,
5968
+ emoji: true
5969
+ }
5970
+ });
5971
+ blocks.push({
5972
+ type: "section",
5973
+ fields: [
5974
+ { type: "mrkdwn", text: `*Total:* ${summary.total}` },
5975
+ { type: "mrkdwn", text: `*Passed:* ${summary.passed}` },
5976
+ { type: "mrkdwn", text: `*Failed:* ${summary.failed}` },
5977
+ { type: "mrkdwn", text: `*Skipped:* ${summary.skipped}` },
5978
+ { type: "mrkdwn", text: `*Duration:* ${formatDuration3(summary.durationMs)}` },
5979
+ { type: "mrkdwn", text: `*Status:* ${statusText}` }
5980
+ ]
5981
+ });
5982
+ if (summary.failedTests.length > 0) {
5983
+ const displayedTests = summary.failedTests.slice(0, maxFailedTests);
5984
+ const lines = displayedTests.map((t) => {
5985
+ const name = t.name;
5986
+ if (t.error) {
5987
+ const cleanError = truncate(stripAnsi(t.error), 500);
5988
+ return `*${name}*
5989
+ \`\`\`${cleanError}\`\`\``;
5990
+ }
5991
+ return `*${name}*`;
5992
+ });
5993
+ let text = lines.join("\n\n");
5994
+ if (summary.failedTests.length > maxFailedTests) {
5995
+ text += `
5996
+
5997
+ _...and ${summary.failedTests.length - maxFailedTests} more_`;
5998
+ }
5999
+ blocks.push({
6000
+ type: "section",
6001
+ text: {
6002
+ type: "mrkdwn",
6003
+ text
6004
+ }
6005
+ });
6006
+ }
6007
+ if (summary.ci) {
6008
+ const elements = [];
6009
+ if (summary.ci.displayName) {
6010
+ elements.push({ type: "mrkdwn", text: `*CI:* ${summary.ci.displayName}` });
6011
+ }
6012
+ if (summary.ci.branch) {
6013
+ elements.push({ type: "mrkdwn", text: `*Branch:* ${summary.ci.branch}` });
6014
+ }
6015
+ if (summary.ci.commitSha) {
6016
+ elements.push({ type: "mrkdwn", text: `*Commit:* ${summary.ci.commitSha.slice(0, 7)}` });
6017
+ }
6018
+ if (summary.ci.buildNumber) {
6019
+ elements.push({ type: "mrkdwn", text: `*Build:* #${summary.ci.buildNumber}` });
6020
+ }
6021
+ if (elements.length > 0) {
6022
+ blocks.push({
6023
+ type: "context",
6024
+ elements
6025
+ });
6026
+ }
6027
+ }
6028
+ if (summary.reportUrl) {
6029
+ blocks.push({
6030
+ type: "actions",
6031
+ elements: [
6032
+ {
6033
+ type: "button",
6034
+ text: {
6035
+ type: "plain_text",
6036
+ text: "View Report",
6037
+ emoji: true
6038
+ },
6039
+ url: summary.reportUrl,
6040
+ action_id: "view_report"
6041
+ }
6042
+ ]
6043
+ });
6044
+ }
6045
+ return { blocks };
6046
+ }
6047
+ async function sendSlackNotification(args, deps) {
6048
+ const { summary, webhookUrl, maxFailedTests = 5 } = args;
6049
+ const { fetch, logger } = deps;
6050
+ const payload = buildSlackPayload(summary, maxFailedTests);
6051
+ try {
6052
+ const response = await fetch(webhookUrl, {
6053
+ method: "POST",
6054
+ headers: { "Content-Type": "application/json" },
6055
+ body: JSON.stringify(payload)
6056
+ });
6057
+ if (!response.ok) {
6058
+ const requestId = response.headers.get("x-request-id") ?? void 0;
6059
+ let bodyText = "";
6060
+ try {
6061
+ bodyText = await response.text();
6062
+ } catch {
6063
+ }
6064
+ const truncatedBody = truncate(bodyText, 200);
6065
+ const idPart = requestId ? ` x-request-id=${requestId}` : "";
6066
+ const errorMsg = `Slack notifier failed: HTTP ${response.status}${idPart} ${truncatedBody}`;
6067
+ logger.warn(errorMsg);
6068
+ return { ok: false, error: errorMsg };
6069
+ }
6070
+ return { ok: true };
6071
+ } catch (err) {
6072
+ const msg = err instanceof Error ? err.message : String(err);
6073
+ const errorMsg = `Slack notifier failed: ${msg}`;
6074
+ logger.warn(errorMsg);
6075
+ return { ok: false, error: errorMsg };
6076
+ }
6077
+ }
6078
+
6079
+ // src/notifiers/teams.ts
6080
+ function truncate2(text, maxLen) {
6081
+ if (text.length <= maxLen) return text;
6082
+ return text.slice(0, maxLen - 3) + "...";
6083
+ }
6084
+ function formatDuration4(ms) {
6085
+ const seconds = ms / 1e3;
6086
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
6087
+ const minutes = Math.floor(seconds / 60);
6088
+ const remainingSeconds = seconds % 60;
6089
+ return `${minutes}m ${remainingSeconds.toFixed(0)}s`;
6090
+ }
6091
+ function buildTeamsPayload(summary, maxFailedTests) {
6092
+ const allPassed = summary.failed === 0;
6093
+ const statusEmoji = allPassed ? "\u2705" : "\u274C";
6094
+ const statusColor = allPassed ? "good" : "attention";
6095
+ const bodyItems = [];
6096
+ bodyItems.push({
6097
+ type: "TextBlock",
6098
+ size: "Large",
6099
+ weight: "Bolder",
6100
+ text: `${statusEmoji} Test Results`,
6101
+ color: statusColor
6102
+ });
6103
+ bodyItems.push({
6104
+ type: "FactSet",
6105
+ facts: [
6106
+ { title: "Total", value: String(summary.total) },
6107
+ { title: "Passed", value: String(summary.passed) },
6108
+ { title: "Failed", value: String(summary.failed) },
6109
+ { title: "Skipped", value: String(summary.skipped) },
6110
+ { title: "Duration", value: formatDuration4(summary.durationMs) }
6111
+ ]
6112
+ });
6113
+ if (summary.failedTests.length > 0) {
6114
+ const displayedTests = summary.failedTests.slice(0, maxFailedTests);
6115
+ const failedItems = [
6116
+ {
6117
+ type: "TextBlock",
6118
+ text: "Failed Tests",
6119
+ weight: "Bolder",
6120
+ spacing: "Medium"
6121
+ }
6122
+ ];
6123
+ for (const t of displayedTests) {
6124
+ failedItems.push({
6125
+ type: "TextBlock",
6126
+ text: `**${t.name}**`,
6127
+ wrap: true
6128
+ });
6129
+ if (t.error) {
6130
+ const cleanError = truncate2(stripAnsi(t.error), 500);
6131
+ failedItems.push({
6132
+ type: "TextBlock",
6133
+ text: cleanError,
6134
+ wrap: true,
6135
+ fontType: "Monospace",
6136
+ size: "Small",
6137
+ color: "Attention"
6138
+ });
6139
+ }
6140
+ }
6141
+ if (summary.failedTests.length > maxFailedTests) {
6142
+ failedItems.push({
6143
+ type: "TextBlock",
6144
+ text: `...and ${summary.failedTests.length - maxFailedTests} more`,
6145
+ isSubtle: true,
6146
+ spacing: "Small"
6147
+ });
6148
+ }
6149
+ bodyItems.push({
6150
+ type: "Container",
6151
+ items: failedItems
6152
+ });
6153
+ }
6154
+ if (summary.ci) {
6155
+ const ciFacts = [];
6156
+ if (summary.ci.displayName) {
6157
+ ciFacts.push({ title: "CI", value: summary.ci.displayName });
6158
+ }
6159
+ if (summary.ci.branch) {
6160
+ ciFacts.push({ title: "Branch", value: summary.ci.branch });
6161
+ }
6162
+ if (summary.ci.commitSha) {
6163
+ ciFacts.push({ title: "Commit", value: summary.ci.commitSha.slice(0, 7) });
6164
+ }
6165
+ if (summary.ci.buildNumber) {
6166
+ ciFacts.push({ title: "Build", value: `#${summary.ci.buildNumber}` });
6167
+ }
6168
+ if (ciFacts.length > 0) {
6169
+ bodyItems.push({
6170
+ type: "FactSet",
6171
+ facts: ciFacts,
6172
+ separator: true
6173
+ });
6174
+ }
6175
+ }
6176
+ const card = {
6177
+ type: "AdaptiveCard",
6178
+ $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
6179
+ version: "1.4",
6180
+ body: bodyItems
6181
+ };
6182
+ if (summary.reportUrl) {
6183
+ card.actions = [
6184
+ {
6185
+ type: "Action.OpenUrl",
6186
+ title: "View Report",
6187
+ url: summary.reportUrl
6188
+ }
6189
+ ];
6190
+ }
6191
+ return {
6192
+ type: "message",
6193
+ attachments: [
6194
+ {
6195
+ contentType: "application/vnd.microsoft.card.adaptive",
6196
+ content: card
6197
+ }
6198
+ ]
6199
+ };
6200
+ }
6201
+ async function sendTeamsNotification(args, deps) {
6202
+ const { summary, webhookUrl, maxFailedTests = 5 } = args;
6203
+ const { fetch, logger } = deps;
6204
+ const payload = buildTeamsPayload(summary, maxFailedTests);
6205
+ try {
6206
+ const response = await fetch(webhookUrl, {
6207
+ method: "POST",
6208
+ headers: { "Content-Type": "application/json" },
6209
+ body: JSON.stringify(payload)
6210
+ });
6211
+ if (!response.ok) {
6212
+ const requestId = response.headers.get("x-request-id") ?? void 0;
6213
+ let bodyText = "";
6214
+ try {
6215
+ bodyText = await response.text();
6216
+ } catch {
6217
+ }
6218
+ const truncatedBody = truncate2(bodyText, 200);
6219
+ const idPart = requestId ? ` x-request-id=${requestId}` : "";
6220
+ const errorMsg = `Teams notifier failed: HTTP ${response.status}${idPart} ${truncatedBody}`;
6221
+ logger.warn(errorMsg);
6222
+ return { ok: false, error: errorMsg };
6223
+ }
6224
+ return { ok: true };
6225
+ } catch (err) {
6226
+ const msg = err instanceof Error ? err.message : String(err);
6227
+ const errorMsg = `Teams notifier failed: ${msg}`;
6228
+ logger.warn(errorMsg);
6229
+ return { ok: false, error: errorMsg };
6230
+ }
6231
+ }
6232
+
6233
+ // src/notifiers/hmac.ts
6234
+ var import_node_crypto3 = require("crypto");
6235
+ function signBody(args) {
6236
+ let input;
6237
+ let timestamp;
6238
+ if (args.includeTimestamp) {
6239
+ timestamp = args.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
6240
+ input = `${timestamp}.${args.body}`;
6241
+ } else {
6242
+ input = args.body;
6243
+ }
6244
+ const hex = (0, import_node_crypto3.createHmac)("sha256", args.secret).update(input, "utf8").digest("hex");
6245
+ return {
6246
+ signature: `sha256=${hex}`,
6247
+ timestamp
6248
+ };
6249
+ }
6250
+
6251
+ // src/notifiers/webhook.ts
6252
+ async function sendWebhookNotification(args, deps) {
6253
+ const { summary, options } = args;
6254
+ const { fetch, logger } = deps;
6255
+ const payload = {
6256
+ schemaVersion: 1,
6257
+ event: "test_run_finished",
6258
+ summary
6259
+ };
6260
+ const body = JSON.stringify(payload);
6261
+ const headers = { "Content-Type": "application/json" };
6262
+ if (options.headers) {
6263
+ for (const [key, value] of Object.entries(options.headers)) {
6264
+ headers[key] = value;
6265
+ }
6266
+ }
6267
+ if (options.signer) {
6268
+ const { secret, header, includeTimestamp, timestampHeader } = options.signer;
6269
+ const result = signBody({ body, secret, includeTimestamp });
6270
+ headers[header] = result.signature;
6271
+ if (result.timestamp) {
6272
+ headers[timestampHeader ?? "X-Timestamp"] = result.timestamp;
6273
+ }
6274
+ }
6275
+ try {
6276
+ const response = await fetch(options.url, {
6277
+ method: options.method ?? "POST",
6278
+ headers,
6279
+ body
6280
+ });
6281
+ if (!response.ok) {
6282
+ const requestId = response.headers.get("x-request-id") ?? void 0;
6283
+ let snippet = "";
6284
+ try {
6285
+ snippet = (await response.text()).slice(0, 200);
6286
+ } catch {
6287
+ }
6288
+ const idPart = requestId ? ` x-request-id=${requestId}` : "";
6289
+ const errorMsg = `webhook: HTTP ${response.status}${idPart} ${snippet}`;
6290
+ logger.warn(errorMsg);
6291
+ return { ok: false, error: errorMsg };
6292
+ }
6293
+ return { ok: true };
6294
+ } catch (err) {
6295
+ const msg = err instanceof Error ? err.message : String(err);
6296
+ const errorMsg = `webhook: ${msg}`;
6297
+ logger.warn(errorMsg);
6298
+ return { ok: false, error: errorMsg };
6299
+ }
6300
+ }
6301
+
6302
+ // src/notifiers/index.ts
6303
+ function buildSummary(run, reportUrl, toCIInfo2) {
6304
+ let passed = 0;
6305
+ let failed = 0;
6306
+ let skipped = 0;
6307
+ const failedTests = [];
6308
+ for (const tc of run.testCases) {
6309
+ switch (tc.status) {
6310
+ case "passed":
6311
+ passed++;
6312
+ break;
6313
+ case "failed":
6314
+ failed++;
6315
+ failedTests.push({
6316
+ testId: tc.id,
6317
+ name: tc.story.scenario,
6318
+ error: tc.errorMessage
6319
+ });
6320
+ break;
6321
+ case "skipped":
6322
+ case "pending":
6323
+ skipped++;
6324
+ break;
6325
+ }
6326
+ }
6327
+ let ci;
6328
+ if (run.ci) {
6329
+ ci = toCIInfo2(run.ci);
6330
+ }
6331
+ return {
6332
+ total: run.testCases.length,
6333
+ passed,
6334
+ failed,
6335
+ skipped,
6336
+ durationMs: run.durationMs,
6337
+ failedTests,
6338
+ ci,
6339
+ reportUrl
6340
+ };
6341
+ }
6342
+ function shouldNotify(condition, failedCount) {
6343
+ if (condition === "never") return false;
6344
+ if (condition === "on-failure" && failedCount === 0) return false;
6345
+ return true;
6346
+ }
6347
+ async function sendNotifications(args, deps) {
6348
+ const { run, notification } = args;
6349
+ const { logger, toCIInfo: toCIInfo2 } = deps;
6350
+ const env = deps.env ?? process.env;
6351
+ if (!deps.fetch) {
6352
+ logger.warn("notifications: skipped (fetch unavailable)");
6353
+ return;
6354
+ }
6355
+ const fetch = deps.fetch;
6356
+ const slackWebhookUrl = notification?.slackWebhookUrl ?? env.SLACK_WEBHOOK_URL;
6357
+ const teamsWebhookUrl = notification?.teamsWebhookUrl ?? env.TEAMS_WEBHOOK_URL;
6358
+ const globalCondition = notification?.condition ?? "on-failure";
6359
+ const reportUrl = notification?.reportUrl;
6360
+ const maxFailedTests = notification?.maxFailedTests ?? 5;
6361
+ const webhooks = notification?.webhooks ?? [];
6362
+ if (!slackWebhookUrl && !teamsWebhookUrl && webhooks.length === 0) {
6363
+ return;
6364
+ }
6365
+ const summary = buildSummary(run, reportUrl, toCIInfo2);
6366
+ const promises = [];
6367
+ if (slackWebhookUrl && shouldNotify(globalCondition, summary.failed)) {
6368
+ promises.push(
6369
+ sendSlackNotification(
6370
+ { summary, webhookUrl: slackWebhookUrl, maxFailedTests },
6371
+ { fetch, logger }
6372
+ ).then(() => void 0)
6373
+ );
6374
+ }
6375
+ if (teamsWebhookUrl && shouldNotify(globalCondition, summary.failed)) {
6376
+ promises.push(
6377
+ sendTeamsNotification(
6378
+ { summary, webhookUrl: teamsWebhookUrl, maxFailedTests },
6379
+ { fetch, logger }
6380
+ ).then(() => void 0)
6381
+ );
6382
+ }
6383
+ for (const webhook of webhooks) {
6384
+ const effectiveCondition = webhook.condition ?? globalCondition;
6385
+ if (!shouldNotify(effectiveCondition, summary.failed)) continue;
6386
+ promises.push(
6387
+ sendWebhookNotification(
6388
+ { summary, options: webhook, maxFailedTests },
6389
+ { fetch, logger }
6390
+ ).then(() => void 0)
6391
+ );
6392
+ }
6393
+ await Promise.allSettled(promises);
6394
+ }
6395
+
6396
+ // src/types/ci.ts
6397
+ var DISPLAY_NAMES = {
6398
+ github: "GitHub Actions",
6399
+ gitlab: "GitLab CI",
6400
+ circleci: "CircleCI",
6401
+ jenkins: "Jenkins",
6402
+ azure: "Azure DevOps",
6403
+ buildkite: "Buildkite",
6404
+ travis: "Travis CI",
6405
+ unknown: "CI"
6406
+ };
6407
+ var NAME_TO_PROVIDER = {
6408
+ github: "github",
6409
+ gitlab: "gitlab",
6410
+ circleci: "circleci",
6411
+ jenkins: "jenkins",
6412
+ azure: "azure",
6413
+ buildkite: "buildkite",
6414
+ travis: "travis",
6415
+ ci: "unknown"
6416
+ };
6417
+ function toCIInfo(raw) {
6418
+ if (!raw) return void 0;
6419
+ const provider = raw.provider ?? NAME_TO_PROVIDER[raw.name] ?? "unknown";
6420
+ return {
6421
+ provider,
6422
+ displayName: DISPLAY_NAMES[provider],
6423
+ url: raw.url,
6424
+ buildNumber: raw.buildNumber,
6425
+ branch: raw.branch,
6426
+ commitSha: raw.commitSha,
6427
+ prNumber: raw.prNumber
6428
+ };
6429
+ }
6430
+ function toRawCIInfo(ci) {
6431
+ if (!ci) return void 0;
6432
+ return {
6433
+ name: ci.provider === "unknown" ? "ci" : ci.provider,
6434
+ provider: ci.provider,
6435
+ url: ci.url,
6436
+ buildNumber: ci.buildNumber,
6437
+ branch: ci.branch,
6438
+ commitSha: ci.commitSha,
6439
+ prNumber: ci.prNumber
6440
+ };
6441
+ }
6442
+
6443
+ // src/history/history-store.ts
6444
+ function emptyStore() {
6445
+ return { version: 1, maxRuns: 10, tests: {}, lastUpdated: 0 };
6446
+ }
6447
+ function loadHistory(args, deps) {
6448
+ const content = deps.readFile(args.filePath);
6449
+ if (content === void 0) {
6450
+ return emptyStore();
6451
+ }
6452
+ let parsed;
6453
+ try {
6454
+ parsed = JSON.parse(content);
6455
+ } catch {
6456
+ deps.logger.warn(`Failed to parse history file: ${args.filePath}`);
6457
+ return emptyStore();
6458
+ }
6459
+ if (typeof parsed !== "object" || parsed === null || parsed.version !== 1) {
6460
+ deps.logger.warn(
6461
+ `Unknown history version in ${args.filePath}, expected version 1`
6462
+ );
6463
+ return emptyStore();
6464
+ }
6465
+ const obj = parsed;
6466
+ if (typeof obj.tests !== "object" || obj.tests === null || Array.isArray(obj.tests)) {
6467
+ deps.logger.warn(
6468
+ `Malformed history store in ${args.filePath}: tests must be a non-null object`
6469
+ );
6470
+ return emptyStore();
6471
+ }
6472
+ return parsed;
6473
+ }
6474
+ function saveHistory(args, deps) {
6475
+ deps.writeFile(args.filePath, JSON.stringify(args.store, null, 2));
6476
+ }
6477
+ function updateHistory(args) {
6478
+ const { store, run, maxRuns } = args;
6479
+ const newTests = { ...store.tests };
6480
+ for (const tc of run.testCases) {
6481
+ const entry = {
6482
+ runId: run.runId,
6483
+ timestamp: run.startedAtMs,
6484
+ status: tc.status,
6485
+ durationMs: tc.durationMs,
6486
+ ci: run.ci ? {
6487
+ provider: void 0,
6488
+ branch: run.ci.branch,
6489
+ commitSha: run.ci.commitSha
6490
+ } : void 0
6491
+ };
6492
+ const existing = newTests[tc.id];
6493
+ if (existing) {
6494
+ const updatedEntries = [...existing.entries, entry];
6495
+ const trimmed = updatedEntries.length > maxRuns ? updatedEntries.slice(updatedEntries.length - maxRuns) : updatedEntries;
6496
+ newTests[tc.id] = {
6497
+ ...existing,
6498
+ testName: tc.story.scenario,
6499
+ sourceFile: tc.sourceFile,
6500
+ sourceLine: tc.sourceLine,
6501
+ entries: trimmed
6502
+ };
6503
+ } else {
6504
+ newTests[tc.id] = {
6505
+ testId: tc.id,
6506
+ testName: tc.story.scenario,
6507
+ sourceFile: tc.sourceFile,
6508
+ sourceLine: tc.sourceLine,
6509
+ entries: [entry]
6510
+ };
6511
+ }
6512
+ }
6513
+ return {
6514
+ version: 1,
6515
+ maxRuns,
6516
+ tests: newTests,
6517
+ lastUpdated: Date.now()
6518
+ };
6519
+ }
6520
+
6521
+ // src/history/flakiness.ts
6522
+ function calculateFlakiness(args) {
6523
+ const { entries } = args;
6524
+ const countable = entries.filter(
6525
+ (e) => e.status === "passed" || e.status === "failed"
6526
+ );
6527
+ if (countable.length < MIN_FLAKINESS_SAMPLES) {
6528
+ return {
6529
+ flakinessLevel: "stable",
6530
+ flakinessScore: 0,
6531
+ failureRate: 0,
6532
+ longestPassStreak: countable.length,
6533
+ longestFailStreak: 0
6534
+ };
6535
+ }
6536
+ let transitions = 0;
6537
+ for (let i = 1; i < countable.length; i++) {
6538
+ if (countable[i].status !== countable[i - 1].status) {
6539
+ transitions++;
6540
+ }
6541
+ }
6542
+ const transitionScore = transitions / (countable.length - 1);
6543
+ const failures = countable.filter((e) => e.status === "failed").length;
6544
+ const failureRate = failures / countable.length;
6545
+ let longestPassStreak = 0;
6546
+ let longestFailStreak = 0;
6547
+ let currentPassStreak = 0;
6548
+ let currentFailStreak = 0;
6549
+ for (const e of countable) {
6550
+ if (e.status === "passed") {
6551
+ currentPassStreak++;
6552
+ currentFailStreak = 0;
6553
+ if (currentPassStreak > longestPassStreak) {
6554
+ longestPassStreak = currentPassStreak;
6555
+ }
6556
+ } else {
6557
+ currentFailStreak++;
6558
+ currentPassStreak = 0;
6559
+ if (currentFailStreak > longestFailStreak) {
6560
+ longestFailStreak = currentFailStreak;
6561
+ }
6562
+ }
6563
+ }
6564
+ let flakinessLevel;
6565
+ if (transitionScore > 0.5 || transitionScore > 0.3 && failureRate > 0.2) {
6566
+ flakinessLevel = "flaky";
6567
+ } else if (transitionScore > 0.2 || failureRate > 0.3) {
6568
+ flakinessLevel = "unstable";
6569
+ } else {
6570
+ flakinessLevel = "stable";
6571
+ }
6572
+ return {
6573
+ flakinessLevel,
6574
+ flakinessScore: transitionScore,
6575
+ failureRate,
6576
+ longestPassStreak,
6577
+ longestFailStreak
6578
+ };
6579
+ }
6580
+
6581
+ // src/history/performance.ts
6582
+ function detectPerformanceTrend(args) {
6583
+ const { entries } = args;
6584
+ const countable = entries.filter(
6585
+ (e) => e.status !== "skipped" && e.status !== "pending"
6586
+ );
6587
+ if (countable.length === 0) {
6588
+ return { trend: "stable", avgDurationMs: 0 };
6589
+ }
6590
+ const avgAll = countable.reduce((sum, e) => sum + e.durationMs, 0) / countable.length;
6591
+ if (countable.length < MIN_PERF_SAMPLES) {
6592
+ return { trend: "stable", avgDurationMs: avgAll };
6593
+ }
6594
+ const mid = Math.floor(countable.length / 2);
6595
+ const earlier = countable.slice(0, mid);
6596
+ const recent = countable.slice(mid);
6597
+ const earlierAvg = earlier.reduce((sum, e) => sum + e.durationMs, 0) / earlier.length;
6598
+ const recentAvg = recent.reduce((sum, e) => sum + e.durationMs, 0) / recent.length;
6599
+ let trend;
6600
+ if (earlierAvg === 0) {
6601
+ trend = "stable";
6602
+ } else {
6603
+ const change = (recentAvg - earlierAvg) / earlierAvg;
6604
+ if (change > 0.1) {
6605
+ trend = "regressing";
6606
+ } else if (change < -0.1) {
6607
+ trend = "improving";
6608
+ } else {
6609
+ trend = "stable";
6610
+ }
6611
+ }
6612
+ return { trend, avgDurationMs: avgAll };
6613
+ }
6614
+
6615
+ // src/history/stability.ts
6616
+ function calculateStability(args) {
6617
+ const { passRate, flakinessScore, longestPassStreak, sampleSize } = args;
6618
+ const inverseFlakiness = 1 - flakinessScore;
6619
+ const streakNorm = longestPassStreak / Math.min(sampleSize, 10);
6620
+ const score = passRate * 0.6 + inverseFlakiness * 0.3 + streakNorm * 0.1;
6621
+ if (score >= 0.95) return "A";
6622
+ if (score >= 0.85) return "B";
6623
+ if (score >= 0.7) return "C";
6624
+ if (score >= 0.5) return "D";
6625
+ return "F";
6626
+ }
6627
+
6628
+ // src/history/metrics.ts
6629
+ function computeTestMetrics(args) {
6630
+ const { testId, entries } = args;
6631
+ const flakiness = calculateFlakiness({ entries });
6632
+ const perf = detectPerformanceTrend({ entries });
6633
+ const countable = entries.filter(
6634
+ (e) => e.status === "passed" || e.status === "failed"
6635
+ );
6636
+ const passRate = countable.length > 0 ? countable.filter((e) => e.status === "passed").length / countable.length : 1;
6637
+ const stabilityGrade = calculateStability({
6638
+ passRate,
6639
+ flakinessScore: flakiness.flakinessScore,
6640
+ longestPassStreak: flakiness.longestPassStreak,
6641
+ sampleSize: entries.length
6642
+ });
6643
+ let consecutiveFailures = 0;
6644
+ for (let i = entries.length - 1; i >= 0; i--) {
6645
+ if (entries[i].status === "failed") {
6646
+ consecutiveFailures++;
6647
+ } else {
6648
+ break;
6649
+ }
6650
+ }
6651
+ return {
6652
+ testId,
6653
+ flakinessLevel: flakiness.flakinessLevel,
6654
+ flakinessScore: flakiness.flakinessScore,
6655
+ failureRate: flakiness.failureRate,
6656
+ stabilityGrade,
6657
+ performanceTrend: perf.trend,
6658
+ avgDurationMs: perf.avgDurationMs,
6659
+ passRate,
6660
+ longestPassStreak: flakiness.longestPassStreak,
6661
+ consecutiveFailures,
6662
+ sampleSize: entries.length
6663
+ };
6664
+ }
6665
+
5484
6666
  // src/index.ts
5485
6667
  var FORMAT_EXTENSIONS = {
5486
6668
  markdown: ".md",
@@ -5830,6 +7012,9 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
5830
7012
  CucumberMessagesFormatter,
5831
7013
  HtmlFormatter,
5832
7014
  JUnitFormatter,
7015
+ MIN_FLAKINESS_SAMPLES,
7016
+ MIN_METRIC_SAMPLES,
7017
+ MIN_PERF_SAMPLES,
5833
7018
  MarkdownFormatter,
5834
7019
  ReportGenerator,
5835
7020
  STORY_META_KEY,
@@ -5837,15 +7022,21 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
5837
7022
  adaptPlaywrightRun,
5838
7023
  adaptVitestRun,
5839
7024
  assertValidRun,
7025
+ calculateFlakiness,
7026
+ calculateStability,
5840
7027
  canonicalizeRun,
5841
7028
  clearVersionCache,
7029
+ computeTestMetrics,
5842
7030
  createReportGenerator,
5843
7031
  deriveStepResults,
5844
7032
  detectCI,
7033
+ detectPerformanceTrend,
5845
7034
  findGitDir,
5846
7035
  formatDuration,
5847
7036
  generateRunId,
5848
7037
  generateTestCaseId,
7038
+ hasSufficientHistory,
7039
+ loadHistory,
5849
7040
  mergeStepResults,
5850
7041
  msToNanoseconds,
5851
7042
  nanosecondsToMs,
@@ -5861,8 +7052,18 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
5861
7052
  resolveAttachment,
5862
7053
  resolveAttachments,
5863
7054
  resolveTraceUrl,
7055
+ saveHistory,
7056
+ sendNotifications,
7057
+ sendSlackNotification,
7058
+ sendTeamsNotification,
7059
+ sendWebhookNotification,
7060
+ signBody,
5864
7061
  slugify,
7062
+ stripAnsi,
7063
+ toCIInfo,
7064
+ toRawCIInfo,
5865
7065
  tryGetActiveOtelContext,
7066
+ updateHistory,
5866
7067
  validateCanonicalRun
5867
7068
  });
5868
7069
  //# sourceMappingURL=index.cjs.map