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/README.md +38 -1
- package/dist/adapters.d.cts +1 -1
- package/dist/adapters.d.ts +1 -1
- package/dist/cli.js +1132 -10
- package/dist/cli.js.map +1 -1
- package/dist/{index-DCJ0NvAp.d.cts → index-C4QO-SVT.d.cts} +58 -8
- package/dist/{index-DCJ0NvAp.d.ts → index-C4QO-SVT.d.ts} +58 -8
- package/dist/index.cjs +1224 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +403 -5
- package/dist/index.d.ts +403 -5
- package/dist/index.js +1205 -23
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/schemas/README.md +11 -2
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
|
-
|
|
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">▼</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(
|
|
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(
|
|
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,
|