executable-stories-formatters 0.4.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
@@ -2764,6 +2764,21 @@ body {
2764
2764
  }
2765
2765
  }
2766
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
+
2767
2782
  `;
2768
2783
 
2769
2784
  // src/formatters/html/renderers/status.ts
@@ -2797,7 +2812,22 @@ function renderMetaInfo(args, deps) {
2797
2812
  items.push(`<dt>Git:</dt><dd>${deps.escapeHtml(shortSha)}</dd>`);
2798
2813
  }
2799
2814
  if (args.ciName) {
2800
- 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
+ );
2801
2831
  }
2802
2832
  return `<dl class="meta-info">${items.join("")}</dl>`;
2803
2833
  }
@@ -3048,6 +3078,9 @@ function highlightStepParams(text, deps) {
3048
3078
  return result;
3049
3079
  }
3050
3080
 
3081
+ // src/history/sample-policy.ts
3082
+ var MIN_METRIC_SAMPLES = 5;
3083
+
3051
3084
  // src/formatters/html/renderers/scenario.ts
3052
3085
  function renderScenario(args, deps) {
3053
3086
  const { tc } = args;
@@ -3068,6 +3101,19 @@ function renderScenario(args, deps) {
3068
3101
  traceBadge = `<span class="tag trace-tag" title="${deps.escapeHtml(otelMeta.traceId)}">${deps.escapeHtml(shortId)}\u2026</span>`;
3069
3102
  }
3070
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
+ }
3071
3117
  const storyDocs = deps.renderDocs(tc.story.docs, "story-docs");
3072
3118
  const steps = deps.renderSteps(
3073
3119
  { steps: tc.story.steps, stepResults: tc.stepResults },
@@ -3102,7 +3148,7 @@ function renderScenario(args, deps) {
3102
3148
  <span class="status-icon ${statusClass}">${statusIcon}</span>
3103
3149
  <span class="scenario-name">${deps.escapeHtml(tc.story.scenario)}</span>
3104
3150
  </div>
3105
- <div class="scenario-meta">${tags}${traceBadge}</div>
3151
+ <div class="scenario-meta">${tags}${traceBadge}${metricBadges}</div>
3106
3152
  </div>
3107
3153
  <span class="scenario-duration">${duration}</span>
3108
3154
  </div>
@@ -3314,7 +3360,12 @@ function renderFeature(args, deps) {
3314
3360
  const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
3315
3361
  const collapsedClass = deps.startCollapsed ? " collapsed" : "";
3316
3362
  const ariaExpanded = !deps.startCollapsed;
3317
- 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");
3318
3369
  return `
3319
3370
  <div class="feature${collapsedClass}">
3320
3371
  <div class="feature-header" role="button" tabindex="0" aria-expanded="${ariaExpanded}">
@@ -3359,7 +3410,11 @@ function buildBody(args, deps) {
3359
3410
  durationMs: run.durationMs,
3360
3411
  packageVersion: run.packageVersion,
3361
3412
  gitSha: run.gitSha,
3362
- 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
3363
3418
  },
3364
3419
  deps.metaDeps
3365
3420
  )
@@ -3388,7 +3443,10 @@ function buildBody(args, deps) {
3388
3443
  const byFile = groupBy(run.testCases, (tc) => tc.sourceFile);
3389
3444
  for (const [file, testCases] of byFile) {
3390
3445
  parts.push(
3391
- deps.renderFeature({ file, testCases }, deps.featureDeps)
3446
+ deps.renderFeature(
3447
+ { file, testCases, metricsMap: args.metricsMap },
3448
+ deps.featureDeps
3449
+ )
3392
3450
  );
3393
3451
  }
3394
3452
  return parts.join("\n");
@@ -5326,6 +5384,574 @@ function pickleStepArgumentToDocs(ps) {
5326
5384
  return docs;
5327
5385
  }
5328
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
+
5329
5955
  // src/index.ts
5330
5956
  var FORMAT_EXTENSIONS = {
5331
5957
  markdown: ".md",
@@ -5697,6 +6323,26 @@ OPTIONS
5697
6323
  --emit-canonical <path> Write canonical JSON to given path
5698
6324
  --help Show this help message
5699
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
+
5700
6346
  EXIT CODES
5701
6347
  0 Success
5702
6348
  1 Schema validation failure
@@ -5733,6 +6379,19 @@ function parseCliArgs(argv) {
5733
6379
  stdin: { type: "boolean", default: false },
5734
6380
  "json-summary": { type: "boolean", default: false },
5735
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 },
5736
6395
  help: { type: "boolean", default: false }
5737
6396
  },
5738
6397
  allowPositionals: true,
@@ -5764,6 +6423,53 @@ function parseCliArgs(argv) {
5764
6423
  }
5765
6424
  const noSynthesize = values["no-synthesize-stories"];
5766
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
+ }
5767
6473
  return {
5768
6474
  subcommand,
5769
6475
  inputFile,
@@ -5780,7 +6486,20 @@ function parseCliArgs(argv) {
5780
6486
  htmlNoMermaid: values["html-no-mermaid"],
5781
6487
  htmlNoMarkdown: values["html-no-markdown"],
5782
6488
  jsonSummary: values["json-summary"],
5783
- 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"]
5784
6503
  };
5785
6504
  }
5786
6505
  async function readInput(args) {
@@ -5864,6 +6583,8 @@ async function main() {
5864
6583
  }
5865
6584
  try {
5866
6585
  const result = await generateReports(run, args);
6586
+ await dispatchNotifications(run, args);
6587
+ runHistoryPipeline(run, args);
5867
6588
  printResult(result, args, startMs);
5868
6589
  process.exit(EXIT_SUCCESS);
5869
6590
  } catch (err) {
@@ -5920,6 +6641,8 @@ ${msg}`);
5920
6641
  }
5921
6642
  try {
5922
6643
  const result = await generateReports(run, args);
6644
+ await dispatchNotifications(run, args);
6645
+ runHistoryPipeline(run, args);
5923
6646
  printResult(result, args, startMs);
5924
6647
  process.exit(EXIT_SUCCESS);
5925
6648
  } catch (err) {
@@ -5975,6 +6698,8 @@ ${msg}`);
5975
6698
  }
5976
6699
  try {
5977
6700
  const result = await generateReports(canonical, args, droppedMissingStory);
6701
+ await dispatchNotifications(canonical, args);
6702
+ runHistoryPipeline(canonical, args);
5978
6703
  printResult(result, args, startMs, droppedMissingStory);
5979
6704
  process.exit(EXIT_SUCCESS);
5980
6705
  } catch (err) {
@@ -5983,6 +6708,85 @@ ${msg}`);
5983
6708
  process.exit(EXIT_GENERATION);
5984
6709
  }
5985
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
+ }
5986
6790
  async function generateReports(run, args, _droppedMissingStory = 0) {
5987
6791
  const generator = new ReportGenerator({
5988
6792
  include: args.include,