executable-stories-formatters 0.4.0 → 0.6.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
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);
@@ -2633,6 +2652,21 @@ body {
2633
2652
  }
2634
2653
  }
2635
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
+
2636
2670
  `;
2637
2671
 
2638
2672
  // src/formatters/html/renderers/status.ts
@@ -2666,7 +2700,22 @@ function renderMetaInfo(args, deps) {
2666
2700
  items.push(`<dt>Git:</dt><dd>${deps.escapeHtml(shortSha)}</dd>`);
2667
2701
  }
2668
2702
  if (args.ciName) {
2669
- 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
+ );
2670
2719
  }
2671
2720
  return `<dl class="meta-info">${items.join("")}</dl>`;
2672
2721
  }
@@ -2917,6 +2966,14 @@ function highlightStepParams(text, deps) {
2917
2966
  return result;
2918
2967
  }
2919
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
+
2920
2977
  // src/formatters/html/renderers/scenario.ts
2921
2978
  function renderScenario(args, deps) {
2922
2979
  const { tc } = args;
@@ -2937,6 +2994,19 @@ function renderScenario(args, deps) {
2937
2994
  traceBadge = `<span class="tag trace-tag" title="${deps.escapeHtml(otelMeta.traceId)}">${deps.escapeHtml(shortId)}\u2026</span>`;
2938
2995
  }
2939
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
+ }
2940
3010
  const storyDocs = deps.renderDocs(tc.story.docs, "story-docs");
2941
3011
  const steps = deps.renderSteps(
2942
3012
  { steps: tc.story.steps, stepResults: tc.stepResults },
@@ -2971,7 +3041,7 @@ function renderScenario(args, deps) {
2971
3041
  <span class="status-icon ${statusClass}">${statusIcon}</span>
2972
3042
  <span class="scenario-name">${deps.escapeHtml(tc.story.scenario)}</span>
2973
3043
  </div>
2974
- <div class="scenario-meta">${tags}${traceBadge}</div>
3044
+ <div class="scenario-meta">${tags}${traceBadge}${metricBadges}</div>
2975
3045
  </div>
2976
3046
  <span class="scenario-duration">${duration}</span>
2977
3047
  </div>
@@ -3183,7 +3253,12 @@ function renderFeature(args, deps) {
3183
3253
  const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
3184
3254
  const collapsedClass = deps.startCollapsed ? " collapsed" : "";
3185
3255
  const ariaExpanded = !deps.startCollapsed;
3186
- 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");
3187
3262
  return `
3188
3263
  <div class="feature${collapsedClass}">
3189
3264
  <div class="feature-header" role="button" tabindex="0" aria-expanded="${ariaExpanded}">
@@ -3228,7 +3303,11 @@ function buildBody(args, deps) {
3228
3303
  durationMs: run.durationMs,
3229
3304
  packageVersion: run.packageVersion,
3230
3305
  gitSha: run.gitSha,
3231
- 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
3232
3311
  },
3233
3312
  deps.metaDeps
3234
3313
  )
@@ -3257,7 +3336,10 @@ function buildBody(args, deps) {
3257
3336
  const byFile = groupBy(run.testCases, (tc) => tc.sourceFile);
3258
3337
  for (const [file, testCases] of byFile) {
3259
3338
  parts.push(
3260
- deps.renderFeature({ file, testCases }, deps.featureDeps)
3339
+ deps.renderFeature(
3340
+ { file, testCases, metricsMap: args.metricsMap },
3341
+ deps.featureDeps
3342
+ )
3261
3343
  );
3262
3344
  }
3263
3345
  return parts.join("\n");
@@ -5729,45 +5811,102 @@ function clearVersionCache() {
5729
5811
 
5730
5812
  // src/utils/ci-detect.ts
5731
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
+ }
5732
5843
  if (env.GITHUB_ACTIONS === "true") {
5733
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;
5734
5848
  return {
5735
5849
  name: "github",
5850
+ provider: "github",
5736
5851
  buildNumber: env.GITHUB_RUN_NUMBER,
5737
- 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
5738
5867
  };
5739
5868
  }
5740
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;
5741
5873
  return {
5742
5874
  name: "circleci",
5875
+ provider: "circleci",
5743
5876
  buildNumber: env.CIRCLE_BUILD_NUM,
5744
- url: env.CIRCLE_BUILD_URL
5877
+ url: env.CIRCLE_BUILD_URL,
5878
+ branch: env.CIRCLE_BRANCH,
5879
+ commitSha: env.CIRCLE_SHA1,
5880
+ prNumber
5745
5881
  };
5746
5882
  }
