@oneuptime/common 12.0.19 → 12.0.21
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/AnalyticsModels/ChangeEvent.ts +331 -0
- package/Models/AnalyticsModels/Index.ts +2 -0
- package/Server/API/EnterpriseLicenseAPI.ts +94 -19
- package/Server/EnvironmentConfig.ts +15 -5
- package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +0 -2
- package/Server/Services/ChangeEventService.ts +11 -0
- package/Server/Services/EnterpriseLicenseService.ts +3 -3
- package/Server/Services/Index.ts +2 -0
- package/Server/Services/ProjectService.ts +104 -0
- package/Server/Utils/Attribution.ts +11 -11
- package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
- package/Server/Utils/Response.ts +2 -2
- package/Server/Utils/StartServer.ts +2 -2
- package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
- package/Server/Views/Partials/AnalyticsConsent.ejs +441 -0
- package/Tests/App/Dashboard/AlertEpisodeViewFields.test.tsx +465 -0
- package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +366 -0
- package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +408 -0
- package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
- package/Tests/App/Dashboard/IncidentEpisodeViewFields.test.tsx +474 -0
- package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
- package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
- package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
- package/Tests/App/Dashboard/MetricSeriesInvestigateMenu.test.tsx +368 -0
- package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
- package/Tests/Server/API/EnterpriseLicenseReportUserCount.test.ts +763 -0
- package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
- package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +138 -0
- package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +516 -0
- package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
- package/Tests/Server/Utils/Attribution.test.ts +4 -5
- package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
- package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
- package/Tests/Server/Utils/ResponseRenderAnalytics.test.ts +53 -0
- package/Tests/Server/Views/AnalyticsConsentPartial.test.ts +241 -0
- package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
- package/Tests/UI/Components/Charts/ChartTooltipSorted.test.tsx +129 -0
- package/Tests/UI/Components/Charts/TooltipEntries.test.ts +191 -0
- package/Tests/UI/Components/Detail/DetailFieldErrors.test.tsx +347 -0
- package/Tests/UI/Components/Forms/BasicFormStepValidation.test.tsx +392 -0
- package/Tests/UI/Components/Forms/ValidationFormSteps.test.ts +549 -0
- package/Tests/Utils/EnterpriseLicenseSync.test.ts +562 -0
- package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
- package/Types/Analytics/RevenueEvent.ts +1 -0
- package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
- package/Types/Marketing/Attribution.ts +9 -15
- package/Types/Marketing/MarketingEvent.ts +19 -12
- package/UI/Components/Charts/Area/AreaChart.tsx +23 -1
- package/UI/Components/Charts/Bar/BarChart.tsx +8 -1
- package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
- package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +208 -97
- package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +26 -3
- package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +232 -118
- package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +120 -0
- package/UI/Components/Charts/Line/LineChart.tsx +23 -1
- package/UI/Components/Detail/Detail.tsx +18 -1
- package/Utils/EnterpriseLicense/EnterpriseLicenseSync.ts +244 -0
- package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
- package/Utils/Telemetry/CrossSignalScope.ts +19 -10
- package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
- package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
- package/build/dist/Models/AnalyticsModels/Index.js +2 -0
- package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
- package/build/dist/Server/API/EnterpriseLicenseAPI.js +66 -12
- package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +14 -5
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +0 -2
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/ChangeEventService.js +9 -0
- package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
- package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
- package/build/dist/Server/Services/Index.js +2 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/ProjectService.js +88 -0
- package/build/dist/Server/Services/ProjectService.js.map +1 -1
- package/build/dist/Server/Utils/Attribution.js +6 -6
- package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
- package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
- package/build/dist/Server/Utils/Response.js +2 -2
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +2 -2
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
- package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
- package/build/dist/Types/Analytics/RevenueEvent.js +1 -0
- package/build/dist/Types/Analytics/RevenueEvent.js.map +1 -1
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
- package/build/dist/Types/Marketing/Attribution.js +9 -15
- package/build/dist/Types/Marketing/Attribution.js.map +1 -1
- package/build/dist/Types/Marketing/MarketingEvent.js +12 -4
- package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +10 -2
- package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js +9 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +114 -51
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +35 -23
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +122 -55
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +86 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -0
- package/build/dist/UI/Components/Charts/Line/LineChart.js +10 -2
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Detail/Detail.js +17 -1
- package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js +174 -0
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js.map +1 -0
- package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
- package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
- package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
- package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
- package/package.json +1 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js.map +0 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, expect, test } from "@jest/globals";
|
|
2
|
+
import ejs from "ejs";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Consent and attribution have to behave the same on every origin that loads
|
|
8
|
+
* the Google Tag Manager container.
|
|
9
|
+
*
|
|
10
|
+
* They did not. Consent Mode v2 and the attribution capture lived only in
|
|
11
|
+
* Home/Views/head-basic.ejs, so the marketing site denied storage by default
|
|
12
|
+
* and asked, while /accounts and /dashboard loaded the same container with no
|
|
13
|
+
* consent signal and captured nothing. A visitor who pressed "Reject all" was
|
|
14
|
+
* tracked anyway on the signup page — which is precisely where the conversion
|
|
15
|
+
* fires — and an ad pointed at /accounts/register?gclid=... stored no click id,
|
|
16
|
+
* so that signup could never be attributed to the campaign that bought it.
|
|
17
|
+
*
|
|
18
|
+
* These render each origin the way Express does and assert the invariants that
|
|
19
|
+
* were being violated. They are deliberately about ORDER and SAMENESS: a
|
|
20
|
+
* consent default that arrives after the container has read the dataLayer is
|
|
21
|
+
* not a consent default, and three copies of the storage contract is how the
|
|
22
|
+
* copies stop agreeing.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const REPOSITORY_ROOT: string = path.resolve(__dirname, "..", "..", "..", "..");
|
|
26
|
+
|
|
27
|
+
const PARTIAL_PATH: string = path.join(
|
|
28
|
+
REPOSITORY_ROOT,
|
|
29
|
+
"Common",
|
|
30
|
+
"Server",
|
|
31
|
+
"Views",
|
|
32
|
+
"Partials",
|
|
33
|
+
"AnalyticsConsent.ejs",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
interface Origin {
|
|
37
|
+
label: string;
|
|
38
|
+
viewPath: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/*
|
|
42
|
+
* Every OneUptime-owned origin that loads GTM-PKQD5WH. Status pages and public
|
|
43
|
+
* dashboards deliberately are not here: they render on customer-owned custom
|
|
44
|
+
* domains, where the answer is to remove the tag rather than to ask the
|
|
45
|
+
* customer's visitors for consent on OneUptime's behalf.
|
|
46
|
+
*/
|
|
47
|
+
const ORIGINS: Array<Origin> = [
|
|
48
|
+
{
|
|
49
|
+
label: "marketing site",
|
|
50
|
+
viewPath: path.join(REPOSITORY_ROOT, "Home", "Views", "head-basic.ejs"),
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
label: "accounts (signup)",
|
|
54
|
+
viewPath: path.join(
|
|
55
|
+
REPOSITORY_ROOT,
|
|
56
|
+
"App",
|
|
57
|
+
"FeatureSet",
|
|
58
|
+
"Accounts",
|
|
59
|
+
"views",
|
|
60
|
+
"index.ejs",
|
|
61
|
+
),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
label: "dashboard",
|
|
65
|
+
viewPath: path.join(
|
|
66
|
+
REPOSITORY_ROOT,
|
|
67
|
+
"App",
|
|
68
|
+
"FeatureSet",
|
|
69
|
+
"Dashboard",
|
|
70
|
+
"views",
|
|
71
|
+
"index.ejs",
|
|
72
|
+
),
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
label: "admin dashboard",
|
|
76
|
+
viewPath: path.join(
|
|
77
|
+
REPOSITORY_ROOT,
|
|
78
|
+
"App",
|
|
79
|
+
"FeatureSet",
|
|
80
|
+
"AdminDashboard",
|
|
81
|
+
"views",
|
|
82
|
+
"index.ejs",
|
|
83
|
+
),
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
type RenderOriginFunction = (
|
|
88
|
+
origin: Origin,
|
|
89
|
+
enableGoogleTagManager: boolean,
|
|
90
|
+
) => string;
|
|
91
|
+
|
|
92
|
+
const renderOrigin: RenderOriginFunction = (
|
|
93
|
+
origin: Origin,
|
|
94
|
+
enableGoogleTagManager: boolean,
|
|
95
|
+
): string => {
|
|
96
|
+
return ejs.render(
|
|
97
|
+
fs.readFileSync(origin.viewPath, "utf-8"),
|
|
98
|
+
{ enableGoogleTagManager: enableGoogleTagManager },
|
|
99
|
+
{ filename: origin.viewPath },
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const GTM_SCRIPT: string = "googletagmanager.com/gtm.js";
|
|
104
|
+
const CONSENT_DEFAULT: string = "pushConsentSignal('default', false)";
|
|
105
|
+
|
|
106
|
+
describe("the shared analytics consent partial", () => {
|
|
107
|
+
test("exists in Common, so every origin can reach it", () => {
|
|
108
|
+
expect(fs.existsSync(PARTIAL_PATH)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("does not carry Home-only concerns", () => {
|
|
112
|
+
const source: string = fs.readFileSync(PARTIAL_PATH, "utf-8");
|
|
113
|
+
|
|
114
|
+
/*
|
|
115
|
+
* The PostHog loader carries Home's own project key, and the home/-prefixed
|
|
116
|
+
* event names are the marketing site's. Moving either here would start the
|
|
117
|
+
* product reporting into Home's funnel. Reporting is handed back to the
|
|
118
|
+
* page through window.oneUptimeOnAttributionCaptured instead.
|
|
119
|
+
*/
|
|
120
|
+
expect(source).not.toContain("posthog.init(");
|
|
121
|
+
// The prose may mention them; no code here may emit one.
|
|
122
|
+
expect(source).not.toContain("capture('home/");
|
|
123
|
+
expect(source).toContain("window.oneUptimeOnAttributionCaptured");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
for (const origin of ORIGINS) {
|
|
128
|
+
describe(origin.label, () => {
|
|
129
|
+
test("defines the consent state machine", () => {
|
|
130
|
+
expect(renderOrigin(origin, true)).toContain(
|
|
131
|
+
"window.oneUptimeConsent = (function",
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("captures ad click identifiers", () => {
|
|
136
|
+
const html: string = renderOrigin(origin, true);
|
|
137
|
+
|
|
138
|
+
expect(html).toContain("window.oneUptimeGetAttribution");
|
|
139
|
+
expect(html).toContain("gclid");
|
|
140
|
+
expect(html).toContain("wbraid");
|
|
141
|
+
expect(html).toContain("gbraid");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("reads and writes the same consent storage key as every other origin", () => {
|
|
145
|
+
/*
|
|
146
|
+
* These origins share one hostname on OneUptime Cloud, so they share one
|
|
147
|
+
* localStorage. A different key here would mean the banner answer given
|
|
148
|
+
* on the marketing site simply did not apply in the product.
|
|
149
|
+
*/
|
|
150
|
+
expect(renderOrigin(origin, true)).toContain("'cookiesAccepted'");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("pushes the Consent Mode defaults before the container loads", () => {
|
|
154
|
+
const html: string = renderOrigin(origin, true);
|
|
155
|
+
|
|
156
|
+
const consentIndex: number = html.indexOf(CONSENT_DEFAULT);
|
|
157
|
+
const containerIndex: number = html.indexOf(GTM_SCRIPT);
|
|
158
|
+
|
|
159
|
+
expect(consentIndex).toBeGreaterThan(-1);
|
|
160
|
+
expect(containerIndex).toBeGreaterThan(-1);
|
|
161
|
+
|
|
162
|
+
/*
|
|
163
|
+
* The whole point. Consent Mode defaults are only read once, when the
|
|
164
|
+
* container boots; arriving afterwards is the same as never arriving.
|
|
165
|
+
*/
|
|
166
|
+
expect(consentIndex).toBeLessThan(containerIndex);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("denies ad and analytics storage until the visitor answers", () => {
|
|
170
|
+
const html: string = renderOrigin(origin, true);
|
|
171
|
+
|
|
172
|
+
expect(html).toContain("ad_storage");
|
|
173
|
+
expect(html).toContain("ad_user_data");
|
|
174
|
+
expect(html).toContain("ad_personalization");
|
|
175
|
+
expect(html).toContain("analytics_storage");
|
|
176
|
+
// `unset` is treated as denied rather than as permission-by-silence.
|
|
177
|
+
expect(html).toContain(CONSENT_DEFAULT);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("loads no Google tag at all when analytics are switched off", () => {
|
|
181
|
+
const html: string = renderOrigin(origin, false);
|
|
182
|
+
|
|
183
|
+
expect(html).not.toContain(GTM_SCRIPT);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
describe("consent can be withdrawn", () => {
|
|
189
|
+
const bannerPath: string = path.join(
|
|
190
|
+
REPOSITORY_ROOT,
|
|
191
|
+
"Home",
|
|
192
|
+
"Views",
|
|
193
|
+
"cookie-banner.ejs",
|
|
194
|
+
);
|
|
195
|
+
const footerPath: string = path.join(
|
|
196
|
+
REPOSITORY_ROOT,
|
|
197
|
+
"Home",
|
|
198
|
+
"Views",
|
|
199
|
+
"footer.ejs",
|
|
200
|
+
);
|
|
201
|
+
const cookiePolicyPath: string = path.join(
|
|
202
|
+
REPOSITORY_ROOT,
|
|
203
|
+
"Home",
|
|
204
|
+
"Views",
|
|
205
|
+
"cookies.ejs",
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
test("the banner exposes a way to reopen itself", () => {
|
|
209
|
+
expect(fs.readFileSync(bannerPath, "utf-8")).toContain(
|
|
210
|
+
"window.oneUptimeShowCookieSettings",
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("the footer carries the settings link the policy promises", () => {
|
|
215
|
+
/*
|
|
216
|
+
* The Cookie Policy has always told visitors to use "the cookie settings
|
|
217
|
+
* link in the footer". There was no such link, so a decision could be made
|
|
218
|
+
* once and never revisited - the most commonly enforced banner defect
|
|
219
|
+
* there is.
|
|
220
|
+
*/
|
|
221
|
+
const footer: string = fs.readFileSync(footerPath, "utf-8");
|
|
222
|
+
|
|
223
|
+
expect(footer).toContain("Cookie settings");
|
|
224
|
+
expect(footer).toContain("window.oneUptimeShowCookieSettings");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("the cookie policy no longer denies using marketing cookies in the product", () => {
|
|
228
|
+
/*
|
|
229
|
+
* It said "We do not use marketing cookies inside the product" while the
|
|
230
|
+
* product loaded the same container - and therefore the same advertising
|
|
231
|
+
* tags - as the marketing site.
|
|
232
|
+
*/
|
|
233
|
+
const policy: string = fs.readFileSync(cookiePolicyPath, "utf-8");
|
|
234
|
+
|
|
235
|
+
expect(policy).not.toContain(
|
|
236
|
+
"We do not use marketing cookies inside the product",
|
|
237
|
+
);
|
|
238
|
+
expect(policy).toContain("governed by the same");
|
|
239
|
+
expect(policy).toContain("consent choice");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { AreaChart } from "../../../../UI/Components/Charts/ChartLibrary/AreaChart/AreaChart";
|
|
2
|
+
import { LineChart } from "../../../../UI/Components/Charts/ChartLibrary/LineChart/LineChart";
|
|
3
|
+
import "@testing-library/jest-dom";
|
|
4
|
+
import { render } from "@testing-library/react";
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { describe, expect, jest, test } from "@jest/globals";
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* ResponsiveContainer measures its parent, which is always 0x0 in jsdom,
|
|
10
|
+
* so the chart renders nothing without a fixed size.
|
|
11
|
+
*/
|
|
12
|
+
jest.mock("recharts", () => {
|
|
13
|
+
const actual: Record<string, any> = jest.requireActual("recharts") as Record<
|
|
14
|
+
string,
|
|
15
|
+
any
|
|
16
|
+
>;
|
|
17
|
+
return {
|
|
18
|
+
...actual,
|
|
19
|
+
ResponsiveContainer: ({ children }: { children: React.ReactElement }) => {
|
|
20
|
+
return React.cloneElement(children, {
|
|
21
|
+
width: 600,
|
|
22
|
+
height: 300,
|
|
23
|
+
} as Record<string, unknown>);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
* Compare-to-previous ghost series must be visually and interactively
|
|
30
|
+
* second-class: dashed, and never part of the fat invisible click-target
|
|
31
|
+
* lines that make live series selectable.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
type ChartRow = Record<string, any>;
|
|
35
|
+
|
|
36
|
+
const data: Array<ChartRow> = [
|
|
37
|
+
{ time: "10:00", live: 10, "live (previous)": 8 },
|
|
38
|
+
{ time: "10:05", live: 20, "live (previous)": 12 },
|
|
39
|
+
{ time: "10:10", live: 15, "live (previous)": 9 },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
describe.each([
|
|
43
|
+
["LineChart", LineChart],
|
|
44
|
+
["AreaChart", AreaChart],
|
|
45
|
+
] as Array<[string, typeof LineChart | typeof AreaChart]>)(
|
|
46
|
+
"%s ghost series",
|
|
47
|
+
(_name: string, Chart: typeof LineChart | typeof AreaChart) => {
|
|
48
|
+
test("ghost categories render dashed; live ones do not", () => {
|
|
49
|
+
const { container } = render(
|
|
50
|
+
<Chart
|
|
51
|
+
data={data}
|
|
52
|
+
index="time"
|
|
53
|
+
categories={["live", "live (previous)"]}
|
|
54
|
+
ghostCategories={["live (previous)"]}
|
|
55
|
+
/>,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const dashed: Array<Element> = Array.from(
|
|
59
|
+
container.querySelectorAll('path[stroke-dasharray="6 4"]'),
|
|
60
|
+
);
|
|
61
|
+
expect(dashed.length).toBeGreaterThan(0);
|
|
62
|
+
|
|
63
|
+
/*
|
|
64
|
+
* The live series' curve must NOT be dashed — recharts names each
|
|
65
|
+
* series path group; assert at least one series curve without the
|
|
66
|
+
* ghost dash exists.
|
|
67
|
+
*/
|
|
68
|
+
const allCurves: Array<Element> = Array.from(
|
|
69
|
+
container.querySelectorAll(".recharts-layer path"),
|
|
70
|
+
).filter((el: Element) => {
|
|
71
|
+
return el.getAttribute("stroke-dasharray") !== "6 4";
|
|
72
|
+
});
|
|
73
|
+
expect(allCurves.length).toBeGreaterThan(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("ghosts are excluded from the fat click-target lines", () => {
|
|
77
|
+
const { container } = render(
|
|
78
|
+
<Chart
|
|
79
|
+
data={data}
|
|
80
|
+
index="time"
|
|
81
|
+
categories={["live", "live (previous)"]}
|
|
82
|
+
ghostCategories={["live (previous)"]}
|
|
83
|
+
onValueChange={() => {
|
|
84
|
+
// Enables the click-target machinery, like the wrappers do.
|
|
85
|
+
}}
|
|
86
|
+
/>,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
/*
|
|
90
|
+
* LineChart renders invisible strokeWidth-12 click-targets per
|
|
91
|
+
* SELECTABLE series; ghosts must not get one. (AreaChart has no
|
|
92
|
+
* fat-line mechanism — zero is correct there.)
|
|
93
|
+
*/
|
|
94
|
+
const fatLines: Array<Element> = Array.from(
|
|
95
|
+
container.querySelectorAll('path[stroke-width="12"]'),
|
|
96
|
+
);
|
|
97
|
+
expect(fatLines.length).toBeLessThanOrEqual(1);
|
|
98
|
+
for (const fatLine of fatLines) {
|
|
99
|
+
expect(fatLine.getAttribute("name")).not.toContain("(previous)");
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
);
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import { afterEach, describe, expect, test } from "@jest/globals";
|
|
3
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { ChartTooltip as AreaChartTooltip } from "../../../../UI/Components/Charts/ChartLibrary/AreaChart/AreaChart";
|
|
6
|
+
import { ChartTooltip as BarChartTooltip } from "../../../../UI/Components/Charts/ChartLibrary/BarChart/BarChart";
|
|
7
|
+
import { ChartTooltip as LineChartTooltip } from "../../../../UI/Components/Charts/ChartLibrary/LineChart/LineChart";
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* The rendered tooltip is where "which host is this spike?" gets answered:
|
|
11
|
+
* with a 20-series group-by chart the entries must arrive highest-value
|
|
12
|
+
* first (recharts hands them over in series-mount order, effectively
|
|
13
|
+
* alphabetical) and the tail past the cap must be summarized, not
|
|
14
|
+
* silently dropped. All three chart types ship their own ChartTooltip, so
|
|
15
|
+
* all three are pinned to the same contract here.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
type TooltipComponent =
|
|
19
|
+
| typeof AreaChartTooltip
|
|
20
|
+
| typeof LineChartTooltip
|
|
21
|
+
| typeof BarChartTooltip;
|
|
22
|
+
|
|
23
|
+
interface TooltipCase {
|
|
24
|
+
name: string;
|
|
25
|
+
Tooltip: TooltipComponent;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const TOOLTIP_CASES: Array<TooltipCase> = [
|
|
29
|
+
{ name: "AreaChart", Tooltip: AreaChartTooltip },
|
|
30
|
+
{ name: "LineChart", Tooltip: LineChartTooltip },
|
|
31
|
+
{ name: "BarChart", Tooltip: BarChartTooltip },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function buildPayload(count: number): Array<{
|
|
35
|
+
category: string;
|
|
36
|
+
value: number;
|
|
37
|
+
index: string;
|
|
38
|
+
color: string;
|
|
39
|
+
payload: Record<string, unknown>;
|
|
40
|
+
}> {
|
|
41
|
+
return Array.from({ length: count }, (_: unknown, index: number) => {
|
|
42
|
+
return {
|
|
43
|
+
/*
|
|
44
|
+
* Alphabetical categories with ASCENDING values — exactly the shape
|
|
45
|
+
* where mount order (host-00 first) and value order (host-NN first)
|
|
46
|
+
* disagree, so a pass proves the sort actually ran.
|
|
47
|
+
*/
|
|
48
|
+
category: `host-${String(index).padStart(2, "0")}`,
|
|
49
|
+
value: index,
|
|
50
|
+
index: "10:00",
|
|
51
|
+
color: "blue",
|
|
52
|
+
payload: {},
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
cleanup();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe.each(TOOLTIP_CASES)("$name tooltip", ({ Tooltip }: TooltipCase) => {
|
|
62
|
+
test("renders entries highest-value first with an overflow summary", () => {
|
|
63
|
+
const { container } = render(
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
65
|
+
<Tooltip
|
|
66
|
+
active={true}
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
68
|
+
payload={buildPayload(14) as any}
|
|
69
|
+
label="10:00"
|
|
70
|
+
valueFormatter={(value: number): string => {
|
|
71
|
+
return `${value}%`;
|
|
72
|
+
}}
|
|
73
|
+
/>,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const text: string = container.textContent || "";
|
|
77
|
+
|
|
78
|
+
// The top-valued series leads; the lowest-valued ones are elided.
|
|
79
|
+
expect(text.indexOf("host-13")).toBeGreaterThan(-1);
|
|
80
|
+
expect(text.indexOf("host-13")).toBeLessThan(text.indexOf("host-12"));
|
|
81
|
+
expect(text.indexOf("host-12")).toBeLessThan(text.indexOf("host-04"));
|
|
82
|
+
expect(text).not.toContain("host-03");
|
|
83
|
+
expect(text).not.toContain("host-00");
|
|
84
|
+
|
|
85
|
+
// 14 series, 10 shown → 4 summarized.
|
|
86
|
+
expect(
|
|
87
|
+
screen.getByText(/\+4 more series — highest values shown/),
|
|
88
|
+
).toBeInTheDocument();
|
|
89
|
+
|
|
90
|
+
// Values run through the caller's formatter.
|
|
91
|
+
expect(text).toContain("13%");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("shows no overflow line when everything fits", () => {
|
|
95
|
+
const { container } = render(
|
|
96
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
97
|
+
<Tooltip
|
|
98
|
+
active={true}
|
|
99
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
100
|
+
payload={buildPayload(3) as any}
|
|
101
|
+
label="10:00"
|
|
102
|
+
valueFormatter={(value: number): string => {
|
|
103
|
+
return String(value);
|
|
104
|
+
}}
|
|
105
|
+
/>,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(container.textContent).not.toContain("more series");
|
|
109
|
+
expect(container.textContent).toContain("host-02");
|
|
110
|
+
expect(container.textContent).toContain("host-00");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("renders nothing when inactive", () => {
|
|
114
|
+
const { container } = render(
|
|
115
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
116
|
+
<Tooltip
|
|
117
|
+
active={false}
|
|
118
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
119
|
+
payload={buildPayload(3) as any}
|
|
120
|
+
label="10:00"
|
|
121
|
+
valueFormatter={(value: number): string => {
|
|
122
|
+
return String(value);
|
|
123
|
+
}}
|
|
124
|
+
/>,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(container.firstChild).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, expect, test } from "@jest/globals";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_TOOLTIP_MAX_ENTRIES,
|
|
4
|
+
PREVIOUS_PERIOD_SERIES_SUFFIX,
|
|
5
|
+
PreparedTooltipEntries,
|
|
6
|
+
SortableTooltipItem,
|
|
7
|
+
prepareTooltipEntries,
|
|
8
|
+
} from "../../../../UI/Components/Charts/ChartLibrary/Utils/TooltipEntries";
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* The tooltip ordering contract: on a grouped chart the series that is
|
|
12
|
+
* spiking AT THE HOVERED TIMESTAMP must be the first tooltip line, the
|
|
13
|
+
* list must stay readable at high cardinality (cap + "+N more"), and
|
|
14
|
+
* non-data entries (hidden series, band fills) must never displace a real
|
|
15
|
+
* reading. The chips already rank by peak; the tooltip is the only place
|
|
16
|
+
* the user reads per-timestamp values, so its order is what answers
|
|
17
|
+
* "which host is this spike?".
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface TestItem extends SortableTooltipItem {
|
|
21
|
+
color?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function item(category: string, value: number, type?: string): TestItem {
|
|
25
|
+
return { category, value, ...(type ? { type } : {}) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function categories(prepared: PreparedTooltipEntries<TestItem>): Array<string> {
|
|
29
|
+
return prepared.entries.map((entry: TestItem) => {
|
|
30
|
+
return entry.category;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("prepareTooltipEntries", () => {
|
|
35
|
+
test("orders entries by value at the hovered point, highest first", () => {
|
|
36
|
+
const prepared: PreparedTooltipEntries<TestItem> = prepareTooltipEntries([
|
|
37
|
+
item("host-b", 10),
|
|
38
|
+
item("host-a", 90),
|
|
39
|
+
item("host-c", 40),
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
expect(categories(prepared)).toEqual(["host-a", "host-c", "host-b"]);
|
|
43
|
+
expect(prepared.overflowCount).toBe(0);
|
|
44
|
+
expect(prepared.totalCount).toBe(3);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("breaks value ties by natural name order (cpu2 before cpu10)", () => {
|
|
48
|
+
const prepared: PreparedTooltipEntries<TestItem> = prepareTooltipEntries([
|
|
49
|
+
item("cpu10", 5),
|
|
50
|
+
item("cpu2", 5),
|
|
51
|
+
item("cpu1", 5),
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
expect(categories(prepared)).toEqual(["cpu1", "cpu2", "cpu10"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("sorts non-finite values last and drops band tuples entirely", () => {
|
|
58
|
+
const prepared: PreparedTooltipEntries<TestItem> = prepareTooltipEntries([
|
|
59
|
+
item("nan-series", NaN),
|
|
60
|
+
item("real-low", 1),
|
|
61
|
+
item("infinite", Infinity),
|
|
62
|
+
item("real-high", 7),
|
|
63
|
+
/*
|
|
64
|
+
* The anomaly band's dataKey yields a [low, high] tuple at runtime
|
|
65
|
+
* — a shaded region, not a series, so it must not render as a row
|
|
66
|
+
* nor count toward the overflow footer.
|
|
67
|
+
*/
|
|
68
|
+
item("band", [2, 9] as unknown as number),
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
expect(categories(prepared).slice(0, 2)).toEqual(["real-high", "real-low"]);
|
|
72
|
+
// Non-finite tail keeps natural name order among itself.
|
|
73
|
+
expect(categories(prepared).slice(2)).toEqual(["infinite", "nan-series"]);
|
|
74
|
+
expect(prepared.totalCount).toBe(4);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('filters out type "none" entries (hidden click-target lines, band fills)', () => {
|
|
78
|
+
const prepared: PreparedTooltipEntries<TestItem> = prepareTooltipEntries([
|
|
79
|
+
item("visible", 3),
|
|
80
|
+
item("hidden", 99, "none"),
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
expect(categories(prepared)).toEqual(["visible"]);
|
|
84
|
+
expect(prepared.totalCount).toBe(1);
|
|
85
|
+
expect(prepared.overflowCount).toBe(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("caps at the default and reports the overflow", () => {
|
|
89
|
+
const payload: Array<TestItem> = Array.from(
|
|
90
|
+
{ length: 25 },
|
|
91
|
+
(_: unknown, index: number) => {
|
|
92
|
+
return item(`series-${index}`, index);
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const prepared: PreparedTooltipEntries<TestItem> =
|
|
97
|
+
prepareTooltipEntries(payload);
|
|
98
|
+
|
|
99
|
+
expect(prepared.entries).toHaveLength(DEFAULT_TOOLTIP_MAX_ENTRIES);
|
|
100
|
+
expect(prepared.overflowCount).toBe(25 - DEFAULT_TOOLTIP_MAX_ENTRIES);
|
|
101
|
+
expect(prepared.totalCount).toBe(25);
|
|
102
|
+
// The cap keeps the HIGHEST values, not the first-mounted series.
|
|
103
|
+
expect(categories(prepared)[0]).toBe("series-24");
|
|
104
|
+
expect(categories(prepared)[DEFAULT_TOOLTIP_MAX_ENTRIES - 1]).toBe(
|
|
105
|
+
`series-${25 - DEFAULT_TOOLTIP_MAX_ENTRIES}`,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("exactly at the cap means no overflow", () => {
|
|
110
|
+
const payload: Array<TestItem> = Array.from(
|
|
111
|
+
{ length: DEFAULT_TOOLTIP_MAX_ENTRIES },
|
|
112
|
+
(_: unknown, index: number) => {
|
|
113
|
+
return item(`series-${index}`, index);
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const prepared: PreparedTooltipEntries<TestItem> =
|
|
118
|
+
prepareTooltipEntries(payload);
|
|
119
|
+
|
|
120
|
+
expect(prepared.entries).toHaveLength(DEFAULT_TOOLTIP_MAX_ENTRIES);
|
|
121
|
+
expect(prepared.overflowCount).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("honors a custom cap and rejects nonsense caps", () => {
|
|
125
|
+
const payload: Array<TestItem> = [item("a", 1), item("b", 2), item("c", 3)];
|
|
126
|
+
|
|
127
|
+
expect(prepareTooltipEntries(payload, 2).entries).toHaveLength(2);
|
|
128
|
+
expect(prepareTooltipEntries(payload, 2).overflowCount).toBe(1);
|
|
129
|
+
|
|
130
|
+
// Zero, negative, and fractional caps fall back to the default.
|
|
131
|
+
expect(prepareTooltipEntries(payload, 0).entries).toHaveLength(3);
|
|
132
|
+
expect(prepareTooltipEntries(payload, -5).entries).toHaveLength(3);
|
|
133
|
+
expect(prepareTooltipEntries(payload, 1.5).entries).toHaveLength(3);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("tolerates empty, null, and undefined payloads", () => {
|
|
137
|
+
for (const payload of [[], null, undefined]) {
|
|
138
|
+
const prepared: PreparedTooltipEntries<TestItem> = prepareTooltipEntries(
|
|
139
|
+
payload as Array<TestItem>,
|
|
140
|
+
);
|
|
141
|
+
expect(prepared.entries).toEqual([]);
|
|
142
|
+
expect(prepared.overflowCount).toBe(0);
|
|
143
|
+
expect(prepared.totalCount).toBe(0);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("previous-period ghosts sort after every live series, whatever their value", () => {
|
|
148
|
+
const prepared: PreparedTooltipEntries<TestItem> = prepareTooltipEntries([
|
|
149
|
+
item(`host-a${PREVIOUS_PERIOD_SERIES_SUFFIX}`, 999),
|
|
150
|
+
item("host-a", 10),
|
|
151
|
+
item("host-b", 50),
|
|
152
|
+
item(`host-b${PREVIOUS_PERIOD_SERIES_SUFFIX}`, 60),
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
expect(categories(prepared)).toEqual([
|
|
156
|
+
"host-b",
|
|
157
|
+
"host-a",
|
|
158
|
+
// Ghost tier keeps its own value ordering.
|
|
159
|
+
`host-a${PREVIOUS_PERIOD_SERIES_SUFFIX}`,
|
|
160
|
+
`host-b${PREVIOUS_PERIOD_SERIES_SUFFIX}`,
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("ghosts never displace live readings under the cap", () => {
|
|
165
|
+
const payload: Array<TestItem> = [];
|
|
166
|
+
for (let index: number = 0; index < DEFAULT_TOOLTIP_MAX_ENTRIES; index++) {
|
|
167
|
+
payload.push(item(`live-${index}`, index));
|
|
168
|
+
payload.push(item(`live-${index}${PREVIOUS_PERIOD_SERIES_SUFFIX}`, 1e6));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const prepared: PreparedTooltipEntries<TestItem> =
|
|
172
|
+
prepareTooltipEntries(payload);
|
|
173
|
+
|
|
174
|
+
expect(
|
|
175
|
+
prepared.entries.every((entry: TestItem) => {
|
|
176
|
+
return !entry.category.endsWith(PREVIOUS_PERIOD_SERIES_SUFFIX);
|
|
177
|
+
}),
|
|
178
|
+
).toBe(true);
|
|
179
|
+
expect(prepared.overflowCount).toBe(DEFAULT_TOOLTIP_MAX_ENTRIES);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("does not mutate the input payload", () => {
|
|
183
|
+
const payload: Array<TestItem> = [item("low", 1), item("high", 2)];
|
|
184
|
+
prepareTooltipEntries(payload);
|
|
185
|
+
expect(
|
|
186
|
+
payload.map((entry: TestItem) => {
|
|
187
|
+
return entry.category;
|
|
188
|
+
}),
|
|
189
|
+
).toEqual(["low", "high"]);
|
|
190
|
+
});
|
|
191
|
+
});
|