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/cli.js CHANGED
@@ -1101,17 +1101,17 @@ function initCollapse() {
1101
1101
  }
1102
1102
 
1103
1103
  function expandAll() {
1104
- document.querySelectorAll('.feature, .scenario').forEach(el => {
1104
+ document.querySelectorAll('.feature, .scenario, .trace-view').forEach(el => {
1105
1105
  el.classList.remove('collapsed');
1106
- const header = el.querySelector('.feature-header, .scenario-header');
1106
+ const header = el.querySelector('.feature-header, .scenario-header, .trace-view-header');
1107
1107
  header?.setAttribute('aria-expanded', 'true');
1108
1108
  });
1109
1109
  }
1110
1110
 
1111
1111
  function collapseAll() {
1112
- document.querySelectorAll('.feature, .scenario').forEach(el => {
1112
+ document.querySelectorAll('.feature, .scenario, .trace-view').forEach(el => {
1113
1113
  el.classList.add('collapsed');
1114
- const header = el.querySelector('.feature-header, .scenario-header');
1114
+ const header = el.querySelector('.feature-header, .scenario-header, .trace-view-header');
1115
1115
  header?.setAttribute('aria-expanded', 'false');
1116
1116
  });
1117
1117
  }
@@ -2638,6 +2638,147 @@ body {
2638
2638
  background: none;
2639
2639
  }
2640
2640
 
2641
+ /* ============================================================================
2642
+ Trace View - OTel span waterfall
2643
+ ============================================================================ */
2644
+ .trace-view {
2645
+ margin-top: 0.75rem;
2646
+ border: 1px solid var(--border);
2647
+ border-radius: calc(var(--radius) - 2px);
2648
+ overflow: hidden;
2649
+ }
2650
+
2651
+ .trace-view-header {
2652
+ display: flex;
2653
+ align-items: center;
2654
+ gap: 0.5rem;
2655
+ padding: 0.5rem 0.75rem;
2656
+ background: var(--card);
2657
+ cursor: pointer;
2658
+ user-select: none;
2659
+ font-size: 0.8125rem;
2660
+ font-weight: 500;
2661
+ color: var(--foreground);
2662
+ transition: background-color 0.15s ease;
2663
+ }
2664
+
2665
+ .trace-view-header:hover {
2666
+ background: var(--accordion-header-hover);
2667
+ }
2668
+
2669
+ .trace-view-count {
2670
+ font-size: 0.6875rem;
2671
+ font-weight: 500;
2672
+ padding: 0.125rem 0.5rem;
2673
+ background: var(--success-light);
2674
+ color: var(--success);
2675
+ border: 1px solid var(--success-border);
2676
+ border-radius: 9999px;
2677
+ font-family: var(--font-mono);
2678
+ }
2679
+
2680
+ .trace-view-content {
2681
+ border-top: 1px solid var(--border);
2682
+ padding: 0.5rem 0.75rem;
2683
+ background: var(--accordion-content-bg);
2684
+ }
2685
+
2686
+ .trace-view.collapsed .trace-view-content {
2687
+ display: none;
2688
+ }
2689
+
2690
+ .trace-view-axis {
2691
+ display: flex;
2692
+ justify-content: space-between;
2693
+ font-size: 0.625rem;
2694
+ font-family: var(--font-mono);
2695
+ color: var(--muted-foreground);
2696
+ padding-bottom: 0.375rem;
2697
+ margin-bottom: 0.375rem;
2698
+ border-bottom: 1px solid var(--border);
2699
+ }
2700
+
2701
+ .trace-view-row {
2702
+ display: flex;
2703
+ align-items: center;
2704
+ gap: 0.5rem;
2705
+ padding: 0.1875rem 0;
2706
+ font-size: 0.75rem;
2707
+ }
2708
+
2709
+ .trace-view-name {
2710
+ width: 35%;
2711
+ flex-shrink: 0;
2712
+ display: flex;
2713
+ align-items: center;
2714
+ gap: 0.375rem;
2715
+ font-family: var(--font-mono);
2716
+ white-space: nowrap;
2717
+ overflow: hidden;
2718
+ text-overflow: ellipsis;
2719
+ color: var(--foreground);
2720
+ }
2721
+
2722
+ .trace-view-status-dot {
2723
+ width: 8px;
2724
+ height: 8px;
2725
+ border-radius: 50%;
2726
+ flex-shrink: 0;
2727
+ }
2728
+
2729
+ .trace-view-status-ok { background: var(--success); }
2730
+ .trace-view-status-error { background: var(--error); }
2731
+ .trace-view-status-unset { background: var(--muted-foreground); }
2732
+
2733
+ .trace-view-bar-container {
2734
+ flex: 1;
2735
+ position: relative;
2736
+ height: 1.25rem;
2737
+ background: var(--muted);
2738
+ border-radius: 2px;
2739
+ }
2740
+
2741
+ .trace-view-bar {
2742
+ position: absolute;
2743
+ top: 0;
2744
+ height: 100%;
2745
+ border-radius: 2px;
2746
+ min-width: 2px;
2747
+ display: flex;
2748
+ align-items: center;
2749
+ padding: 0 0.375rem;
2750
+ font-size: 0.625rem;
2751
+ font-family: var(--font-mono);
2752
+ color: white;
2753
+ white-space: nowrap;
2754
+ overflow: hidden;
2755
+ }
2756
+
2757
+ .trace-view-bar-ok { background: var(--success); }
2758
+ .trace-view-bar-error { background: var(--error); }
2759
+ .trace-view-bar-unset { background: var(--muted-foreground); }
2760
+
2761
+ @media print {
2762
+ .trace-view.collapsed .trace-view-content {
2763
+ display: block;
2764
+ }
2765
+ }
2766
+
2767
+ /* ============================================================================
2768
+ History metric badges
2769
+ ============================================================================ */
2770
+ .badge { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 0.75em; font-weight: 600; margin-left: 4px; vertical-align: middle; }
2771
+ .badge-grade { color: #fff; }
2772
+ .badge-grade-A { background: var(--success); }
2773
+ .badge-grade-B { background: #2196F3; }
2774
+ .badge-grade-C { background: #FF9800; }
2775
+ .badge-grade-D { background: #f44336; }
2776
+ .badge-grade-F { background: #9E0000; }
2777
+ .badge-flaky { background: #FF9800; color: #fff; }
2778
+ .badge-perf { font-size: 0.7em; }
2779
+ .badge-perf-improving { color: var(--success); }
2780
+ .badge-perf-regressing { color: var(--error); }
2781
+
2641
2782
  `;
2642
2783
 
2643
2784
  // src/formatters/html/renderers/status.ts
@@ -2671,7 +2812,22 @@ function renderMetaInfo(args, deps) {
2671
2812
  items.push(`<dt>Git:</dt><dd>${deps.escapeHtml(shortSha)}</dd>`);
2672
2813
  }
2673
2814
  if (args.ciName) {
2674
- items.push(`<dt>CI:</dt><dd>${deps.escapeHtml(args.ciName)}</dd>`);
2815
+ if (args.ciUrl && args.ciBuildNumber) {
2816
+ items.push(
2817
+ `<dt>CI:</dt><dd>${deps.escapeHtml(args.ciName)} <a href="${deps.escapeHtml(args.ciUrl)}">#${deps.escapeHtml(args.ciBuildNumber)}</a></dd>`
2818
+ );
2819
+ } else {
2820
+ items.push(`<dt>CI:</dt><dd>${deps.escapeHtml(args.ciName)}</dd>`);
2821
+ }
2822
+ }
2823
+ if (args.ciBranch) {
2824
+ items.push(`<dt>Branch:</dt><dd>${deps.escapeHtml(args.ciBranch)}</dd>`);
2825
+ }
2826
+ if (args.ciCommitSha) {
2827
+ const shortSha = args.ciCommitSha.length > 7 ? args.ciCommitSha.slice(0, 7) : args.ciCommitSha;
2828
+ items.push(
2829
+ `<dt>Commit:</dt><dd title="${deps.escapeHtml(args.ciCommitSha)}">${deps.escapeHtml(shortSha)}</dd>`
2830
+ );
2675
2831
  }
2676
2832
  return `<dl class="meta-info">${items.join("")}</dl>`;
2677
2833
  }
@@ -2922,6 +3078,9 @@ function highlightStepParams(text, deps) {
2922
3078
  return result;
2923
3079
  }
2924
3080
 
3081
+ // src/history/sample-policy.ts
3082
+ var MIN_METRIC_SAMPLES = 5;
3083
+
2925
3084
  // src/formatters/html/renderers/scenario.ts
2926
3085
  function renderScenario(args, deps) {
2927
3086
  const { tc } = args;
@@ -2942,6 +3101,19 @@ function renderScenario(args, deps) {
2942
3101
  traceBadge = `<span class="tag trace-tag" title="${deps.escapeHtml(otelMeta.traceId)}">${deps.escapeHtml(shortId)}\u2026</span>`;
2943
3102
  }
2944
3103
  }
3104
+ let metricBadges = "";
3105
+ const { metrics } = args;
3106
+ if (metrics && metrics.sampleSize >= MIN_METRIC_SAMPLES) {
3107
+ const grade = metrics.stabilityGrade;
3108
+ metricBadges += `<span class="badge badge-grade badge-grade-${grade}" title="Pass rate: ${(metrics.passRate * 100).toFixed(0)}% (${metrics.sampleSize} runs)">${grade}</span>`;
3109
+ if (metrics.flakinessLevel !== "stable") {
3110
+ metricBadges += `<span class="badge badge-flaky">${metrics.flakinessLevel}</span>`;
3111
+ }
3112
+ if (metrics.performanceTrend !== "stable") {
3113
+ const arrow = metrics.performanceTrend === "improving" ? "\u2191" : "\u2193";
3114
+ metricBadges += `<span class="badge badge-perf badge-perf-${metrics.performanceTrend}">${arrow} ${metrics.performanceTrend}</span>`;
3115
+ }
3116
+ }
2945
3117
  const storyDocs = deps.renderDocs(tc.story.docs, "story-docs");
2946
3118
  const steps = deps.renderSteps(
2947
3119
  { steps: tc.story.steps, stepResults: tc.stepResults },
@@ -2962,6 +3134,10 @@ function renderScenario(args, deps) {
2962
3134
  embedScreenshots: deps.embedScreenshots
2963
3135
  }
2964
3136
  );
3137
+ const traceView = deps.renderTraceView(
3138
+ { spans: tc.story.otelSpans },
3139
+ { escapeHtml: deps.escapeHtml }
3140
+ );
2965
3141
  const collapsedClass = deps.startCollapsed ? " collapsed" : "";
2966
3142
  const ariaExpanded = !deps.startCollapsed;
2967
3143
  return `
@@ -2972,7 +3148,7 @@ function renderScenario(args, deps) {
2972
3148
  <span class="status-icon ${statusClass}">${statusIcon}</span>
2973
3149
  <span class="scenario-name">${deps.escapeHtml(tc.story.scenario)}</span>
2974
3150
  </div>
2975
- <div class="scenario-meta">${tags}${traceBadge}</div>
3151
+ <div class="scenario-meta">${tags}${traceBadge}${metricBadges}</div>
2976
3152
  </div>
2977
3153
  <span class="scenario-duration">${duration}</span>
2978
3154
  </div>
@@ -2981,6 +3157,193 @@ function renderScenario(args, deps) {
2981
3157
  ${steps}
2982
3158
  ${error}
2983
3159
  ${attachments}
3160
+ ${traceView}
3161
+ </div>
3162
+ </div>`;
3163
+ }
3164
+
3165
+ // src/formatters/html/renderers/trace-view.ts
3166
+ var VALID_STATUSES = /* @__PURE__ */ new Set(["ok", "error", "unset"]);
3167
+ var TOOLTIP_MAX_LENGTH = 4096;
3168
+ function safeStatus(status) {
3169
+ return VALID_STATUSES.has(status) ? status : "unset";
3170
+ }
3171
+ function formatDuration(ms) {
3172
+ if (ms >= 1e3) return `${(ms / 1e3).toFixed(2)}s`;
3173
+ return `${ms.toFixed(1)}ms`;
3174
+ }
3175
+ function clamp(value, min, max) {
3176
+ return Math.min(max, Math.max(min, value));
3177
+ }
3178
+ function normalizeSpans(spans) {
3179
+ const result = [];
3180
+ for (const span of spans) {
3181
+ if (!span || typeof span !== "object") continue;
3182
+ if (typeof span.spanId !== "string" || typeof span.name !== "string") continue;
3183
+ let startTimeMs;
3184
+ let durationMs;
3185
+ if (span.startTimeMs != null && span.durationMs != null) {
3186
+ startTimeMs = span.startTimeMs;
3187
+ durationMs = span.durationMs;
3188
+ } else if (span.startTimeUnixNano != null && span.endTimeUnixNano != null) {
3189
+ startTimeMs = span.startTimeUnixNano / 1e6;
3190
+ durationMs = (span.endTimeUnixNano - span.startTimeUnixNano) / 1e6;
3191
+ } else {
3192
+ continue;
3193
+ }
3194
+ durationMs = Math.max(0, durationMs);
3195
+ if (!isFinite(startTimeMs) || !isFinite(durationMs)) continue;
3196
+ result.push({
3197
+ spanId: span.spanId,
3198
+ parentSpanId: span.parentSpanId,
3199
+ name: span.name,
3200
+ startTimeMs,
3201
+ durationMs,
3202
+ status: safeStatus(span.status),
3203
+ statusMessage: span.statusMessage,
3204
+ attributes: span.attributes
3205
+ });
3206
+ }
3207
+ return result;
3208
+ }
3209
+ function buildTree(spans) {
3210
+ const byId = /* @__PURE__ */ new Map();
3211
+ for (const span of spans) {
3212
+ let key = span.spanId;
3213
+ if (byId.has(key)) {
3214
+ let suffix = 2;
3215
+ while (byId.has(`${span.spanId}__dup${suffix}`)) suffix++;
3216
+ key = `${span.spanId}__dup${suffix}`;
3217
+ }
3218
+ byId.set(key, { span: { ...span, spanId: key }, children: [], depth: 0 });
3219
+ }
3220
+ const roots = [];
3221
+ for (const node of byId.values()) {
3222
+ const parentId = node.span.parentSpanId;
3223
+ const parent = parentId ? byId.get(parentId) : void 0;
3224
+ if (parent && parent !== node) {
3225
+ parent.children.push(node);
3226
+ } else {
3227
+ roots.push(node);
3228
+ }
3229
+ }
3230
+ for (const node of byId.values()) {
3231
+ node.children.sort((a, b) => a.span.startTimeMs - b.span.startTimeMs);
3232
+ }
3233
+ roots.sort((a, b) => a.span.startTimeMs - b.span.startTimeMs);
3234
+ const visited = /* @__PURE__ */ new Set();
3235
+ function assignDepth(node, depth) {
3236
+ if (visited.has(node.span.spanId)) return;
3237
+ visited.add(node.span.spanId);
3238
+ node.depth = depth;
3239
+ for (const child of node.children) {
3240
+ assignDepth(child, depth + 1);
3241
+ }
3242
+ }
3243
+ for (const root of roots) {
3244
+ assignDepth(root, 0);
3245
+ }
3246
+ for (const node of byId.values()) {
3247
+ if (!visited.has(node.span.spanId)) {
3248
+ node.children = [];
3249
+ roots.push(node);
3250
+ assignDepth(node, 0);
3251
+ }
3252
+ }
3253
+ roots.sort((a, b) => a.span.startTimeMs - b.span.startTimeMs);
3254
+ return roots;
3255
+ }
3256
+ function flattenTree(roots) {
3257
+ const result = [];
3258
+ function walk(node) {
3259
+ result.push(node);
3260
+ for (const child of node.children) {
3261
+ walk(child);
3262
+ }
3263
+ }
3264
+ for (const root of roots) {
3265
+ walk(root);
3266
+ }
3267
+ return result;
3268
+ }
3269
+ function buildTooltip(span, escapeHtml2) {
3270
+ const parts = [];
3271
+ parts.push(`${span.name} (${formatDuration(span.durationMs)})`);
3272
+ if (span.statusMessage) {
3273
+ parts.push(`Status: ${span.statusMessage}`);
3274
+ }
3275
+ if (span.attributes) {
3276
+ const keys = Object.keys(span.attributes).sort();
3277
+ for (const key of keys) {
3278
+ const val = span.attributes[key];
3279
+ const formatted = Array.isArray(val) ? `[${val.map((v) => String(v)).join(", ")}]` : String(val);
3280
+ parts.push(`${key}=${formatted}`);
3281
+ }
3282
+ }
3283
+ let text = parts.join("\n");
3284
+ if (text.length > TOOLTIP_MAX_LENGTH) {
3285
+ text = text.slice(0, TOOLTIP_MAX_LENGTH - 3) + "...";
3286
+ }
3287
+ return escapeHtml2(text);
3288
+ }
3289
+ function renderTraceView(args, deps) {
3290
+ if (!args.spans || args.spans.length === 0) return "";
3291
+ const normalized = normalizeSpans(args.spans);
3292
+ if (normalized.length === 0) return "";
3293
+ const roots = buildTree(normalized);
3294
+ const flat = flattenTree(roots);
3295
+ let minStart = Infinity;
3296
+ let maxEnd = -Infinity;
3297
+ for (const node of flat) {
3298
+ const s = node.span.startTimeMs;
3299
+ const e = s + node.span.durationMs;
3300
+ if (s < minStart) minStart = s;
3301
+ if (e > maxEnd) maxEnd = e;
3302
+ }
3303
+ let totalDuration = maxEnd - minStart;
3304
+ if (totalDuration <= 0) totalDuration = 1;
3305
+ const rows = flat.map((node) => {
3306
+ const { span, depth } = node;
3307
+ const indent = depth * 16;
3308
+ const minWidth = 0.5;
3309
+ let spanLeft = clamp(
3310
+ (span.startTimeMs - minStart) / totalDuration * 100,
3311
+ 0,
3312
+ 100
3313
+ );
3314
+ if (spanLeft + minWidth > 100) {
3315
+ spanLeft = 100 - minWidth;
3316
+ }
3317
+ const spanWidth = clamp(
3318
+ span.durationMs / totalDuration * 100,
3319
+ minWidth,
3320
+ 100 - spanLeft
3321
+ );
3322
+ const tooltip = buildTooltip(span, deps.escapeHtml);
3323
+ const durationLabel = formatDuration(span.durationMs);
3324
+ return ` <div class="trace-view-row">
3325
+ <div class="trace-view-name" style="padding-left: ${indent}px" title="${deps.escapeHtml(span.name)}">
3326
+ <span class="trace-view-status-dot trace-view-status-${span.status}"></span>
3327
+ ${deps.escapeHtml(span.name)}
3328
+ </div>
3329
+ <div class="trace-view-bar-container">
3330
+ <div class="trace-view-bar trace-view-bar-${span.status}" style="left: ${spanLeft.toFixed(2)}%; width: ${spanWidth.toFixed(2)}%" title="${tooltip}">${durationLabel}</div>
3331
+ </div>
3332
+ </div>`;
3333
+ }).join("\n");
3334
+ const axisEnd = formatDuration(maxEnd - minStart);
3335
+ return `<div class="trace-view collapsed">
3336
+ <div class="trace-view-header" role="button" tabindex="0" aria-expanded="false">
3337
+ <span>Spans</span>
3338
+ <span class="trace-view-count">${flat.length}</span>
3339
+ <span class="chevron">&#9660;</span>
3340
+ </div>
3341
+ <div class="trace-view-content">
3342
+ <div class="trace-view-axis">
3343
+ <span>0ms</span>
3344
+ <span>${axisEnd}</span>
3345
+ </div>
3346
+ ${rows}
2984
3347
  </div>
2985
3348
  </div>`;
2986
3349
  }
@@ -2997,7 +3360,12 @@ function renderFeature(args, deps) {
2997
3360
  const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
2998
3361
  const collapsedClass = deps.startCollapsed ? " collapsed" : "";
2999
3362
  const ariaExpanded = !deps.startCollapsed;
3000
- const scenarios = testCases.map((tc) => deps.renderScenario({ tc }, deps.scenarioDeps)).join("\n");
3363
+ const scenarios = testCases.map(
3364
+ (tc) => deps.renderScenario(
3365
+ { tc, metrics: args.metricsMap?.get(tc.id) },
3366
+ deps.scenarioDeps
3367
+ )
3368
+ ).join("\n");
3001
3369
  return `
3002
3370
  <div class="feature${collapsedClass}">
3003
3371
  <div class="feature-header" role="button" tabindex="0" aria-expanded="${ariaExpanded}">
@@ -3042,7 +3410,11 @@ function buildBody(args, deps) {
3042
3410
  durationMs: run.durationMs,
3043
3411
  packageVersion: run.packageVersion,
3044
3412
  gitSha: run.gitSha,
3045
- ciName: run.ci?.name
3413
+ ciName: run.ci?.name,
3414
+ ciBranch: run.ci?.branch,
3415
+ ciUrl: run.ci?.url,
3416
+ ciCommitSha: run.ci?.commitSha,
3417
+ ciBuildNumber: run.ci?.buildNumber
3046
3418
  },
3047
3419
  deps.metaDeps
3048
3420
  )
@@ -3071,7 +3443,10 @@ function buildBody(args, deps) {
3071
3443
  const byFile = groupBy(run.testCases, (tc) => tc.sourceFile);
3072
3444
  for (const [file, testCases] of byFile) {
3073
3445
  parts.push(
3074
- deps.renderFeature({ file, testCases }, deps.featureDeps)
3446
+ deps.renderFeature(
3447
+ { file, testCases, metricsMap: args.metricsMap },
3448
+ deps.featureDeps
3449
+ )
3075
3450
  );
3076
3451
  }
3077
3452
  return parts.join("\n");
@@ -3117,6 +3492,7 @@ function createHtmlFormatter(options = {}) {
3117
3492
  renderDocs,
3118
3493
  renderErrorBox: (args, d) => renderErrorBox(args, d),
3119
3494
  renderAttachments: (args, d) => renderAttachments(args, d),
3495
+ renderTraceView: (args, d) => renderTraceView(args, d),
3120
3496
  embedScreenshots: opts.embedScreenshots
3121
3497
  };
3122
3498
  const featureDeps = {
@@ -5008,6 +5384,574 @@ function pickleStepArgumentToDocs(ps) {
5008
5384
  return docs;
5009
5385
  }
5010
5386
 
5387
+ // src/notifiers/ansi-strip.ts
5388
+ function stripAnsi(text) {
5389
+ return text.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
5390
+ }
5391
+
5392
+ // src/notifiers/slack.ts
5393
+ function truncate(text, maxLen) {
5394
+ if (text.length <= maxLen) return text;
5395
+ return text.slice(0, maxLen - 3) + "...";
5396
+ }
5397
+ function formatDuration2(ms) {
5398
+ const seconds = ms / 1e3;
5399
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
5400
+ const minutes = Math.floor(seconds / 60);
5401
+ const remainingSeconds = seconds % 60;
5402
+ return `${minutes}m ${remainingSeconds.toFixed(0)}s`;
5403
+ }
5404
+ function buildSlackPayload(summary, maxFailedTests) {
5405
+ const allPassed = summary.failed === 0;
5406
+ const emoji = allPassed ? ":white_check_mark:" : ":x:";
5407
+ const statusText = allPassed ? "Passed" : "Failed";
5408
+ const blocks = [];
5409
+ blocks.push({
5410
+ type: "header",
5411
+ text: {
5412
+ type: "plain_text",
5413
+ text: `${emoji} Test Results: ${summary.passed} passed, ${summary.failed} failed`,
5414
+ emoji: true
5415
+ }
5416
+ });
5417
+ blocks.push({
5418
+ type: "section",
5419
+ fields: [
5420
+ { type: "mrkdwn", text: `*Total:* ${summary.total}` },
5421
+ { type: "mrkdwn", text: `*Passed:* ${summary.passed}` },
5422
+ { type: "mrkdwn", text: `*Failed:* ${summary.failed}` },
5423
+ { type: "mrkdwn", text: `*Skipped:* ${summary.skipped}` },
5424
+ { type: "mrkdwn", text: `*Duration:* ${formatDuration2(summary.durationMs)}` },
5425
+ { type: "mrkdwn", text: `*Status:* ${statusText}` }
5426
+ ]
5427
+ });
5428
+ if (summary.failedTests.length > 0) {
5429
+ const displayedTests = summary.failedTests.slice(0, maxFailedTests);
5430
+ const lines = displayedTests.map((t) => {
5431
+ const name = t.name;
5432
+ if (t.error) {
5433
+ const cleanError = truncate(stripAnsi(t.error), 500);
5434
+ return `*${name}*
5435
+ \`\`\`${cleanError}\`\`\``;
5436
+ }
5437
+ return `*${name}*`;
5438
+ });
5439
+ let text = lines.join("\n\n");
5440
+ if (summary.failedTests.length > maxFailedTests) {
5441
+ text += `
5442
+
5443
+ _...and ${summary.failedTests.length - maxFailedTests} more_`;
5444
+ }
5445
+ blocks.push({
5446
+ type: "section",
5447
+ text: {
5448
+ type: "mrkdwn",
5449
+ text
5450
+ }
5451
+ });
5452
+ }
5453
+ if (summary.ci) {
5454
+ const elements = [];
5455
+ if (summary.ci.displayName) {
5456
+ elements.push({ type: "mrkdwn", text: `*CI:* ${summary.ci.displayName}` });
5457
+ }
5458
+ if (summary.ci.branch) {
5459
+ elements.push({ type: "mrkdwn", text: `*Branch:* ${summary.ci.branch}` });
5460
+ }
5461
+ if (summary.ci.commitSha) {
5462
+ elements.push({ type: "mrkdwn", text: `*Commit:* ${summary.ci.commitSha.slice(0, 7)}` });
5463
+ }
5464
+ if (summary.ci.buildNumber) {
5465
+ elements.push({ type: "mrkdwn", text: `*Build:* #${summary.ci.buildNumber}` });
5466
+ }
5467
+ if (elements.length > 0) {
5468
+ blocks.push({
5469
+ type: "context",
5470
+ elements
5471
+ });
5472
+ }
5473
+ }
5474
+ if (summary.reportUrl) {
5475
+ blocks.push({
5476
+ type: "actions",
5477
+ elements: [
5478
+ {
5479
+ type: "button",
5480
+ text: {
5481
+ type: "plain_text",
5482
+ text: "View Report",
5483
+ emoji: true
5484
+ },
5485
+ url: summary.reportUrl,
5486
+ action_id: "view_report"
5487
+ }
5488
+ ]
5489
+ });
5490
+ }
5491
+ return { blocks };
5492
+ }
5493
+ async function sendSlackNotification(args, deps) {
5494
+ const { summary, webhookUrl, maxFailedTests = 5 } = args;
5495
+ const { fetch, logger } = deps;
5496
+ const payload = buildSlackPayload(summary, maxFailedTests);
5497
+ try {
5498
+ const response = await fetch(webhookUrl, {
5499
+ method: "POST",
5500
+ headers: { "Content-Type": "application/json" },
5501
+ body: JSON.stringify(payload)
5502
+ });
5503
+ if (!response.ok) {
5504
+ const requestId = response.headers.get("x-request-id") ?? void 0;
5505
+ let bodyText = "";
5506
+ try {
5507
+ bodyText = await response.text();
5508
+ } catch {
5509
+ }
5510
+ const truncatedBody = truncate(bodyText, 200);
5511
+ const idPart = requestId ? ` x-request-id=${requestId}` : "";
5512
+ const errorMsg = `Slack notifier failed: HTTP ${response.status}${idPart} ${truncatedBody}`;
5513
+ logger.warn(errorMsg);
5514
+ return { ok: false, error: errorMsg };
5515
+ }
5516
+ return { ok: true };
5517
+ } catch (err) {
5518
+ const msg = err instanceof Error ? err.message : String(err);
5519
+ const errorMsg = `Slack notifier failed: ${msg}`;
5520
+ logger.warn(errorMsg);
5521
+ return { ok: false, error: errorMsg };
5522
+ }
5523
+ }
5524
+
5525
+ // src/notifiers/teams.ts
5526
+ function truncate2(text, maxLen) {
5527
+ if (text.length <= maxLen) return text;
5528
+ return text.slice(0, maxLen - 3) + "...";
5529
+ }
5530
+ function formatDuration3(ms) {
5531
+ const seconds = ms / 1e3;
5532
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
5533
+ const minutes = Math.floor(seconds / 60);
5534
+ const remainingSeconds = seconds % 60;
5535
+ return `${minutes}m ${remainingSeconds.toFixed(0)}s`;
5536
+ }
5537
+ function buildTeamsPayload(summary, maxFailedTests) {
5538
+ const allPassed = summary.failed === 0;
5539
+ const statusEmoji = allPassed ? "\u2705" : "\u274C";
5540
+ const statusColor = allPassed ? "good" : "attention";
5541
+ const bodyItems = [];
5542
+ bodyItems.push({
5543
+ type: "TextBlock",
5544
+ size: "Large",
5545
+ weight: "Bolder",
5546
+ text: `${statusEmoji} Test Results`,
5547
+ color: statusColor
5548
+ });
5549
+ bodyItems.push({
5550
+ type: "FactSet",
5551
+ facts: [
5552
+ { title: "Total", value: String(summary.total) },
5553
+ { title: "Passed", value: String(summary.passed) },
5554
+ { title: "Failed", value: String(summary.failed) },
5555
+ { title: "Skipped", value: String(summary.skipped) },
5556
+ { title: "Duration", value: formatDuration3(summary.durationMs) }
5557
+ ]
5558
+ });
5559
+ if (summary.failedTests.length > 0) {
5560
+ const displayedTests = summary.failedTests.slice(0, maxFailedTests);
5561
+ const failedItems = [
5562
+ {
5563
+ type: "TextBlock",
5564
+ text: "Failed Tests",
5565
+ weight: "Bolder",
5566
+ spacing: "Medium"
5567
+ }
5568
+ ];
5569
+ for (const t of displayedTests) {
5570
+ failedItems.push({
5571
+ type: "TextBlock",
5572
+ text: `**${t.name}**`,
5573
+ wrap: true
5574
+ });
5575
+ if (t.error) {
5576
+ const cleanError = truncate2(stripAnsi(t.error), 500);
5577
+ failedItems.push({
5578
+ type: "TextBlock",
5579
+ text: cleanError,
5580
+ wrap: true,
5581
+ fontType: "Monospace",
5582
+ size: "Small",
5583
+ color: "Attention"
5584
+ });
5585
+ }
5586
+ }
5587
+ if (summary.failedTests.length > maxFailedTests) {
5588
+ failedItems.push({
5589
+ type: "TextBlock",
5590
+ text: `...and ${summary.failedTests.length - maxFailedTests} more`,
5591
+ isSubtle: true,
5592
+ spacing: "Small"
5593
+ });
5594
+ }
5595
+ bodyItems.push({
5596
+ type: "Container",
5597
+ items: failedItems
5598
+ });
5599
+ }
5600
+ if (summary.ci) {
5601
+ const ciFacts = [];
5602
+ if (summary.ci.displayName) {
5603
+ ciFacts.push({ title: "CI", value: summary.ci.displayName });
5604
+ }
5605
+ if (summary.ci.branch) {
5606
+ ciFacts.push({ title: "Branch", value: summary.ci.branch });
5607
+ }
5608
+ if (summary.ci.commitSha) {
5609
+ ciFacts.push({ title: "Commit", value: summary.ci.commitSha.slice(0, 7) });
5610
+ }
5611
+ if (summary.ci.buildNumber) {
5612
+ ciFacts.push({ title: "Build", value: `#${summary.ci.buildNumber}` });
5613
+ }
5614
+ if (ciFacts.length > 0) {
5615
+ bodyItems.push({
5616
+ type: "FactSet",
5617
+ facts: ciFacts,
5618
+ separator: true
5619
+ });
5620
+ }
5621
+ }
5622
+ const card = {
5623
+ type: "AdaptiveCard",
5624
+ $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
5625
+ version: "1.4",
5626
+ body: bodyItems
5627
+ };
5628
+ if (summary.reportUrl) {
5629
+ card.actions = [
5630
+ {
5631
+ type: "Action.OpenUrl",
5632
+ title: "View Report",
5633
+ url: summary.reportUrl
5634
+ }
5635
+ ];
5636
+ }
5637
+ return {
5638
+ type: "message",
5639
+ attachments: [
5640
+ {
5641
+ contentType: "application/vnd.microsoft.card.adaptive",
5642
+ content: card
5643
+ }
5644
+ ]
5645
+ };
5646
+ }
5647
+ async function sendTeamsNotification(args, deps) {
5648
+ const { summary, webhookUrl, maxFailedTests = 5 } = args;
5649
+ const { fetch, logger } = deps;
5650
+ const payload = buildTeamsPayload(summary, maxFailedTests);
5651
+ try {
5652
+ const response = await fetch(webhookUrl, {
5653
+ method: "POST",
5654
+ headers: { "Content-Type": "application/json" },
5655
+ body: JSON.stringify(payload)
5656
+ });
5657
+ if (!response.ok) {
5658
+ const requestId = response.headers.get("x-request-id") ?? void 0;
5659
+ let bodyText = "";
5660
+ try {
5661
+ bodyText = await response.text();
5662
+ } catch {
5663
+ }
5664
+ const truncatedBody = truncate2(bodyText, 200);
5665
+ const idPart = requestId ? ` x-request-id=${requestId}` : "";
5666
+ const errorMsg = `Teams notifier failed: HTTP ${response.status}${idPart} ${truncatedBody}`;
5667
+ logger.warn(errorMsg);
5668
+ return { ok: false, error: errorMsg };
5669
+ }
5670
+ return { ok: true };
5671
+ } catch (err) {
5672
+ const msg = err instanceof Error ? err.message : String(err);
5673
+ const errorMsg = `Teams notifier failed: ${msg}`;
5674
+ logger.warn(errorMsg);
5675
+ return { ok: false, error: errorMsg };
5676
+ }
5677
+ }
5678
+
5679
+ // src/notifiers/hmac.ts
5680
+ import { createHmac } from "crypto";
5681
+ function signBody(args) {
5682
+ let input;
5683
+ let timestamp;
5684
+ if (args.includeTimestamp) {
5685
+ timestamp = args.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
5686
+ input = `${timestamp}.${args.body}`;
5687
+ } else {
5688
+ input = args.body;
5689
+ }
5690
+ const hex = createHmac("sha256", args.secret).update(input, "utf8").digest("hex");
5691
+ return {
5692
+ signature: `sha256=${hex}`,
5693
+ timestamp
5694
+ };
5695
+ }
5696
+
5697
+ // src/notifiers/webhook.ts
5698
+ async function sendWebhookNotification(args, deps) {
5699
+ const { summary, options } = args;
5700
+ const { fetch, logger } = deps;
5701
+ const payload = {
5702
+ schemaVersion: 1,
5703
+ event: "test_run_finished",
5704
+ summary
5705
+ };
5706
+ const body = JSON.stringify(payload);
5707
+ const headers = { "Content-Type": "application/json" };
5708
+ if (options.headers) {
5709
+ for (const [key, value] of Object.entries(options.headers)) {
5710
+ headers[key] = value;
5711
+ }
5712
+ }
5713
+ if (options.signer) {
5714
+ const { secret, header, includeTimestamp, timestampHeader } = options.signer;
5715
+ const result = signBody({ body, secret, includeTimestamp });
5716
+ headers[header] = result.signature;
5717
+ if (result.timestamp) {
5718
+ headers[timestampHeader ?? "X-Timestamp"] = result.timestamp;
5719
+ }
5720
+ }
5721
+ try {
5722
+ const response = await fetch(options.url, {
5723
+ method: options.method ?? "POST",
5724
+ headers,
5725
+ body
5726
+ });
5727
+ if (!response.ok) {
5728
+ const requestId = response.headers.get("x-request-id") ?? void 0;
5729
+ let snippet = "";
5730
+ try {
5731
+ snippet = (await response.text()).slice(0, 200);
5732
+ } catch {
5733
+ }
5734
+ const idPart = requestId ? ` x-request-id=${requestId}` : "";
5735
+ const errorMsg = `webhook: HTTP ${response.status}${idPart} ${snippet}`;
5736
+ logger.warn(errorMsg);
5737
+ return { ok: false, error: errorMsg };
5738
+ }
5739
+ return { ok: true };
5740
+ } catch (err) {
5741
+ const msg = err instanceof Error ? err.message : String(err);
5742
+ const errorMsg = `webhook: ${msg}`;
5743
+ logger.warn(errorMsg);
5744
+ return { ok: false, error: errorMsg };
5745
+ }
5746
+ }
5747
+
5748
+ // src/notifiers/index.ts
5749
+ function buildSummary(run, reportUrl, toCIInfo2) {
5750
+ let passed = 0;
5751
+ let failed = 0;
5752
+ let skipped = 0;
5753
+ const failedTests = [];
5754
+ for (const tc of run.testCases) {
5755
+ switch (tc.status) {
5756
+ case "passed":
5757
+ passed++;
5758
+ break;
5759
+ case "failed":
5760
+ failed++;
5761
+ failedTests.push({
5762
+ testId: tc.id,
5763
+ name: tc.story.scenario,
5764
+ error: tc.errorMessage
5765
+ });
5766
+ break;
5767
+ case "skipped":
5768
+ case "pending":
5769
+ skipped++;
5770
+ break;
5771
+ }
5772
+ }
5773
+ let ci;
5774
+ if (run.ci) {
5775
+ ci = toCIInfo2(run.ci);
5776
+ }
5777
+ return {
5778
+ total: run.testCases.length,
5779
+ passed,
5780
+ failed,
5781
+ skipped,
5782
+ durationMs: run.durationMs,
5783
+ failedTests,
5784
+ ci,
5785
+ reportUrl
5786
+ };
5787
+ }
5788
+ function shouldNotify(condition, failedCount) {
5789
+ if (condition === "never") return false;
5790
+ if (condition === "on-failure" && failedCount === 0) return false;
5791
+ return true;
5792
+ }
5793
+ async function sendNotifications(args, deps) {
5794
+ const { run, notification } = args;
5795
+ const { logger, toCIInfo: toCIInfo2 } = deps;
5796
+ const env = deps.env ?? process.env;
5797
+ if (!deps.fetch) {
5798
+ logger.warn("notifications: skipped (fetch unavailable)");
5799
+ return;
5800
+ }
5801
+ const fetch = deps.fetch;
5802
+ const slackWebhookUrl = notification?.slackWebhookUrl ?? env.SLACK_WEBHOOK_URL;
5803
+ const teamsWebhookUrl = notification?.teamsWebhookUrl ?? env.TEAMS_WEBHOOK_URL;
5804
+ const globalCondition = notification?.condition ?? "on-failure";
5805
+ const reportUrl = notification?.reportUrl;
5806
+ const maxFailedTests = notification?.maxFailedTests ?? 5;
5807
+ const webhooks = notification?.webhooks ?? [];
5808
+ if (!slackWebhookUrl && !teamsWebhookUrl && webhooks.length === 0) {
5809
+ return;
5810
+ }
5811
+ const summary = buildSummary(run, reportUrl, toCIInfo2);
5812
+ const promises = [];
5813
+ if (slackWebhookUrl && shouldNotify(globalCondition, summary.failed)) {
5814
+ promises.push(
5815
+ sendSlackNotification(
5816
+ { summary, webhookUrl: slackWebhookUrl, maxFailedTests },
5817
+ { fetch, logger }
5818
+ ).then(() => void 0)
5819
+ );
5820
+ }
5821
+ if (teamsWebhookUrl && shouldNotify(globalCondition, summary.failed)) {
5822
+ promises.push(
5823
+ sendTeamsNotification(
5824
+ { summary, webhookUrl: teamsWebhookUrl, maxFailedTests },
5825
+ { fetch, logger }
5826
+ ).then(() => void 0)
5827
+ );
5828
+ }
5829
+ for (const webhook of webhooks) {
5830
+ const effectiveCondition = webhook.condition ?? globalCondition;
5831
+ if (!shouldNotify(effectiveCondition, summary.failed)) continue;
5832
+ promises.push(
5833
+ sendWebhookNotification(
5834
+ { summary, options: webhook, maxFailedTests },
5835
+ { fetch, logger }
5836
+ ).then(() => void 0)
5837
+ );
5838
+ }
5839
+ await Promise.allSettled(promises);
5840
+ }
5841
+
5842
+ // src/types/ci.ts
5843
+ var DISPLAY_NAMES = {
5844
+ github: "GitHub Actions",
5845
+ gitlab: "GitLab CI",
5846
+ circleci: "CircleCI",
5847
+ jenkins: "Jenkins",
5848
+ azure: "Azure DevOps",
5849
+ buildkite: "Buildkite",
5850
+ travis: "Travis CI",
5851
+ unknown: "CI"
5852
+ };
5853
+ var NAME_TO_PROVIDER = {
5854
+ github: "github",
5855
+ gitlab: "gitlab",
5856
+ circleci: "circleci",
5857
+ jenkins: "jenkins",
5858
+ azure: "azure",
5859
+ buildkite: "buildkite",
5860
+ travis: "travis",
5861
+ ci: "unknown"
5862
+ };
5863
+ function toCIInfo(raw) {
5864
+ if (!raw) return void 0;
5865
+ const provider = raw.provider ?? NAME_TO_PROVIDER[raw.name] ?? "unknown";
5866
+ return {
5867
+ provider,
5868
+ displayName: DISPLAY_NAMES[provider],
5869
+ url: raw.url,
5870
+ buildNumber: raw.buildNumber,
5871
+ branch: raw.branch,
5872
+ commitSha: raw.commitSha,
5873
+ prNumber: raw.prNumber
5874
+ };
5875
+ }
5876
+
5877
+ // src/history/history-store.ts
5878
+ function emptyStore() {
5879
+ return { version: 1, maxRuns: 10, tests: {}, lastUpdated: 0 };
5880
+ }
5881
+ function loadHistory(args, deps) {
5882
+ const content = deps.readFile(args.filePath);
5883
+ if (content === void 0) {
5884
+ return emptyStore();
5885
+ }
5886
+ let parsed;
5887
+ try {
5888
+ parsed = JSON.parse(content);
5889
+ } catch {
5890
+ deps.logger.warn(`Failed to parse history file: ${args.filePath}`);
5891
+ return emptyStore();
5892
+ }
5893
+ if (typeof parsed !== "object" || parsed === null || parsed.version !== 1) {
5894
+ deps.logger.warn(
5895
+ `Unknown history version in ${args.filePath}, expected version 1`
5896
+ );
5897
+ return emptyStore();
5898
+ }
5899
+ const obj = parsed;
5900
+ if (typeof obj.tests !== "object" || obj.tests === null || Array.isArray(obj.tests)) {
5901
+ deps.logger.warn(
5902
+ `Malformed history store in ${args.filePath}: tests must be a non-null object`
5903
+ );
5904
+ return emptyStore();
5905
+ }
5906
+ return parsed;
5907
+ }
5908
+ function saveHistory(args, deps) {
5909
+ deps.writeFile(args.filePath, JSON.stringify(args.store, null, 2));
5910
+ }
5911
+ function updateHistory(args) {
5912
+ const { store, run, maxRuns } = args;
5913
+ const newTests = { ...store.tests };
5914
+ for (const tc of run.testCases) {
5915
+ const entry = {
5916
+ runId: run.runId,
5917
+ timestamp: run.startedAtMs,
5918
+ status: tc.status,
5919
+ durationMs: tc.durationMs,
5920
+ ci: run.ci ? {
5921
+ provider: void 0,
5922
+ branch: run.ci.branch,
5923
+ commitSha: run.ci.commitSha
5924
+ } : void 0
5925
+ };
5926
+ const existing = newTests[tc.id];
5927
+ if (existing) {
5928
+ const updatedEntries = [...existing.entries, entry];
5929
+ const trimmed = updatedEntries.length > maxRuns ? updatedEntries.slice(updatedEntries.length - maxRuns) : updatedEntries;
5930
+ newTests[tc.id] = {
5931
+ ...existing,
5932
+ testName: tc.story.scenario,
5933
+ sourceFile: tc.sourceFile,
5934
+ sourceLine: tc.sourceLine,
5935
+ entries: trimmed
5936
+ };
5937
+ } else {
5938
+ newTests[tc.id] = {
5939
+ testId: tc.id,
5940
+ testName: tc.story.scenario,
5941
+ sourceFile: tc.sourceFile,
5942
+ sourceLine: tc.sourceLine,
5943
+ entries: [entry]
5944
+ };
5945
+ }
5946
+ }
5947
+ return {
5948
+ version: 1,
5949
+ maxRuns,
5950
+ tests: newTests,
5951
+ lastUpdated: Date.now()
5952
+ };
5953
+ }
5954
+
5011
5955
  // src/index.ts
5012
5956
  var FORMAT_EXTENSIONS = {
5013
5957
  markdown: ".md",
@@ -5379,6 +6323,26 @@ OPTIONS
5379
6323
  --emit-canonical <path> Write canonical JSON to given path
5380
6324
  --help Show this help message
5381
6325
 
6326
+ NOTIFICATIONS
6327
+ --slack-webhook <url> Slack incoming webhook URL (fallback: SLACK_WEBHOOK_URL env var)
6328
+ --teams-webhook <url> Teams incoming webhook URL (fallback: TEAMS_WEBHOOK_URL env var)
6329
+ --notify <condition> When to send: always, on-failure, never (default: on-failure)
6330
+ --report-url <url> URL to link in notification messages
6331
+ --max-failed-tests <n> Max failed tests to show in notifications (default: 5)
6332
+
6333
+ GENERIC WEBHOOK
6334
+ --webhook-url <url> Generic webhook URL (repeatable for multiple endpoints)
6335
+ --webhook-header <Key: Value> Custom request header (repeatable)
6336
+ --webhook-method <POST|PUT> HTTP method (default: POST)
6337
+ --webhook-hmac-secret <s> HMAC-SHA256 signing secret
6338
+ --webhook-hmac-header <name> Signature header name (default: X-Signature)
6339
+ --webhook-hmac-timestamp Include timestamp in HMAC signing
6340
+ Note: all --webhook-url entries share the same method/headers/signing options.
6341
+
6342
+ HISTORY
6343
+ --history-file <path> Path to JSON history file (enables tracking)
6344
+ --max-history-runs <n> Max runs to keep in history per test (default: 10)
6345
+
5382
6346
  EXIT CODES
5383
6347
  0 Success
5384
6348
  1 Schema validation failure
@@ -5415,6 +6379,19 @@ function parseCliArgs(argv) {
5415
6379
  stdin: { type: "boolean", default: false },
5416
6380
  "json-summary": { type: "boolean", default: false },
5417
6381
  "emit-canonical": { type: "string" },
6382
+ "slack-webhook": { type: "string" },
6383
+ "teams-webhook": { type: "string" },
6384
+ notify: { type: "string", default: "on-failure" },
6385
+ "report-url": { type: "string" },
6386
+ "max-failed-tests": { type: "string" },
6387
+ "history-file": { type: "string" },
6388
+ "max-history-runs": { type: "string" },
6389
+ "webhook-url": { type: "string", multiple: true },
6390
+ "webhook-header": { type: "string", multiple: true },
6391
+ "webhook-method": { type: "string" },
6392
+ "webhook-hmac-secret": { type: "string" },
6393
+ "webhook-hmac-header": { type: "string" },
6394
+ "webhook-hmac-timestamp": { type: "boolean", default: false },
5418
6395
  help: { type: "boolean", default: false }
5419
6396
  },
5420
6397
  allowPositionals: true,
@@ -5446,6 +6423,53 @@ function parseCliArgs(argv) {
5446
6423
  }
5447
6424
  const noSynthesize = values["no-synthesize-stories"];
5448
6425
  const parseGlobs = (v) => v ? v.split(",").map((s) => s.trim()).filter(Boolean) : [];
6426
+ const notifyValue = values.notify;
6427
+ const validNotifyConditions = /* @__PURE__ */ new Set(["always", "on-failure", "never"]);
6428
+ if (!validNotifyConditions.has(notifyValue)) {
6429
+ console.error(`Error: --notify must be "always", "on-failure", or "never", got "${notifyValue}".`);
6430
+ process.exit(EXIT_USAGE);
6431
+ }
6432
+ const maxFailedTestsStr = values["max-failed-tests"];
6433
+ const maxFailedTests = maxFailedTestsStr ? parseInt(maxFailedTestsStr, 10) : 5;
6434
+ if (maxFailedTestsStr && (isNaN(maxFailedTests) || maxFailedTests < 0)) {
6435
+ console.error(`Error: --max-failed-tests must be a non-negative integer, got "${maxFailedTestsStr}".`);
6436
+ process.exit(EXIT_USAGE);
6437
+ }
6438
+ const slackWebhook = values["slack-webhook"];
6439
+ const teamsWebhook = values["teams-webhook"];
6440
+ const webhookUrls = values["webhook-url"] ?? [];
6441
+ const webhookHeaders = {};
6442
+ const rawHeaders = values["webhook-header"] ?? [];
6443
+ for (const h of rawHeaders) {
6444
+ const colonIdx = h.indexOf(":");
6445
+ if (colonIdx <= 0) {
6446
+ console.error(`Warning: ignoring invalid --webhook-header "${h}" (expected "Key: Value")`);
6447
+ continue;
6448
+ }
6449
+ const key = h.slice(0, colonIdx).trim();
6450
+ const value = h.slice(colonIdx + 1).trim();
6451
+ if (!key) {
6452
+ console.error(`Warning: ignoring --webhook-header with empty key`);
6453
+ continue;
6454
+ }
6455
+ webhookHeaders[key] = value;
6456
+ }
6457
+ const webhookMethodRaw = values["webhook-method"];
6458
+ let webhookMethod = "POST";
6459
+ if (webhookMethodRaw) {
6460
+ const upper = webhookMethodRaw.toUpperCase();
6461
+ if (upper !== "POST" && upper !== "PUT") {
6462
+ console.error(`Error: --webhook-method must be "POST" or "PUT", got "${webhookMethodRaw}".`);
6463
+ process.exit(EXIT_USAGE);
6464
+ }
6465
+ webhookMethod = upper;
6466
+ }
6467
+ const maxHistoryRunsStr = values["max-history-runs"];
6468
+ const maxHistoryRuns = maxHistoryRunsStr ? parseInt(maxHistoryRunsStr, 10) : 10;
6469
+ if (maxHistoryRunsStr && (isNaN(maxHistoryRuns) || maxHistoryRuns < 1)) {
6470
+ console.error(`Error: --max-history-runs must be a positive integer, got "${maxHistoryRunsStr}".`);
6471
+ process.exit(EXIT_USAGE);
6472
+ }
5449
6473
  return {
5450
6474
  subcommand,
5451
6475
  inputFile,
@@ -5462,7 +6486,20 @@ function parseCliArgs(argv) {
5462
6486
  htmlNoMermaid: values["html-no-mermaid"],
5463
6487
  htmlNoMarkdown: values["html-no-markdown"],
5464
6488
  jsonSummary: values["json-summary"],
5465
- emitCanonical: values["emit-canonical"]
6489
+ emitCanonical: values["emit-canonical"],
6490
+ slackWebhook,
6491
+ teamsWebhook,
6492
+ notify: notifyValue,
6493
+ reportUrl: values["report-url"],
6494
+ maxFailedTests,
6495
+ historyFile: values["history-file"],
6496
+ maxHistoryRuns,
6497
+ webhookUrls,
6498
+ webhookHeaders,
6499
+ webhookMethod,
6500
+ webhookHmacSecret: values["webhook-hmac-secret"],
6501
+ webhookHmacHeader: values["webhook-hmac-header"] ?? "X-Signature",
6502
+ webhookHmacTimestamp: values["webhook-hmac-timestamp"]
5466
6503
  };
5467
6504
  }
5468
6505
  async function readInput(args) {
@@ -5546,6 +6583,8 @@ async function main() {
5546
6583
  }
5547
6584
  try {
5548
6585
  const result = await generateReports(run, args);
6586
+ await dispatchNotifications(run, args);
6587
+ runHistoryPipeline(run, args);
5549
6588
  printResult(result, args, startMs);
5550
6589
  process.exit(EXIT_SUCCESS);
5551
6590
  } catch (err) {
@@ -5602,6 +6641,8 @@ ${msg}`);
5602
6641
  }
5603
6642
  try {
5604
6643
  const result = await generateReports(run, args);
6644
+ await dispatchNotifications(run, args);
6645
+ runHistoryPipeline(run, args);
5605
6646
  printResult(result, args, startMs);
5606
6647
  process.exit(EXIT_SUCCESS);
5607
6648
  } catch (err) {
@@ -5657,6 +6698,8 @@ ${msg}`);
5657
6698
  }
5658
6699
  try {
5659
6700
  const result = await generateReports(canonical, args, droppedMissingStory);
6701
+ await dispatchNotifications(canonical, args);
6702
+ runHistoryPipeline(canonical, args);
5660
6703
  printResult(result, args, startMs, droppedMissingStory);
5661
6704
  process.exit(EXIT_SUCCESS);
5662
6705
  } catch (err) {
@@ -5665,6 +6708,85 @@ ${msg}`);
5665
6708
  process.exit(EXIT_GENERATION);
5666
6709
  }
5667
6710
  }
6711
+ async function dispatchNotifications(run, args) {
6712
+ const webhooks = args.webhookUrls.map((url) => {
6713
+ const opts = { url };
6714
+ if (Object.keys(args.webhookHeaders).length > 0) {
6715
+ opts.headers = { ...args.webhookHeaders };
6716
+ }
6717
+ if (args.webhookMethod !== "POST") {
6718
+ opts.method = args.webhookMethod;
6719
+ }
6720
+ if (args.webhookHmacSecret) {
6721
+ const signer = {
6722
+ type: "hmac-sha256",
6723
+ secret: args.webhookHmacSecret,
6724
+ header: args.webhookHmacHeader
6725
+ };
6726
+ if (args.webhookHmacTimestamp) {
6727
+ signer.includeTimestamp = true;
6728
+ }
6729
+ opts.signer = signer;
6730
+ }
6731
+ return opts;
6732
+ });
6733
+ await sendNotifications(
6734
+ {
6735
+ run,
6736
+ notification: {
6737
+ slackWebhookUrl: args.slackWebhook,
6738
+ teamsWebhookUrl: args.teamsWebhook,
6739
+ condition: args.notify,
6740
+ reportUrl: args.reportUrl,
6741
+ maxFailedTests: args.maxFailedTests,
6742
+ webhooks: webhooks.length > 0 ? webhooks : void 0
6743
+ }
6744
+ },
6745
+ {
6746
+ fetch: globalThis.fetch,
6747
+ logger: console,
6748
+ toCIInfo
6749
+ }
6750
+ );
6751
+ }
6752
+ function runHistoryPipeline(run, args) {
6753
+ if (!args.historyFile) return;
6754
+ const historyPath = path3.resolve(args.historyFile);
6755
+ const store = loadHistory(
6756
+ { filePath: historyPath },
6757
+ {
6758
+ readFile: (p) => {
6759
+ try {
6760
+ return fs3.readFileSync(p, "utf8");
6761
+ } catch {
6762
+ return void 0;
6763
+ }
6764
+ },
6765
+ logger: console
6766
+ }
6767
+ );
6768
+ const updated = updateHistory({
6769
+ store,
6770
+ run,
6771
+ maxRuns: args.maxHistoryRuns
6772
+ });
6773
+ const dir = path3.dirname(historyPath);
6774
+ fs3.mkdirSync(dir, { recursive: true });
6775
+ saveHistory(
6776
+ { filePath: historyPath, store: updated },
6777
+ { writeFile: (p, content) => fs3.writeFileSync(p, content, "utf8") }
6778
+ );
6779
+ let metricsCount = 0;
6780
+ for (const testId of Object.keys(updated.tests)) {
6781
+ const history = updated.tests[testId];
6782
+ if (history.entries.length >= 3) {
6783
+ metricsCount++;
6784
+ }
6785
+ }
6786
+ if (metricsCount > 0) {
6787
+ console.error(`History updated: ${historyPath} (${Object.keys(updated.tests).length} tests tracked)`);
6788
+ }
6789
+ }
5668
6790
  async function generateReports(run, args, _droppedMissingStory = 0) {
5669
6791
  const generator = new ReportGenerator({
5670
6792
  include: args.include,