@oneuptime/common 14.0.0 → 14.0.1
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/Models/DatabaseModels/Index.ts +4 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveFeed.ts +2 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.ts +522 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.ts +38 -4
- package/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.ts +603 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.ts +248 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.ts +19 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Services/DatabaseService.ts +10 -0
- package/Server/Services/Index.ts +4 -0
- package/Server/Services/MonitorService.ts +3 -2
- package/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.ts +385 -0
- package/Server/Services/ServiceLevelObjectiveLabelRuleService.ts +51 -0
- package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +92 -16
- package/Server/Services/ServiceLevelObjectiveMonitorRuleService.ts +36 -1
- package/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.ts +521 -0
- package/Server/Services/ServiceLevelObjectiveOwnerRuleService.ts +51 -0
- package/Server/Services/ServiceLevelObjectiveService.ts +28 -0
- package/Server/Types/Workflow/Components/Telegram/SendMessageToChat.ts +27 -1
- package/Server/Utils/AI/Toolbox/SloTools.ts +1 -0
- package/Server/Utils/Monitor/AffectedResourceList.ts +31 -73
- package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +1 -1
- package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +1 -1
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +181 -136
- package/Server/Utils/Monitor/RootCauseList.ts +139 -0
- package/Server/Utils/Rules/RuleRun/RuleRunRegistry.ts +28 -0
- package/Server/Utils/Slo/SloRulePatternValidator.ts +42 -0
- package/Tests/App/Dashboard/FeedFilterSortIntegration.test.tsx +1632 -0
- package/Tests/App/Dashboard/FeedOptionsLocales.test.ts +250 -0
- package/Tests/App/Dashboard/FeedOrdering.test.ts +279 -9
- package/Tests/App/Dashboard/InvestigationFeedRefresh.test.tsx +60 -1
- package/Tests/App/Dashboard/InvestigationPanel.test.tsx +594 -3
- package/Tests/App/Dashboard/ProductOrientation.test.tsx +646 -0
- package/Tests/App/Dashboard/ResourceFeed.test.tsx +612 -2
- package/Tests/App/Dashboard/ResourceFeedPageWiring.test.ts +30 -0
- package/Tests/App/Dashboard/ResourceFeedPagesFilterSort.test.tsx +1202 -0
- package/Tests/App/Dashboard/ScheduledMaintenanceFeedRefresh.test.tsx +52 -1
- package/Tests/App/Dashboard/SloFeed.test.tsx +106 -1
- package/Tests/App/Dashboard/SloMonitorRuleCriteria.test.tsx +566 -0
- package/Tests/App/Dashboard/SloNavigationWiring.test.tsx +6 -4
- package/Tests/App/Dashboard/SloNoticeBanner.test.tsx +186 -0
- package/Tests/App/Dashboard/SloOverviewKpiStrip.test.tsx +388 -44
- package/Tests/App/Dashboard/SloOverviewLayout.test.tsx +613 -0
- package/Tests/Models/DatabaseModels/ResourceFeedModels.test.ts +0 -8
- package/Tests/Models/DatabaseModels/SloLabelAndOwnerRuleModels.test.ts +404 -0
- package/Tests/Models/DatabaseModels/SloOverhaulModels.test.ts +17 -1
- package/Tests/Models/DatabaseModels/StaticRuleCriteriaInheritance.test.ts +4 -2
- package/Tests/Server/Infrastructure/Postgres/AddConfigurableRuleCriteriaMigration.test.ts +3 -0
- package/Tests/Server/Infrastructure/Postgres/AddSloLabelAndOwnerRulesMigration.test.ts +407 -0
- package/Tests/Server/Infrastructure/Postgres/AddSloMonitorRuleMonitorTypeMigration.test.ts +171 -0
- package/Tests/Server/Infrastructure/Postgres/SloProductOverhaulMigration.test.ts +88 -4
- package/Tests/Server/Services/DatabaseServiceRuleCriteriaValidation.test.ts +104 -0
- package/Tests/Server/Services/RuleRun/LabelRuleEnginesGroupBRun.test.ts +48 -1
- package/Tests/Server/Services/RuleRun/OwnerRuleEnginesGroupBRun.test.ts +30 -0
- package/Tests/Server/Services/ServiceLevelObjectiveLabelAndOwnerRuleEngines.test.ts +1170 -0
- package/Tests/Server/Services/ServiceLevelObjectiveLabelAndOwnerRuleServices.test.ts +172 -0
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +323 -0
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleService.test.ts +115 -0
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleTriggers.test.ts +21 -2
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorTypePostgres.test.ts +480 -0
- package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +162 -0
- package/Tests/Server/Services/StaticRuleCriteriaRuntimeCoverage.test.ts +45 -6
- package/Tests/Server/Types/Database/FeedEventTypeFilterQuery.test.ts +745 -0
- package/Tests/Server/Types/Markdown.test.ts +2 -1
- package/Tests/Server/Types/Workflow/Components/ApiComponentSsrf.test.ts +49 -0
- package/Tests/Server/Types/Workflow/Components/TelegramSendMessageToChat.test.ts +331 -0
- package/Tests/Server/Utils/AI/SloTools.test.ts +10 -0
- package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaAnomalyUnits.test.ts +1 -1
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluator.test.ts +405 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorBreachingSamples.test.ts +1101 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorMetricUnits.test.ts +106 -80
- package/Tests/Server/Utils/Monitor/RootCauseList.test.ts +252 -0
- package/Tests/Server/Utils/Rules/RuleRun/RuleRunPermission.test.ts +154 -0
- package/Tests/Server/Utils/SSRFProtection.test.ts +111 -0
- package/Tests/Server/Utils/Slo/SloRulePatternValidator.test.ts +67 -0
- package/Tests/Server/Utils/VM/VMRunnerSsrf.test.ts +213 -0
- package/Tests/Types/API/Route.test.ts +56 -0
- package/Tests/Types/API/URLSchemeLikePath.test.ts +304 -0
- package/Tests/UI/Components/FeedOptions.test.ts +1404 -0
- package/Tests/UI/Components/FeedOptionsButton.test.tsx +2603 -0
- package/Tests/UI/Components/Icon.test.tsx +26 -0
- package/Tests/UI/Components/RuleCriteria/RuleCriteriaModelTable.test.tsx +3 -3
- package/Tests/UI/Components/useFeedItemsViewKey.test.tsx +1017 -0
- package/Tests/UI/Components/useFeedOptions.test.tsx +1107 -0
- package/Tests/Utils/LabelRuleImportExport.test.ts +102 -0
- package/Tests/Utils/Slo/SloDuration.test.ts +115 -24
- package/Tests/Utils/Slo/SloHealth.test.ts +93 -10
- package/Tests/Utils/Slo/SloMonitorRuleCriteria.test.ts +109 -0
- package/Tests/Utils/Slo/SloOverviewText.test.ts +64 -0
- package/Types/API/Route.ts +19 -2
- package/Types/API/URL.ts +26 -2
- package/Types/Monitor/MetricMonitor/MetricCriteriaContext.ts +6 -5
- package/Types/Monitor/MetricMonitor/MetricMonitorResponse.ts +9 -0
- package/Types/Permission.ts +90 -0
- package/Types/Rules/RuleCriteriaFieldRegistry.ts +11 -0
- package/Types/Rules/RuleRun.ts +4 -0
- package/UI/Components/Feed/FeedOptions.ts +427 -0
- package/UI/Components/Feed/FeedOptionsButton.tsx +692 -0
- package/UI/Components/Feed/useFeedItems.ts +28 -9
- package/UI/Components/Feed/useFeedOptions.ts +225 -0
- package/UI/Components/Icon/Icon.tsx +1 -1
- package/Utils/LabelRuleImportExport.ts +27 -9
- package/Utils/Monitor/MetricValueFormatter.ts +2 -2
- package/Utils/Rules/MonitorTypeRuleCriteria.ts +31 -0
- package/Utils/Slo/SloDuration.ts +22 -6
- package/Utils/Slo/SloHealth.ts +8 -7
- package/Utils/Slo/SloMonitorRuleCriteria.ts +33 -1
- package/Utils/Slo/SloOverviewText.ts +33 -0
- package/Utils/Slo/SloProjection.ts +4 -3
- package/build/dist/Models/DatabaseModels/Index.js +4 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveFeed.js +2 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.js +530 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.js +39 -4
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.js +612 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.js +93 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.js +12 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +7 -0
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +4 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +3 -2
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.js +295 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleService.js +56 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +59 -14
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleService.js +22 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.js +400 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleService.js +56 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js +21 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/Telegram/SendMessageToChat.js +20 -1
- package/build/dist/Server/Types/Workflow/Components/Telegram/SendMessageToChat.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/SloTools.js +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/SloTools.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/AffectedResourceList.js +22 -51
- package/build/dist/Server/Utils/Monitor/AffectedResourceList.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +139 -109
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/RootCauseList.js +101 -0
- package/build/dist/Server/Utils/Monitor/RootCauseList.js.map +1 -0
- package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js +28 -0
- package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js.map +1 -1
- package/build/dist/Server/Utils/Slo/SloRulePatternValidator.js +28 -0
- package/build/dist/Server/Utils/Slo/SloRulePatternValidator.js.map +1 -0
- package/build/dist/Types/API/Route.js +18 -2
- package/build/dist/Types/API/Route.js.map +1 -1
- package/build/dist/Types/API/URL.js +23 -2
- package/build/dist/Types/API/URL.js.map +1 -1
- package/build/dist/Types/Permission.js +84 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js +11 -0
- package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js.map +1 -1
- package/build/dist/Types/Rules/RuleRun.js +4 -0
- package/build/dist/Types/Rules/RuleRun.js.map +1 -1
- package/build/dist/UI/Components/Feed/FeedOptions.js +235 -0
- package/build/dist/UI/Components/Feed/FeedOptions.js.map +1 -0
- package/build/dist/UI/Components/Feed/FeedOptionsButton.js +346 -0
- package/build/dist/UI/Components/Feed/FeedOptionsButton.js.map +1 -0
- package/build/dist/UI/Components/Feed/useFeedItems.js +17 -7
- package/build/dist/UI/Components/Feed/useFeedItems.js.map +1 -1
- package/build/dist/UI/Components/Feed/useFeedOptions.js +130 -0
- package/build/dist/UI/Components/Feed/useFeedOptions.js.map +1 -0
- package/build/dist/UI/Components/Icon/Icon.js +1 -1
- package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
- package/build/dist/Utils/LabelRuleImportExport.js +22 -6
- package/build/dist/Utils/LabelRuleImportExport.js.map +1 -1
- package/build/dist/Utils/Monitor/MetricValueFormatter.js +2 -2
- package/build/dist/Utils/Rules/MonitorTypeRuleCriteria.js +18 -0
- package/build/dist/Utils/Rules/MonitorTypeRuleCriteria.js.map +1 -0
- package/build/dist/Utils/Slo/SloDuration.js +13 -5
- package/build/dist/Utils/Slo/SloDuration.js.map +1 -1
- package/build/dist/Utils/Slo/SloHealth.js +8 -7
- package/build/dist/Utils/Slo/SloHealth.js.map +1 -1
- package/build/dist/Utils/Slo/SloMonitorRuleCriteria.js +16 -1
- package/build/dist/Utils/Slo/SloMonitorRuleCriteria.js.map +1 -1
- package/build/dist/Utils/Slo/SloOverviewText.js +16 -0
- package/build/dist/Utils/Slo/SloOverviewText.js.map +1 -1
- package/build/dist/Utils/Slo/SloProjection.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The evaluator's import chain pulls the native isolated-vm addon
|
|
3
|
+
* (MonitorCriteriaEvaluator → VMAPI → VMRunner). Nothing under test here
|
|
4
|
+
* touches the sandbox, and the prebuilt binary cannot always dlopen in the
|
|
5
|
+
* test environment — so stub the module out before anything imports it.
|
|
6
|
+
*/
|
|
7
|
+
jest.mock("isolated-vm", () => {
|
|
8
|
+
return {};
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
import MonitorCriteriaEvaluator from "../../../../Server/Utils/Monitor/MonitorCriteriaEvaluator";
|
|
12
|
+
import Markdown, {
|
|
13
|
+
MarkdownContentType,
|
|
14
|
+
} from "../../../../Server/Types/Markdown";
|
|
15
|
+
import SlackUtil from "../../../../Server/Utils/Workspace/Slack/Slack";
|
|
16
|
+
import Monitor from "../../../../Models/DatabaseModels/Monitor";
|
|
17
|
+
import MonitorStep from "../../../../Types/Monitor/MonitorStep";
|
|
18
|
+
import MonitorCriteriaInstance from "../../../../Types/Monitor/MonitorCriteriaInstance";
|
|
19
|
+
import MetricCriteriaContext, {
|
|
20
|
+
MetricBreachingSample,
|
|
21
|
+
} from "../../../../Types/Monitor/MetricMonitor/MetricCriteriaContext";
|
|
22
|
+
import {
|
|
23
|
+
CheckOn,
|
|
24
|
+
CriteriaFilter,
|
|
25
|
+
} from "../../../../Types/Monitor/CriteriaFilter";
|
|
26
|
+
import FilterCondition from "../../../../Types/Filter/FilterCondition";
|
|
27
|
+
import ObjectID from "../../../../Types/ObjectID";
|
|
28
|
+
import { marked, Tokens, Token } from "marked";
|
|
29
|
+
import { describe, expect, test } from "@jest/globals";
|
|
30
|
+
|
|
31
|
+
/*
|
|
32
|
+
* THE BREACHING SAMPLES LIST.
|
|
33
|
+
*
|
|
34
|
+
* A metric monitor's root cause lists the samples that breached. It was a
|
|
35
|
+
* GitHub-flavoured table — Timestamp | Metric | Alias | Value, one column
|
|
36
|
+
* per formula component and one per attribute key — that grew a column
|
|
37
|
+
* for every group-by attribute. In the ~416px email card every cell
|
|
38
|
+
* wrapped into fragments, Slack (mrkdwn has no tables) got raw pipe rows,
|
|
39
|
+
* and on the dashboard the widest column squeezed the rest.
|
|
40
|
+
*
|
|
41
|
+
* It is now a numbered list in the same visual language as the Affected
|
|
42
|
+
* Resources list — one item per sample, oldest first:
|
|
43
|
+
*
|
|
44
|
+
* 1. `2026-08-14T10:28:00.000Z` — **1.07 GB**
|
|
45
|
+
* - `a`: 537 MB
|
|
46
|
+
* - `k8s.pod.name`: `web-1`
|
|
47
|
+
*
|
|
48
|
+
* These tests pin the exact markdown, that it PARSES as that shape (one
|
|
49
|
+
* ordered list, each item owning a nested bullet list, nothing mistaken
|
|
50
|
+
* for a code block), and what it becomes in each place a root cause is
|
|
51
|
+
* read: the email (Markdown.convertToHTML), Slack
|
|
52
|
+
* (SlackUtil.convertMarkdownToSlackRichText) and plain text
|
|
53
|
+
* (Markdown.convertToPlainText). The value formatting itself — units,
|
|
54
|
+
* scaling, the formula guard — is pinned in
|
|
55
|
+
* MonitorCriteriaEvaluatorMetricUnits.test.ts.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
type EvaluatorPrivate = {
|
|
59
|
+
buildMetricRootCauseContext: (input: {
|
|
60
|
+
criteriaInstance: MonitorCriteriaInstance;
|
|
61
|
+
monitor: Monitor;
|
|
62
|
+
monitorStep?: MonitorStep | undefined;
|
|
63
|
+
}) => string | null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const Evaluator: EvaluatorPrivate =
|
|
67
|
+
MonitorCriteriaEvaluator as unknown as EvaluatorPrivate;
|
|
68
|
+
|
|
69
|
+
/*
|
|
70
|
+
* MarkdownViewer's ISO_8601_REGEX: an inline code span whose whole text
|
|
71
|
+
* matches it is re-rendered in the viewer's timezone on the dashboard.
|
|
72
|
+
*/
|
|
73
|
+
const DASHBOARD_LOCALIZED_TIMESTAMP: RegExp =
|
|
74
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
|
|
75
|
+
|
|
76
|
+
function at(minute: number): Date {
|
|
77
|
+
return new Date(Date.UTC(2026, 7, 14, 10, minute, 0, 0));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function iso(minute: number): string {
|
|
81
|
+
return at(minute).toISOString();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function makeContext(
|
|
85
|
+
overrides: Partial<MetricCriteriaContext> = {},
|
|
86
|
+
): MetricCriteriaContext {
|
|
87
|
+
return {
|
|
88
|
+
metricName: "k8s.pod.memory.usage",
|
|
89
|
+
alias: "a",
|
|
90
|
+
unit: "By",
|
|
91
|
+
aggregationType: null,
|
|
92
|
+
isFormula: false,
|
|
93
|
+
filterAttributes: {},
|
|
94
|
+
groupBy: ["k8s.namespace.name", "k8s.pod.name"],
|
|
95
|
+
totalSamplesInWindow: 10,
|
|
96
|
+
// Out of order on purpose: the list is chronological.
|
|
97
|
+
breachingSamples: [
|
|
98
|
+
{
|
|
99
|
+
value: 1258291,
|
|
100
|
+
timestamp: at(30),
|
|
101
|
+
attributes: {
|
|
102
|
+
"k8s.namespace.name": "payments",
|
|
103
|
+
"k8s.pod.name": "web-2",
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
value: 1073741824,
|
|
108
|
+
timestamp: at(28),
|
|
109
|
+
attributes: {
|
|
110
|
+
"k8s.namespace.name": "payments",
|
|
111
|
+
"k8s.pod.name": "web-1",
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
value: 2147483648,
|
|
116
|
+
timestamp: at(29),
|
|
117
|
+
attributes: {
|
|
118
|
+
"k8s.namespace.name": "payments",
|
|
119
|
+
"k8s.pod.name": "web-1",
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
...overrides,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function formulaContext(
|
|
128
|
+
overrides: Partial<MetricCriteriaContext> = {},
|
|
129
|
+
): MetricCriteriaContext {
|
|
130
|
+
return makeContext({
|
|
131
|
+
metricName: "a + b",
|
|
132
|
+
alias: "c",
|
|
133
|
+
isFormula: true,
|
|
134
|
+
formulaExpression: "a + b",
|
|
135
|
+
unit: "By",
|
|
136
|
+
groupBy: ["host.name"],
|
|
137
|
+
totalSamplesInWindow: 5,
|
|
138
|
+
components: [
|
|
139
|
+
{
|
|
140
|
+
alias: "a",
|
|
141
|
+
name: "container.memory.usage",
|
|
142
|
+
unit: "By",
|
|
143
|
+
isFormula: false,
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
alias: "b",
|
|
147
|
+
name: "http.server.duration",
|
|
148
|
+
unit: "ms",
|
|
149
|
+
isFormula: false,
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
breachingSamples: [
|
|
153
|
+
{
|
|
154
|
+
value: 805306368,
|
|
155
|
+
timestamp: at(29),
|
|
156
|
+
attributes: { "host.name": "prod-01" },
|
|
157
|
+
componentValues: [
|
|
158
|
+
{ alias: "a", value: 536870912 },
|
|
159
|
+
{ alias: "b", value: 1500 },
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
value: 1073741824,
|
|
164
|
+
timestamp: at(30),
|
|
165
|
+
attributes: { "host.name": "prod-02" },
|
|
166
|
+
// b had no data point at this timestamp.
|
|
167
|
+
componentValues: [
|
|
168
|
+
{ alias: "a", value: 1073741824 },
|
|
169
|
+
{ alias: "b", value: null },
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
...overrides,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// `count` samples one minute apart from 10:00, valued 1, 2, 3, ...
|
|
178
|
+
function manySamples(count: number): Array<MetricBreachingSample> {
|
|
179
|
+
const samples: Array<MetricBreachingSample> = [];
|
|
180
|
+
|
|
181
|
+
for (let i: number = 1; i <= count; i++) {
|
|
182
|
+
samples.push({
|
|
183
|
+
value: i,
|
|
184
|
+
timestamp: at(i - 1),
|
|
185
|
+
attributes: { "host.name": `host-${i}` },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Newest first, so the cap has to sort before it slices.
|
|
190
|
+
return samples.reverse();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function makeCriteriaInstance(
|
|
194
|
+
ctx: MetricCriteriaContext,
|
|
195
|
+
): MonitorCriteriaInstance {
|
|
196
|
+
const filter: CriteriaFilter = {
|
|
197
|
+
checkOn: CheckOn.MetricValue,
|
|
198
|
+
metricCriteriaContext: ctx,
|
|
199
|
+
} as CriteriaFilter;
|
|
200
|
+
|
|
201
|
+
const instance: MonitorCriteriaInstance = new MonitorCriteriaInstance();
|
|
202
|
+
instance.data = {
|
|
203
|
+
monitorStatusId: undefined,
|
|
204
|
+
filterCondition: FilterCondition.All,
|
|
205
|
+
filters: [filter],
|
|
206
|
+
incidents: [],
|
|
207
|
+
alerts: [],
|
|
208
|
+
name: "Memory breach",
|
|
209
|
+
description: "Memory breach",
|
|
210
|
+
id: ObjectID.generate().toString(),
|
|
211
|
+
};
|
|
212
|
+
return instance;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function rootCause(ctx: MetricCriteriaContext, monitor?: Monitor): string {
|
|
216
|
+
const context: string | null = Evaluator.buildMetricRootCauseContext({
|
|
217
|
+
criteriaInstance: makeCriteriaInstance(ctx),
|
|
218
|
+
monitor: monitor || new Monitor(),
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(context).not.toBeNull();
|
|
222
|
+
return context as string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/*
|
|
226
|
+
* The Breaching Samples section on its own: from its heading to the end of
|
|
227
|
+
* the root cause, which is where it sits when the monitor has no project
|
|
228
|
+
* (so no explorer links follow it).
|
|
229
|
+
*/
|
|
230
|
+
function section(ctx: MetricCriteriaContext): string {
|
|
231
|
+
const context: string = rootCause(ctx);
|
|
232
|
+
const start: number = context.indexOf("**Breaching Samples**");
|
|
233
|
+
|
|
234
|
+
expect(start).toBeGreaterThan(-1);
|
|
235
|
+
return context.slice(start);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The top-level block tokens marked produces, without the blank-line spacers.
|
|
239
|
+
function blocks(markdown: string): Array<Token> {
|
|
240
|
+
return marked.lexer(markdown).filter((token: Token): boolean => {
|
|
241
|
+
return token.type !== "space";
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function orderedLists(markdown: string): Array<Tokens.List> {
|
|
246
|
+
return blocks(markdown).filter((token: Token): boolean => {
|
|
247
|
+
return token.type === "list" && (token as Tokens.List).ordered;
|
|
248
|
+
}) as Array<Tokens.List>;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function nestedList(item: Tokens.ListItem): Tokens.List | undefined {
|
|
252
|
+
return item.tokens.find((token: Token): boolean => {
|
|
253
|
+
return token.type === "list";
|
|
254
|
+
}) as Tokens.List | undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function detailTexts(item: Tokens.ListItem): Array<string> {
|
|
258
|
+
const nested: Tokens.List | undefined = nestedList(item);
|
|
259
|
+
|
|
260
|
+
if (!nested) {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return nested.items.map((detail: Tokens.ListItem) => {
|
|
265
|
+
return detail.text;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Every token type in the tree, list items included.
|
|
270
|
+
function allTokenTypes(tokens: Array<Token>): Array<string> {
|
|
271
|
+
const types: Array<string> = [];
|
|
272
|
+
|
|
273
|
+
for (const token of tokens) {
|
|
274
|
+
types.push(token.type);
|
|
275
|
+
|
|
276
|
+
const children: Array<Token> | undefined = (
|
|
277
|
+
token as { tokens?: Array<Token> }
|
|
278
|
+
).tokens;
|
|
279
|
+
|
|
280
|
+
if (children) {
|
|
281
|
+
types.push(...allTokenTypes(children));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (token.type === "list") {
|
|
285
|
+
for (const item of (token as Tokens.List).items) {
|
|
286
|
+
types.push(...allTokenTypes(item.tokens));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return types;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// The inline tokens of a list item's own line (not its nested list).
|
|
295
|
+
function itemInlineTokens(item: Tokens.ListItem): Array<Token> {
|
|
296
|
+
const text: Tokens.Text | undefined = item.tokens.find(
|
|
297
|
+
(token: Token): boolean => {
|
|
298
|
+
return token.type === "text";
|
|
299
|
+
},
|
|
300
|
+
) as Tokens.Text | undefined;
|
|
301
|
+
|
|
302
|
+
expect(text).toBeDefined();
|
|
303
|
+
return text!.tokens || [];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
describe("Breaching Samples - markdown", () => {
|
|
307
|
+
test("renders the whole metric root cause with the samples as a numbered list", () => {
|
|
308
|
+
expect(rootCause(makeContext())).toBe(
|
|
309
|
+
[
|
|
310
|
+
"**Metric Details**",
|
|
311
|
+
"- Metric: `k8s.pod.memory.usage`",
|
|
312
|
+
"- Alias: `a`",
|
|
313
|
+
"- Unit: Bytes",
|
|
314
|
+
"- Grouped By: `k8s.namespace.name`, `k8s.pod.name`",
|
|
315
|
+
"",
|
|
316
|
+
"",
|
|
317
|
+
"**Breaching Samples**",
|
|
318
|
+
"3 of 10 samples breached the threshold.",
|
|
319
|
+
"",
|
|
320
|
+
"1. `2026-08-14T10:28:00.000Z` — **1.07 GB**",
|
|
321
|
+
" - `k8s.namespace.name`: `payments`",
|
|
322
|
+
" - `k8s.pod.name`: `web-1`",
|
|
323
|
+
"2. `2026-08-14T10:29:00.000Z` — **2.15 GB**",
|
|
324
|
+
" - `k8s.namespace.name`: `payments`",
|
|
325
|
+
" - `k8s.pod.name`: `web-1`",
|
|
326
|
+
"3. `2026-08-14T10:30:00.000Z` — **1.26 MB**",
|
|
327
|
+
" - `k8s.namespace.name`: `payments`",
|
|
328
|
+
" - `k8s.pod.name`: `web-2`",
|
|
329
|
+
].join("\n"),
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("renders a formula's component values and attributes under each sample", () => {
|
|
334
|
+
expect(section(formulaContext())).toBe(
|
|
335
|
+
[
|
|
336
|
+
"**Breaching Samples**",
|
|
337
|
+
"2 of 5 samples breached the threshold.",
|
|
338
|
+
"",
|
|
339
|
+
"1. `2026-08-14T10:29:00.000Z` — **805 MB**",
|
|
340
|
+
" - `a`: 537 MB",
|
|
341
|
+
" - `b`: 1.5 sec",
|
|
342
|
+
" - `host.name`: `prod-01`",
|
|
343
|
+
"2. `2026-08-14T10:30:00.000Z` — **1.07 GB**",
|
|
344
|
+
" - `a`: 1.07 GB",
|
|
345
|
+
" - `host.name`: `prod-02`",
|
|
346
|
+
].join("\n"),
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("a sample with no components and no attributes is a single line", () => {
|
|
351
|
+
expect(
|
|
352
|
+
section(
|
|
353
|
+
makeContext({
|
|
354
|
+
groupBy: [],
|
|
355
|
+
totalSamplesInWindow: undefined,
|
|
356
|
+
breachingSamples: [
|
|
357
|
+
{ value: 1073741824, timestamp: at(30), attributes: {} },
|
|
358
|
+
],
|
|
359
|
+
}),
|
|
360
|
+
),
|
|
361
|
+
).toBe(
|
|
362
|
+
[
|
|
363
|
+
"**Breaching Samples**",
|
|
364
|
+
"1 sample breached the threshold.",
|
|
365
|
+
"",
|
|
366
|
+
"1. `2026-08-14T10:30:00.000Z` — **1.07 GB**",
|
|
367
|
+
].join("\n"),
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("never emits GFM table syntax", () => {
|
|
372
|
+
for (const ctx of [
|
|
373
|
+
makeContext(),
|
|
374
|
+
formulaContext(),
|
|
375
|
+
makeContext({ breachingSamples: manySamples(25) }),
|
|
376
|
+
]) {
|
|
377
|
+
const markdown: string = section(ctx);
|
|
378
|
+
|
|
379
|
+
expect(markdown).not.toMatch(/^\s*\|/m);
|
|
380
|
+
expect(markdown).not.toContain("| --- |");
|
|
381
|
+
expect(markdown).not.toContain("Timestamp");
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("lists the samples oldest first, whatever order they arrive in", () => {
|
|
386
|
+
const markdown: string = section(makeContext());
|
|
387
|
+
|
|
388
|
+
const first: number = markdown.indexOf(`1. \`${iso(28)}\``);
|
|
389
|
+
const second: number = markdown.indexOf(`2. \`${iso(29)}\``);
|
|
390
|
+
const third: number = markdown.indexOf(`3. \`${iso(30)}\``);
|
|
391
|
+
|
|
392
|
+
expect(first).toBeGreaterThan(-1);
|
|
393
|
+
expect(second).toBeGreaterThan(first);
|
|
394
|
+
expect(third).toBeGreaterThan(second);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("timestamps are bare ISO 8601 code spans, which the dashboard localizes", () => {
|
|
398
|
+
const list: Tokens.List = orderedLists(section(makeContext()))[0]!;
|
|
399
|
+
|
|
400
|
+
for (const item of list.items) {
|
|
401
|
+
const first: Token = itemInlineTokens(item)[0]!;
|
|
402
|
+
|
|
403
|
+
expect(first.type).toBe("codespan");
|
|
404
|
+
expect((first as Tokens.Codespan).text).toMatch(
|
|
405
|
+
DASHBOARD_LOCALIZED_TIMESTAMP,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("a timestamp that arrives as a string (a context read back from JSON) is still ISO", () => {
|
|
411
|
+
const markdown: string = section(
|
|
412
|
+
makeContext({
|
|
413
|
+
breachingSamples: [
|
|
414
|
+
{
|
|
415
|
+
value: 1073741824,
|
|
416
|
+
timestamp: "2026-08-14T10:30:00.000Z" as unknown as Date,
|
|
417
|
+
attributes: {},
|
|
418
|
+
},
|
|
419
|
+
],
|
|
420
|
+
}),
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
expect(markdown).toContain("1. `2026-08-14T10:30:00.000Z` — **1.07 GB**");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("the value is bold at the end of the item's line", () => {
|
|
427
|
+
const list: Tokens.List = orderedLists(section(makeContext()))[0]!;
|
|
428
|
+
const inline: Array<Token> = itemInlineTokens(list.items[0]!);
|
|
429
|
+
const last: Token = inline[inline.length - 1]!;
|
|
430
|
+
|
|
431
|
+
expect(last.type).toBe("strong");
|
|
432
|
+
expect((last as Tokens.Strong).text).toBe("1.07 GB");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
/*
|
|
436
|
+
* The table's Metric and Alias columns printed the same two values on
|
|
437
|
+
* every row — the ones the Metric Details list right above states once.
|
|
438
|
+
*/
|
|
439
|
+
test("does not repeat the metric name or alias on each sample", () => {
|
|
440
|
+
const context: string = rootCause(makeContext());
|
|
441
|
+
const markdown: string = section(makeContext());
|
|
442
|
+
|
|
443
|
+
expect(markdown).not.toContain("k8s.pod.memory.usage");
|
|
444
|
+
expect(markdown).not.toContain("Metric:");
|
|
445
|
+
expect(markdown).not.toContain("Alias:");
|
|
446
|
+
expect(markdown).not.toContain("`a`");
|
|
447
|
+
|
|
448
|
+
// Still stated once, above.
|
|
449
|
+
expect(context).toContain("- Metric: `k8s.pod.memory.usage`");
|
|
450
|
+
expect(context).toContain("- Alias: `a`");
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("does not repeat a formula's expression or alias on each sample either", () => {
|
|
454
|
+
const markdown: string = section(formulaContext());
|
|
455
|
+
|
|
456
|
+
expect(markdown).not.toContain("a + b");
|
|
457
|
+
expect(markdown).not.toContain("`c`");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("the summary line is unchanged and precedes the list in the heading's paragraph", () => {
|
|
461
|
+
expect(section(makeContext())).toMatch(
|
|
462
|
+
/^\*\*Breaching Samples\*\*\n3 of 10 samples breached the threshold\.\n\n1\. /,
|
|
463
|
+
);
|
|
464
|
+
expect(section(makeContext({ totalSamplesInWindow: undefined }))).toContain(
|
|
465
|
+
"**Breaching Samples**\n3 samples breached the threshold.\n",
|
|
466
|
+
);
|
|
467
|
+
// A total smaller than the breach count is ignored rather than contradicted.
|
|
468
|
+
expect(section(makeContext({ totalSamplesInWindow: 2 }))).toContain(
|
|
469
|
+
"\n3 samples breached the threshold.\n",
|
|
470
|
+
);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("falls back to the single breachingSample when there is no list of them", () => {
|
|
474
|
+
const markdown: string = section(
|
|
475
|
+
makeContext({
|
|
476
|
+
breachingSamples: undefined,
|
|
477
|
+
breachingSample: {
|
|
478
|
+
value: 1073741824,
|
|
479
|
+
timestamp: at(30),
|
|
480
|
+
attributes: { "k8s.pod.name": "web-1" },
|
|
481
|
+
},
|
|
482
|
+
}),
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
expect(markdown).toContain(
|
|
486
|
+
"1. `2026-08-14T10:30:00.000Z` — **1.07 GB**\n - `k8s.pod.name`: `web-1`",
|
|
487
|
+
);
|
|
488
|
+
expect(markdown).not.toContain("2. ");
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("no Breaching Samples section at all when nothing breached", () => {
|
|
492
|
+
const context: string = rootCause(
|
|
493
|
+
makeContext({ breachingSamples: [], breachingSample: undefined }),
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
expect(context).not.toContain("Breaching Samples");
|
|
497
|
+
expect(context).toContain("**Metric Details**");
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
describe("Breaching Samples - formula components", () => {
|
|
502
|
+
test("each component is labelled by its alias and formatted in its own unit", () => {
|
|
503
|
+
const list: Tokens.List = orderedLists(section(formulaContext()))[0]!;
|
|
504
|
+
|
|
505
|
+
expect(detailTexts(list.items[0]!)).toEqual([
|
|
506
|
+
"`a`: 537 MB",
|
|
507
|
+
"`b`: 1.5 sec",
|
|
508
|
+
"`host.name`: `prod-01`",
|
|
509
|
+
]);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("a component with a null value is left out rather than printed as a dash", () => {
|
|
513
|
+
const markdown: string = section(formulaContext());
|
|
514
|
+
|
|
515
|
+
expect(markdown).toContain(
|
|
516
|
+
"2. `2026-08-14T10:30:00.000Z` — **1.07 GB**\n - `a`: 1.07 GB\n - `host.name`: `prod-02`",
|
|
517
|
+
);
|
|
518
|
+
expect(markdown).not.toContain("`b`: -");
|
|
519
|
+
expect(markdown).not.toMatch(/: -$/m);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test("a component the sample has no entry for is left out too", () => {
|
|
523
|
+
const ctx: MetricCriteriaContext = formulaContext();
|
|
524
|
+
ctx.breachingSamples![0]!.componentValues = [{ alias: "b", value: 1500 }];
|
|
525
|
+
|
|
526
|
+
const list: Tokens.List = orderedLists(section(ctx))[0]!;
|
|
527
|
+
|
|
528
|
+
expect(detailTexts(list.items[0]!)).toEqual([
|
|
529
|
+
"`b`: 1.5 sec",
|
|
530
|
+
"`host.name`: `prod-01`",
|
|
531
|
+
]);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("a sample with no componentValues lists only its attributes", () => {
|
|
535
|
+
const ctx: MetricCriteriaContext = formulaContext();
|
|
536
|
+
delete ctx.breachingSamples![0]!.componentValues;
|
|
537
|
+
|
|
538
|
+
const list: Tokens.List = orderedLists(section(ctx))[0]!;
|
|
539
|
+
|
|
540
|
+
expect(detailTexts(list.items[0]!)).toEqual(["`host.name`: `prod-01`"]);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("components are listed in the formula's order, before the attributes", () => {
|
|
544
|
+
const ctx: MetricCriteriaContext = formulaContext();
|
|
545
|
+
// componentValues arrive in a different order from the components.
|
|
546
|
+
ctx.breachingSamples![0]!.componentValues = [
|
|
547
|
+
{ alias: "b", value: 1500 },
|
|
548
|
+
{ alias: "a", value: 536870912 },
|
|
549
|
+
];
|
|
550
|
+
|
|
551
|
+
const list: Tokens.List = orderedLists(section(ctx))[0]!;
|
|
552
|
+
|
|
553
|
+
expect(detailTexts(list.items[0]!)).toEqual([
|
|
554
|
+
"`a`: 537 MB",
|
|
555
|
+
"`b`: 1.5 sec",
|
|
556
|
+
"`host.name`: `prod-01`",
|
|
557
|
+
]);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("a component value of zero is shown, not treated as missing", () => {
|
|
561
|
+
const ctx: MetricCriteriaContext = formulaContext();
|
|
562
|
+
ctx.breachingSamples![0]!.componentValues = [
|
|
563
|
+
{ alias: "a", value: 0 },
|
|
564
|
+
{ alias: "b", value: 1500 },
|
|
565
|
+
];
|
|
566
|
+
|
|
567
|
+
const list: Tokens.List = orderedLists(section(ctx))[0]!;
|
|
568
|
+
|
|
569
|
+
expect(detailTexts(list.items[0]!)[0]).toMatch(/^`a`: 0/);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("a component value that is not a number is left out", () => {
|
|
573
|
+
const ctx: MetricCriteriaContext = formulaContext();
|
|
574
|
+
ctx.breachingSamples![0]!.componentValues = [
|
|
575
|
+
{ alias: "a", value: "537" as unknown as number },
|
|
576
|
+
{ alias: "b", value: 1500 },
|
|
577
|
+
];
|
|
578
|
+
|
|
579
|
+
const list: Tokens.List = orderedLists(section(ctx))[0]!;
|
|
580
|
+
|
|
581
|
+
expect(detailTexts(list.items[0]!)).toEqual([
|
|
582
|
+
"`b`: 1.5 sec",
|
|
583
|
+
"`host.name`: `prod-01`",
|
|
584
|
+
]);
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
describe("Breaching Samples - attributes", () => {
|
|
589
|
+
test("lists every attribute of the sample as `key`: `value`", () => {
|
|
590
|
+
const list: Tokens.List = orderedLists(section(makeContext()))[0]!;
|
|
591
|
+
|
|
592
|
+
expect(detailTexts(list.items[2]!)).toEqual([
|
|
593
|
+
"`k8s.namespace.name`: `payments`",
|
|
594
|
+
"`k8s.pod.name`: `web-2`",
|
|
595
|
+
]);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("a missing, null or empty attribute is left out rather than printed as a dash", () => {
|
|
599
|
+
const markdown: string = section(
|
|
600
|
+
makeContext({
|
|
601
|
+
breachingSamples: [
|
|
602
|
+
{
|
|
603
|
+
value: 1,
|
|
604
|
+
timestamp: at(28),
|
|
605
|
+
attributes: { "k8s.pod.name": "web-1", "k8s.node.name": null },
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
value: 2,
|
|
609
|
+
timestamp: at(29),
|
|
610
|
+
attributes: {
|
|
611
|
+
"k8s.pod.name": "",
|
|
612
|
+
"k8s.node.name": "node-a",
|
|
613
|
+
region: " ",
|
|
614
|
+
},
|
|
615
|
+
},
|
|
616
|
+
],
|
|
617
|
+
unit: null,
|
|
618
|
+
}),
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
expect(markdown).toContain(
|
|
622
|
+
[
|
|
623
|
+
"1. `2026-08-14T10:28:00.000Z` — **1**",
|
|
624
|
+
" - `k8s.pod.name`: `web-1`",
|
|
625
|
+
"2. `2026-08-14T10:29:00.000Z` — **2**",
|
|
626
|
+
" - `k8s.node.name`: `node-a`",
|
|
627
|
+
].join("\n"),
|
|
628
|
+
);
|
|
629
|
+
expect(markdown).not.toContain("region");
|
|
630
|
+
expect(markdown).not.toMatch(/: -$/m);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("every sample lists its attributes in the same order, first seen first", () => {
|
|
634
|
+
const markdown: string = section(
|
|
635
|
+
makeContext({
|
|
636
|
+
unit: null,
|
|
637
|
+
breachingSamples: [
|
|
638
|
+
{
|
|
639
|
+
value: 1,
|
|
640
|
+
timestamp: at(28),
|
|
641
|
+
attributes: { zone: "z1", host: "h1" },
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
value: 2,
|
|
645
|
+
timestamp: at(29),
|
|
646
|
+
// Same keys, different insertion order, plus a new one.
|
|
647
|
+
attributes: { pod: "p2", host: "h2", zone: "z2" },
|
|
648
|
+
},
|
|
649
|
+
],
|
|
650
|
+
}),
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
expect(markdown).toContain(
|
|
654
|
+
[
|
|
655
|
+
"1. `2026-08-14T10:28:00.000Z` — **1**",
|
|
656
|
+
" - `zone`: `z1`",
|
|
657
|
+
" - `host`: `h1`",
|
|
658
|
+
"2. `2026-08-14T10:29:00.000Z` — **2**",
|
|
659
|
+
" - `zone`: `z2`",
|
|
660
|
+
" - `host`: `h2`",
|
|
661
|
+
" - `pod`: `p2`",
|
|
662
|
+
].join("\n"),
|
|
663
|
+
);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("numbers and booleans are shown as text", () => {
|
|
667
|
+
const markdown: string = section(
|
|
668
|
+
makeContext({
|
|
669
|
+
breachingSamples: [
|
|
670
|
+
{
|
|
671
|
+
value: 1073741824,
|
|
672
|
+
timestamp: at(30),
|
|
673
|
+
attributes: { "http.status_code": 503, "error.flag": false },
|
|
674
|
+
},
|
|
675
|
+
],
|
|
676
|
+
}),
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
expect(markdown).toContain(" - `http.status_code`: `503`");
|
|
680
|
+
expect(markdown).toContain(" - `error.flag`: `false`");
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test("a backtick in a key or value cannot close its code span early", () => {
|
|
684
|
+
const markdown: string = section(
|
|
685
|
+
makeContext({
|
|
686
|
+
breachingSamples: [
|
|
687
|
+
{
|
|
688
|
+
value: 1073741824,
|
|
689
|
+
timestamp: at(30),
|
|
690
|
+
attributes: { "odd`key": "we``ird" },
|
|
691
|
+
},
|
|
692
|
+
],
|
|
693
|
+
}),
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
expect(markdown).toContain(" - `` odd`key ``: ``` we``ird ```");
|
|
697
|
+
|
|
698
|
+
const list: Tokens.List = orderedLists(markdown)[0]!;
|
|
699
|
+
const detail: Tokens.ListItem = nestedList(list.items[0]!)!.items[0]!;
|
|
700
|
+
const codespans: Array<string> = (
|
|
701
|
+
(detail.tokens[0] as Tokens.Text).tokens || []
|
|
702
|
+
)
|
|
703
|
+
.filter((token: Token): boolean => {
|
|
704
|
+
return token.type === "codespan";
|
|
705
|
+
})
|
|
706
|
+
.map((token: Token) => {
|
|
707
|
+
return (token as Tokens.Codespan).text;
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
expect(codespans).toEqual(["odd`key", "we``ird"]);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test("a line break in a value cannot split the item", () => {
|
|
714
|
+
const markdown: string = section(
|
|
715
|
+
makeContext({
|
|
716
|
+
breachingSamples: [
|
|
717
|
+
{
|
|
718
|
+
value: 1073741824,
|
|
719
|
+
timestamp: at(30),
|
|
720
|
+
attributes: { message: "first line\n\n2. second line" },
|
|
721
|
+
},
|
|
722
|
+
],
|
|
723
|
+
}),
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
expect(markdown).toContain(" - `message`: `first line 2. second line`");
|
|
727
|
+
expect(orderedLists(markdown)[0]!.items).toHaveLength(1);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
test("markdown and HTML in an attribute stay inert inside the code span in the email", async () => {
|
|
731
|
+
const markdown: string = section(
|
|
732
|
+
makeContext({
|
|
733
|
+
breachingSamples: [
|
|
734
|
+
{
|
|
735
|
+
value: 1073741824,
|
|
736
|
+
timestamp: at(30),
|
|
737
|
+
attributes: {
|
|
738
|
+
"**key**":
|
|
739
|
+
'**bold** [x](https://evil.example) <img src=x onerror="alert(1)">',
|
|
740
|
+
},
|
|
741
|
+
},
|
|
742
|
+
],
|
|
743
|
+
}),
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
const html: string = await Markdown.convertToHTML(
|
|
747
|
+
markdown,
|
|
748
|
+
MarkdownContentType.Email,
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
expect(html).not.toContain("<strong>bold</strong>");
|
|
752
|
+
expect(html).not.toContain("<strong>key</strong>");
|
|
753
|
+
expect(html).not.toContain('href="https://evil.example"');
|
|
754
|
+
expect(html).not.toContain("<img");
|
|
755
|
+
expect(html).toMatch(/<code[^>]*>\*\*key\*\*<\/code>/);
|
|
756
|
+
expect(html).toMatch(
|
|
757
|
+
/<code[^>]*>\*\*bold\*\* \[x\]\(https:\/\/evil\.example\) <img src=x onerror="alert\(1\)"><\/code>/,
|
|
758
|
+
);
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
describe("Breaching Samples - the 20-sample cap", () => {
|
|
763
|
+
test("shows the first 20 samples chronologically and says how many there were", () => {
|
|
764
|
+
const markdown: string = section(
|
|
765
|
+
makeContext({
|
|
766
|
+
unit: null,
|
|
767
|
+
totalSamplesInWindow: 60,
|
|
768
|
+
breachingSamples: manySamples(25),
|
|
769
|
+
}),
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
const list: Tokens.List = orderedLists(markdown)[0]!;
|
|
773
|
+
|
|
774
|
+
expect(list.items).toHaveLength(20);
|
|
775
|
+
expect(markdown).toContain("25 of 60 samples breached the threshold.");
|
|
776
|
+
expect(markdown).toContain(`1. \`${iso(0)}\` — **1**`);
|
|
777
|
+
expect(markdown).toContain(`20. \`${iso(19)}\` — **20**`);
|
|
778
|
+
expect(markdown).not.toContain(iso(20));
|
|
779
|
+
expect(markdown).not.toContain("21. ");
|
|
780
|
+
expect(
|
|
781
|
+
markdown.endsWith("\n\n_Showing the first 20 of 25 breaching samples._"),
|
|
782
|
+
).toBe(true);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test("indents the details of items 10-20 to their wider content column", () => {
|
|
786
|
+
const markdown: string = section(
|
|
787
|
+
makeContext({ unit: null, breachingSamples: manySamples(25) }),
|
|
788
|
+
);
|
|
789
|
+
|
|
790
|
+
expect(markdown).toContain(
|
|
791
|
+
`9. \`${iso(8)}\` — **9**\n - \`host.name\`: \`host-9\`\n10. \`${iso(9)}\` — **10**\n - \`host.name\`: \`host-10\``,
|
|
792
|
+
);
|
|
793
|
+
expect(markdown).toContain(
|
|
794
|
+
`20. \`${iso(19)}\` — **20**\n - \`host.name\`: \`host-20\`\n\n_Showing`,
|
|
795
|
+
);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
test("every one of the 20 items owns its details, and nothing parses as a code block", () => {
|
|
799
|
+
const markdown: string = section(
|
|
800
|
+
makeContext({ unit: null, breachingSamples: manySamples(25) }),
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
const list: Tokens.List = orderedLists(markdown)[0]!;
|
|
804
|
+
|
|
805
|
+
list.items.forEach((item: Tokens.ListItem, index: number) => {
|
|
806
|
+
expect(detailTexts(item)).toEqual([
|
|
807
|
+
`\`host.name\`: \`host-${index + 1}\``,
|
|
808
|
+
]);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
expect(allTokenTypes(marked.lexer(markdown))).not.toContain("code");
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
test("the note is a paragraph of its own after the list, not part of the last item", () => {
|
|
815
|
+
const tokens: Array<Token> = blocks(
|
|
816
|
+
section(makeContext({ unit: null, breachingSamples: manySamples(25) })),
|
|
817
|
+
);
|
|
818
|
+
|
|
819
|
+
expect(
|
|
820
|
+
tokens.map((token: Token) => {
|
|
821
|
+
return token.type;
|
|
822
|
+
}),
|
|
823
|
+
).toEqual(["paragraph", "list", "paragraph"]);
|
|
824
|
+
expect((tokens[2] as Tokens.Paragraph).text).toBe(
|
|
825
|
+
"_Showing the first 20 of 25 breaching samples._",
|
|
826
|
+
);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
test("exactly 20 samples are all shown, with no note", () => {
|
|
830
|
+
const markdown: string = section(
|
|
831
|
+
makeContext({ unit: null, breachingSamples: manySamples(20) }),
|
|
832
|
+
);
|
|
833
|
+
|
|
834
|
+
expect(orderedLists(markdown)[0]!.items).toHaveLength(20);
|
|
835
|
+
expect(markdown).not.toContain("Showing the first");
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
test("21 samples show 20 and note the one left out", () => {
|
|
839
|
+
const markdown: string = section(
|
|
840
|
+
makeContext({ unit: null, breachingSamples: manySamples(21) }),
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
expect(orderedLists(markdown)[0]!.items).toHaveLength(20);
|
|
844
|
+
expect(markdown).toContain(
|
|
845
|
+
"_Showing the first 20 of 21 breaching samples._",
|
|
846
|
+
);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
test("attribute keys that only appear on samples past the cap are not listed", () => {
|
|
850
|
+
const samples: Array<MetricBreachingSample> = manySamples(21);
|
|
851
|
+
// The newest sample (first in the array) is the one the cap drops.
|
|
852
|
+
samples[0]!.attributes = { "only.on.21": "x" };
|
|
853
|
+
|
|
854
|
+
const markdown: string = section(
|
|
855
|
+
makeContext({ unit: null, breachingSamples: samples }),
|
|
856
|
+
);
|
|
857
|
+
|
|
858
|
+
expect(markdown).not.toContain("only.on.21");
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
describe("Breaching Samples - parses as one ordered list of samples", () => {
|
|
863
|
+
test("marked sees the heading paragraph and one ordered list", () => {
|
|
864
|
+
const tokens: Array<Token> = blocks(section(makeContext()));
|
|
865
|
+
|
|
866
|
+
expect(
|
|
867
|
+
tokens.map((token: Token) => {
|
|
868
|
+
return token.type;
|
|
869
|
+
}),
|
|
870
|
+
).toEqual(["paragraph", "list"]);
|
|
871
|
+
|
|
872
|
+
const list: Tokens.List = tokens[1] as Tokens.List;
|
|
873
|
+
|
|
874
|
+
expect(list.ordered).toBe(true);
|
|
875
|
+
expect(list.start).toBe(1);
|
|
876
|
+
expect(list.loose).toBe(false);
|
|
877
|
+
expect(list.items).toHaveLength(3);
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
test("every item owns its details as a nested bullet list", () => {
|
|
881
|
+
const list: Tokens.List = orderedLists(section(makeContext()))[0]!;
|
|
882
|
+
|
|
883
|
+
expect(
|
|
884
|
+
list.items.map((item: Tokens.ListItem) => {
|
|
885
|
+
const nested: Tokens.List | undefined = nestedList(item);
|
|
886
|
+
|
|
887
|
+
expect(nested).toBeDefined();
|
|
888
|
+
expect(nested!.ordered).toBe(false);
|
|
889
|
+
|
|
890
|
+
return detailTexts(item);
|
|
891
|
+
}),
|
|
892
|
+
).toEqual([
|
|
893
|
+
["`k8s.namespace.name`: `payments`", "`k8s.pod.name`: `web-1`"],
|
|
894
|
+
["`k8s.namespace.name`: `payments`", "`k8s.pod.name`: `web-1`"],
|
|
895
|
+
["`k8s.namespace.name`: `payments`", "`k8s.pod.name`: `web-2`"],
|
|
896
|
+
]);
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
test("the whole root cause has exactly one ordered list, and no code block", () => {
|
|
900
|
+
for (const ctx of [makeContext(), formulaContext()]) {
|
|
901
|
+
const context: string = rootCause(ctx);
|
|
902
|
+
|
|
903
|
+
expect(orderedLists(context)).toHaveLength(1);
|
|
904
|
+
expect(allTokenTypes(marked.lexer(context))).not.toContain("code");
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test("the explorer link after the list is its own paragraph, not part of the last sample", () => {
|
|
909
|
+
const monitor: Monitor = new Monitor();
|
|
910
|
+
monitor.projectId = ObjectID.generate();
|
|
911
|
+
|
|
912
|
+
const context: string = rootCause(makeContext(), monitor);
|
|
913
|
+
const tokens: Array<Token> = blocks(context);
|
|
914
|
+
const listIndex: number = tokens.findIndex((token: Token): boolean => {
|
|
915
|
+
return token.type === "list" && (token as Tokens.List).ordered;
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
expect(listIndex).toBeGreaterThan(-1);
|
|
919
|
+
|
|
920
|
+
const list: Tokens.List = tokens[listIndex] as Tokens.List;
|
|
921
|
+
expect(list.items).toHaveLength(3);
|
|
922
|
+
expect(list.items[2]!.raw).not.toContain("Open metric in dashboard");
|
|
923
|
+
|
|
924
|
+
const after: Token | undefined = tokens[listIndex + 1];
|
|
925
|
+
expect(after?.type).toBe("paragraph");
|
|
926
|
+
expect((after as Tokens.Paragraph).text).toContain(
|
|
927
|
+
"[Open metric in dashboard](",
|
|
928
|
+
);
|
|
929
|
+
});
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
describe("Breaching Samples - in the email", () => {
|
|
933
|
+
test("renders as a styled <ol> with a nested <ul> in each <li>, and no table", async () => {
|
|
934
|
+
const html: string = await Markdown.convertToHTML(
|
|
935
|
+
rootCause(makeContext()),
|
|
936
|
+
MarkdownContentType.Email,
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
expect(html).not.toContain("<table");
|
|
940
|
+
expect(html.match(/<ol /g)).toHaveLength(1);
|
|
941
|
+
expect(html).toMatch(/<ol style="[^"]+">/);
|
|
942
|
+
|
|
943
|
+
expect(html).toMatch(
|
|
944
|
+
/<li style="[^"]+">\s*<code[^>]*>2026-08-14T10:28:00\.000Z<\/code> — <strong>1\.07 GB<\/strong>\s*<ul style="[^"]+">\s*<li style="[^"]+"><code[^>]*>k8s\.namespace\.name<\/code>: <code[^>]*>payments<\/code><\/li>\s*<li style="[^"]+"><code[^>]*>k8s\.pod\.name<\/code>: <code[^>]*>web-1<\/code><\/li>\s*<\/ul>\s*<\/li>/,
|
|
945
|
+
);
|
|
946
|
+
expect(html).toMatch(
|
|
947
|
+
/<code[^>]*>2026-08-14T10:30:00\.000Z<\/code> — <strong>1\.26 MB<\/strong>/,
|
|
948
|
+
);
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
test("renders a formula's components as nested bullets", async () => {
|
|
952
|
+
const html: string = await Markdown.convertToHTML(
|
|
953
|
+
section(formulaContext()),
|
|
954
|
+
MarkdownContentType.Email,
|
|
955
|
+
);
|
|
956
|
+
|
|
957
|
+
expect(html).toMatch(
|
|
958
|
+
/<ul style="[^"]+">\s*<li style="[^"]+"><code[^>]*>a<\/code>: 537 MB<\/li>\s*<li style="[^"]+"><code[^>]*>b<\/code>: 1\.5 sec<\/li>\s*<li style="[^"]+"><code[^>]*>host\.name<\/code>: <code[^>]*>prod-01<\/code><\/li>\s*<\/ul>/,
|
|
959
|
+
);
|
|
960
|
+
expect(html).not.toContain("-</");
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
test("the heading and summary come before the list", async () => {
|
|
964
|
+
const html: string = await Markdown.convertToHTML(
|
|
965
|
+
section(makeContext()),
|
|
966
|
+
MarkdownContentType.Email,
|
|
967
|
+
);
|
|
968
|
+
|
|
969
|
+
const heading: number = html.indexOf("<strong>Breaching Samples</strong>");
|
|
970
|
+
const summary: number = html.indexOf(
|
|
971
|
+
"3 of 10 samples breached the threshold.",
|
|
972
|
+
);
|
|
973
|
+
const list: number = html.indexOf("<ol");
|
|
974
|
+
|
|
975
|
+
expect(heading).toBeGreaterThan(-1);
|
|
976
|
+
expect(summary).toBeGreaterThan(heading);
|
|
977
|
+
expect(list).toBeGreaterThan(summary);
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
test("the cap note follows the list; it is not inside the last item", async () => {
|
|
981
|
+
const html: string = await Markdown.convertToHTML(
|
|
982
|
+
section(makeContext({ unit: null, breachingSamples: manySamples(25) })),
|
|
983
|
+
MarkdownContentType.Email,
|
|
984
|
+
);
|
|
985
|
+
|
|
986
|
+
const listEnd: number = html.lastIndexOf("</ol>");
|
|
987
|
+
const note: number = html.indexOf(
|
|
988
|
+
"Showing the first 20 of 25 breaching samples.",
|
|
989
|
+
);
|
|
990
|
+
|
|
991
|
+
expect(note).toBeGreaterThan(listEnd);
|
|
992
|
+
expect(html).toContain(
|
|
993
|
+
"<em>Showing the first 20 of 25 breaching samples.</em>",
|
|
994
|
+
);
|
|
995
|
+
expect(
|
|
996
|
+
html.match(
|
|
997
|
+
/<code[^>]*>2026-08-14T10:\d{2}:00\.000Z<\/code> — <strong>\d+<\/strong>/g,
|
|
998
|
+
),
|
|
999
|
+
).toHaveLength(20);
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
describe("Breaching Samples - in Slack", () => {
|
|
1004
|
+
test("renders as numbered lines with bullet details, not a table dump", () => {
|
|
1005
|
+
const slack: string = SlackUtil.convertMarkdownToSlackRichText(
|
|
1006
|
+
section(makeContext()),
|
|
1007
|
+
);
|
|
1008
|
+
|
|
1009
|
+
expect(slack).not.toContain("_Row 1_");
|
|
1010
|
+
expect(slack).not.toMatch(/^\s*\|/m);
|
|
1011
|
+
/*
|
|
1012
|
+
* slackify-markdown turns **bold** into Slack's *bold* and fences it
|
|
1013
|
+
* with zero-width spaces so it binds next to punctuation.
|
|
1014
|
+
*/
|
|
1015
|
+
expect(slack).toMatch(
|
|
1016
|
+
/1\.\s+`2026-08-14T10:28:00\.000Z` — \u200B?\*1\.07 GB\*\u200B?\n/,
|
|
1017
|
+
);
|
|
1018
|
+
expect(slack).toMatch(
|
|
1019
|
+
/\n\s+•\s+`k8s\.namespace\.name`: `payments`\n\s+•\s+`k8s\.pod\.name`: `web-1`\n/,
|
|
1020
|
+
);
|
|
1021
|
+
expect(slack).toMatch(
|
|
1022
|
+
/\n3\.\s+`2026-08-14T10:30:00\.000Z` — \u200B?\*1\.26 MB\*\u200B?\n/,
|
|
1023
|
+
);
|
|
1024
|
+
expect(slack).toContain("3 of 10 samples breached the threshold.");
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
test("renders a formula's components as bullets under their sample", () => {
|
|
1028
|
+
const slack: string = SlackUtil.convertMarkdownToSlackRichText(
|
|
1029
|
+
section(formulaContext()),
|
|
1030
|
+
);
|
|
1031
|
+
|
|
1032
|
+
expect(slack).toMatch(
|
|
1033
|
+
/\n\s+•\s+`a`: 537 MB\n\s+•\s+`b`: 1\.5 sec\n\s+•\s+`host\.name`: `prod-01`\n/,
|
|
1034
|
+
);
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
test("keeps all 20 items numbered in order, with the cap note after them", () => {
|
|
1038
|
+
const slack: string = SlackUtil.convertMarkdownToSlackRichText(
|
|
1039
|
+
section(makeContext({ unit: null, breachingSamples: manySamples(25) })),
|
|
1040
|
+
);
|
|
1041
|
+
|
|
1042
|
+
for (let i: number = 1; i <= 20; i++) {
|
|
1043
|
+
expect(slack).toMatch(
|
|
1044
|
+
new RegExp(`(^|\\n)${i}\\.\\s+\`${iso(i - 1).replace(/\./g, "\\.")}\``),
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
expect(slack).not.toMatch(/(^|\n)21\.\s/);
|
|
1049
|
+
expect(
|
|
1050
|
+
slack.indexOf("Showing the first 20 of 25 breaching samples."),
|
|
1051
|
+
).toBeGreaterThan(slack.indexOf(`\`${iso(19)}\``));
|
|
1052
|
+
});
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
describe("Breaching Samples - as plain text", () => {
|
|
1056
|
+
test("reads as one line per sample and per detail", () => {
|
|
1057
|
+
expect(
|
|
1058
|
+
Markdown.convertToPlainText(section(makeContext())).split("\n"),
|
|
1059
|
+
).toEqual([
|
|
1060
|
+
"Breaching Samples",
|
|
1061
|
+
"3 of 10 samples breached the threshold.",
|
|
1062
|
+
"2026-08-14T10:28:00.000Z — 1.07 GB",
|
|
1063
|
+
"k8s.namespace.name: payments",
|
|
1064
|
+
"k8s.pod.name: web-1",
|
|
1065
|
+
"2026-08-14T10:29:00.000Z — 2.15 GB",
|
|
1066
|
+
"k8s.namespace.name: payments",
|
|
1067
|
+
"k8s.pod.name: web-1",
|
|
1068
|
+
"2026-08-14T10:30:00.000Z — 1.26 MB",
|
|
1069
|
+
"k8s.namespace.name: payments",
|
|
1070
|
+
"k8s.pod.name: web-2",
|
|
1071
|
+
]);
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
test("a formula's components read as alias: value", () => {
|
|
1075
|
+
expect(
|
|
1076
|
+
Markdown.convertToPlainText(section(formulaContext())).split("\n"),
|
|
1077
|
+
).toEqual([
|
|
1078
|
+
"Breaching Samples",
|
|
1079
|
+
"2 of 5 samples breached the threshold.",
|
|
1080
|
+
"2026-08-14T10:29:00.000Z — 805 MB",
|
|
1081
|
+
"a: 537 MB",
|
|
1082
|
+
"b: 1.5 sec",
|
|
1083
|
+
"host.name: prod-01",
|
|
1084
|
+
"2026-08-14T10:30:00.000Z — 1.07 GB",
|
|
1085
|
+
"a: 1.07 GB",
|
|
1086
|
+
"host.name: prod-02",
|
|
1087
|
+
]);
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
test("the cap note is the last line", () => {
|
|
1091
|
+
const lines: Array<string> = Markdown.convertToPlainText(
|
|
1092
|
+
section(makeContext({ unit: null, breachingSamples: manySamples(25) })),
|
|
1093
|
+
).split("\n");
|
|
1094
|
+
|
|
1095
|
+
expect(lines[lines.length - 1]).toBe(
|
|
1096
|
+
"Showing the first 20 of 25 breaching samples.",
|
|
1097
|
+
);
|
|
1098
|
+
expect(lines).toContain(`${iso(19)} — 20`);
|
|
1099
|
+
expect(lines).toContain("host.name: host-20");
|
|
1100
|
+
});
|
|
1101
|
+
});
|