5747
5883
  if (env.JENKINS_URL !== void 0) {
5748
5884
  return {
5749
5885
  name: "jenkins",
5886
+ provider: "jenkins",
5750
5887
  buildNumber: env.BUILD_NUMBER,
5751
- url: env.BUILD_URL
5888
+ url: env.BUILD_URL,
5889
+ branch: env.GIT_BRANCH,
5890
+ commitSha: env.GIT_COMMIT
5752
5891
  };
5753
5892
  }
5754
5893
  if (env.TRAVIS === "true") {
5894
+ const prRaw = env.TRAVIS_PULL_REQUEST;
5895
+ const prNumber = prRaw && prRaw !== "false" ? prRaw : void 0;
5755
5896
  return {
5756
5897
  name: "travis",
5898
+ provider: "travis",
5757
5899
  buildNumber: env.TRAVIS_BUILD_NUMBER,
5758
- url: env.TRAVIS_BUILD_WEB_URL
5759
- };
5760
- }
5761
- if (env.GITLAB_CI === "true") {
5762
- return {
5763
- name: "gitlab",
5764
- buildNumber: env.CI_PIPELINE_IID,
5765
- url: env.CI_PIPELINE_URL
5900
+ url: env.TRAVIS_BUILD_WEB_URL,
5901
+ branch: env.TRAVIS_BRANCH,
5902
+ commitSha: env.TRAVIS_COMMIT,
5903
+ prNumber
5766
5904
  };
5767
5905
  }
5768
5906
  if (env.CI === "true") {
5769
5907
  return {
5770
- name: "ci"
5908
+ name: "ci",
5909
+ provider: "unknown"
5771
5910
  };
5772
5911
  }
5773
5912
  return void 0;
@@ -5799,6 +5938,731 @@ function resolveTraceUrl(template, traceId) {
5799
5938
  return template.replace(/\{traceId\}/g, traceId);
5800
5939
  }
5801
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
+
5802
6666
  // src/index.ts
5803
6667
  var FORMAT_EXTENSIONS = {
5804
6668
  markdown: ".md",
@@ -6148,6 +7012,9 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
6148
7012
  CucumberMessagesFormatter,
6149
7013
  HtmlFormatter,
6150
7014
  JUnitFormatter,
7015
+ MIN_FLAKINESS_SAMPLES,
7016
+ MIN_METRIC_SAMPLES,
7017
+ MIN_PERF_SAMPLES,
6151
7018
  MarkdownFormatter,
6152
7019
  ReportGenerator,
6153
7020
  STORY_META_KEY,
@@ -6155,15 +7022,21 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
6155
7022
  adaptPlaywrightRun,
6156
7023
  adaptVitestRun,
6157
7024
  assertValidRun,
7025
+ calculateFlakiness,
7026
+ calculateStability,
6158
7027
  canonicalizeRun,
6159
7028
  clearVersionCache,
7029
+ computeTestMetrics,
6160
7030
  createReportGenerator,
6161
7031
  deriveStepResults,
6162
7032
  detectCI,
7033
+ detectPerformanceTrend,
6163
7034
  findGitDir,
6164
7035
  formatDuration,
6165
7036
  generateRunId,
6166
7037
  generateTestCaseId,
7038
+ hasSufficientHistory,
7039
+ loadHistory,
6167
7040
  mergeStepResults,
6168
7041
  msToNanoseconds,
6169
7042
  nanosecondsToMs,
@@ -6179,8 +7052,18 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
6179
7052
  resolveAttachment,
6180
7053
  resolveAttachments,
6181
7054
  resolveTraceUrl,
7055
+ saveHistory,
7056
+ sendNotifications,
7057
+ sendSlackNotification,
7058
+ sendTeamsNotification,
7059
+ sendWebhookNotification,
7060
+ signBody,
6182
7061
  slugify,
7062
+ stripAnsi,
7063
+ toCIInfo,
7064
+ toRawCIInfo,
6183
7065
  tryGetActiveOtelContext,
7066
+ updateHistory,
6184
7067
  validateCanonicalRun
6185
7068
  });
6186
7069
  //# sourceMappingURL=index.cjs.map