@oneuptime/common 14.0.2 → 14.0.3

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.
Files changed (166) hide show
  1. package/Models/DatabaseModels/AlertFeed.ts +2 -0
  2. package/Models/DatabaseModels/IncidentAlert.ts +540 -0
  3. package/Models/DatabaseModels/IncidentFeed.ts +2 -0
  4. package/Models/DatabaseModels/Index.ts +2 -0
  5. package/Models/DatabaseModels/Project.ts +60 -0
  6. package/Models/DatabaseModels/RumApplication.ts +52 -1
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.ts +28 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.ts +89 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  10. package/Server/Services/IncidentAlertService.ts +1811 -0
  11. package/Server/Services/IncidentService.ts +210 -10
  12. package/Server/Services/IncidentStateTimelineService.ts +24 -0
  13. package/Server/Services/Index.ts +2 -0
  14. package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +24 -2
  15. package/Tests/App/Dashboard/BulkIncidentLinkActions.test.tsx +869 -0
  16. package/Tests/App/Dashboard/CephMetricTooltips.test.tsx +1777 -0
  17. package/Tests/App/Dashboard/CloudFleetSummary.test.ts +8 -0
  18. package/Tests/App/Dashboard/CloudMetricTooltips.test.tsx +887 -0
  19. package/Tests/App/Dashboard/ContainerHostMetricTooltips.test.tsx +1309 -0
  20. package/Tests/App/Dashboard/DiscoveryScanLivePage.test.tsx +39 -0
  21. package/Tests/App/Dashboard/DockerSwarmClustersListTooltips.test.tsx +414 -0
  22. package/Tests/App/Dashboard/DockerSwarmMetricTooltips.test.ts +796 -0
  23. package/Tests/App/Dashboard/DockerSwarmOverviewTooltips.test.tsx +747 -0
  24. package/Tests/App/Dashboard/ExceptionMonitorEnvironmentFilter.test.tsx +38 -18
  25. package/Tests/App/Dashboard/HostDetailViewTooltips.test.tsx +725 -0
  26. package/Tests/App/Dashboard/HostListColumnTooltips.test.tsx +472 -0
  27. package/Tests/App/Dashboard/HostMetricTooltips.test.ts +709 -0
  28. package/Tests/App/Dashboard/HostOverviewTooltips.test.tsx +698 -0
  29. package/Tests/App/Dashboard/HostTooltipHarness.ts +124 -0
  30. package/Tests/App/Dashboard/HostsListTooltips.test.tsx +395 -0
  31. package/Tests/App/Dashboard/IncidentAlertLinkDialog.test.tsx +691 -0
  32. package/Tests/App/Dashboard/IncidentAlertLinkHelpers.test.ts +458 -0
  33. package/Tests/App/Dashboard/IncidentAlertLinkPages.test.tsx +828 -0
  34. package/Tests/App/Dashboard/IncidentAlertLinkSideMenus.test.tsx +231 -0
  35. package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +689 -0
  36. package/Tests/App/Dashboard/IoTMetricTooltips.test.tsx +1123 -0
  37. package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +837 -0
  38. package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +699 -0
  39. package/Tests/App/Dashboard/KubernetesContainersTabTooltips.test.tsx +320 -0
  40. package/Tests/App/Dashboard/KubernetesCostTooltips.test.tsx +502 -0
  41. package/Tests/App/Dashboard/KubernetesInsightsTooltips.test.tsx +222 -0
  42. package/Tests/App/Dashboard/KubernetesResourceMetricTooltips.test.ts +561 -0
  43. package/Tests/App/Dashboard/KubernetesResourcePagesTooltips.test.tsx +1168 -0
  44. package/Tests/App/Dashboard/LinkIncidentAlertModal.test.tsx +154 -0
  45. package/Tests/App/Dashboard/MetricDescriptionRules.ts +170 -0
  46. package/Tests/App/Dashboard/MetricDescriptionsCatalog.test.ts +206 -0
  47. package/Tests/App/Dashboard/MetricDescriptionsConsistency.test.ts +275 -0
  48. package/Tests/App/Dashboard/NetworkDeviceTooltips.test.tsx +852 -0
  49. package/Tests/App/Dashboard/NetworkDiagnosticsTooltips.test.tsx +556 -0
  50. package/Tests/App/Dashboard/NetworkMetricTooltips.test.ts +1348 -0
  51. package/Tests/App/Dashboard/NetworkSiteTooltips.test.tsx +679 -0
  52. package/Tests/App/Dashboard/ProxmoxMetricTooltips.test.ts +592 -0
  53. package/Tests/App/Dashboard/ProxmoxOverviewTooltips.test.tsx +1002 -0
  54. package/Tests/App/Dashboard/ProxmoxResourcePageTooltips.test.tsx +923 -0
  55. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +1066 -0
  56. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +1195 -0
  57. package/Tests/App/Dashboard/ServerlessMetricTooltips.test.tsx +474 -0
  58. package/Tests/App/Dashboard/ServiceMetricTooltips.test.tsx +772 -0
  59. package/Tests/App/Dashboard/SessionReplayAuditTable.test.tsx +56 -1
  60. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +1207 -0
  61. package/Tests/App/Dashboard/TelemetryResourceTabTooltips.test.tsx +437 -0
  62. package/Tests/App/Dashboard/VMwareMetricTooltips.test.ts +801 -0
  63. package/Tests/App/Dashboard/VMwareOverviewTooltips.test.tsx +601 -0
  64. package/Tests/App/Dashboard/VMwareResourcePagesTooltips.test.tsx +646 -0
  65. package/Tests/App/Dashboard/VMwareTooltipHarness.ts +145 -0
  66. package/Tests/Models/DatabaseModels/IncidentAlertModel.test.ts +554 -0
  67. package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +117 -0
  68. package/Tests/Server/Infrastructure/Postgres/AddIncidentAlertMigration.test.ts +341 -0
  69. package/Tests/Server/Infrastructure/Postgres/AddSessionReplaySameOriginTracePropagationMigration.test.ts +247 -0
  70. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +903 -0
  71. package/Tests/Server/Services/IncidentAlertRegistration.test.ts +96 -0
  72. package/Tests/Server/Services/IncidentAlertService.test.ts +2225 -0
  73. package/Tests/Server/Services/IncidentAlertStateCascade.test.ts +970 -0
  74. package/Tests/Server/Services/IncidentCreateFromAlerts.test.ts +1284 -0
  75. package/Tests/Server/Utils/SessionReplay/SessionReplayGateCachePolicy.test.ts +103 -0
  76. package/Tests/Types/Rum/WebVitals.test.ts +173 -0
  77. package/Tests/UI/Components/FieldLabelInfoCardTooltip.test.tsx +413 -0
  78. package/Tests/UI/Components/InfoTooltip.test.tsx +242 -0
  79. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +207 -3
  80. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDeleteVerb.test.tsx +367 -0
  81. package/Tests/UI/Components/ModelTable/BaseModelTableCreateButtonTitle.test.tsx +275 -0
  82. package/Tests/UI/Components/TableHeaderTooltip.test.tsx +662 -0
  83. package/Tests/UI/Rum/PrivacySummaryCard.test.tsx +166 -9
  84. package/Tests/UI/Rum/RecordingHealthTooltips.test.tsx +447 -0
  85. package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +164 -0
  86. package/Tests/UI/Rum/ReplayFrameCapture.test.ts +6750 -0
  87. package/Tests/UI/Rum/ReplayRail.test.tsx +723 -12
  88. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +86 -0
  89. package/Tests/UI/Rum/ReplayScreenshot.test.ts +2124 -0
  90. package/Tests/UI/Rum/ReplayScreenshotActions.test.tsx +3288 -0
  91. package/Tests/UI/Rum/ReplayStage.test.tsx +62 -0
  92. package/Tests/UI/Rum/ReplayStageOverlays.test.tsx +983 -2
  93. package/Tests/UI/Rum/SessionReplaySettingsPolicyLoading.test.tsx +149 -0
  94. package/Tests/UI/Rum/SessionReplaySetupGuide.test.tsx +107 -6
  95. package/Tests/UI/Rum/SessionReplayUsersTableTooltips.test.tsx +330 -0
  96. package/Tests/UI/Rum/UserFlowTooltips.test.tsx +886 -0
  97. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  98. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  99. package/Tests/UI/Utils/PermissionGate.test.ts +76 -0
  100. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +696 -0
  101. package/Tests/Utils/Rum/SessionTraceState.test.ts +239 -0
  102. package/Types/Incident/IncidentAlertLink.ts +38 -0
  103. package/Types/Permission.ts +48 -0
  104. package/Types/Rum/SessionReplay.ts +25 -6
  105. package/Types/Rum/WebVitals.ts +52 -0
  106. package/UI/Components/Detail/FieldLabel.tsx +8 -0
  107. package/UI/Components/InfoCard/InfoCard.tsx +47 -1
  108. package/UI/Components/ModelTable/BaseModelTable.tsx +95 -17
  109. package/UI/Components/ModelTable/Column.ts +5 -0
  110. package/UI/Components/Table/TableHeader.tsx +47 -2
  111. package/UI/Components/Table/Types/Column.ts +7 -0
  112. package/UI/Components/Tooltip/InfoTooltip.tsx +78 -0
  113. package/UI/Utils/PermissionGate.ts +11 -2
  114. package/Utils/Incident/IncidentFromAlerts.ts +516 -0
  115. package/Utils/Rum/SessionTraceState.ts +212 -0
  116. package/build/dist/Models/DatabaseModels/AlertFeed.js +2 -0
  117. package/build/dist/Models/DatabaseModels/AlertFeed.js.map +1 -1
  118. package/build/dist/Models/DatabaseModels/IncidentAlert.js +553 -0
  119. package/build/dist/Models/DatabaseModels/IncidentAlert.js.map +1 -0
  120. package/build/dist/Models/DatabaseModels/IncidentFeed.js +2 -0
  121. package/build/dist/Models/DatabaseModels/IncidentFeed.js.map +1 -1
  122. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  123. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  124. package/build/dist/Models/DatabaseModels/Project.js +62 -0
  125. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  126. package/build/dist/Models/DatabaseModels/RumApplication.js +53 -1
  127. package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js +20 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js.map +1 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js +42 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js.map +1 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  134. package/build/dist/Server/Services/IncidentAlertService.js +1370 -0
  135. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -0
  136. package/build/dist/Server/Services/IncidentService.js +164 -12
  137. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  138. package/build/dist/Server/Services/IncidentStateTimelineService.js +21 -0
  139. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  140. package/build/dist/Server/Services/Index.js +2 -0
  141. package/build/dist/Server/Services/Index.js.map +1 -1
  142. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js +8 -0
  143. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
  144. package/build/dist/Types/Incident/IncidentAlertLink.js +34 -0
  145. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -0
  146. package/build/dist/Types/Permission.js +42 -0
  147. package/build/dist/Types/Permission.js.map +1 -1
  148. package/build/dist/Types/Rum/WebVitals.js +25 -0
  149. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  150. package/build/dist/UI/Components/Detail/FieldLabel.js +2 -0
  151. package/build/dist/UI/Components/Detail/FieldLabel.js.map +1 -1
  152. package/build/dist/UI/Components/InfoCard/InfoCard.js +22 -1
  153. package/build/dist/UI/Components/InfoCard/InfoCard.js.map +1 -1
  154. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +66 -14
  155. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  156. package/build/dist/UI/Components/Table/TableHeader.js +18 -2
  157. package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
  158. package/build/dist/UI/Components/Tooltip/InfoTooltip.js +28 -0
  159. package/build/dist/UI/Components/Tooltip/InfoTooltip.js.map +1 -0
  160. package/build/dist/UI/Utils/PermissionGate.js +4 -2
  161. package/build/dist/UI/Utils/PermissionGate.js.map +1 -1
  162. package/build/dist/Utils/Incident/IncidentFromAlerts.js +306 -0
  163. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -0
  164. package/build/dist/Utils/Rum/SessionTraceState.js +154 -0
  165. package/build/dist/Utils/Rum/SessionTraceState.js.map +1 -0
  166. package/package.json +1 -1
@@ -0,0 +1,886 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ act,
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ within,
9
+ } from "@testing-library/react";
10
+ import * as React from "react";
11
+ import { MemoryRouter } from "react-router-dom";
12
+ import {
13
+ afterEach,
14
+ beforeEach,
15
+ describe,
16
+ expect,
17
+ jest,
18
+ test,
19
+ } from "@jest/globals";
20
+ import ObjectID from "../../../Types/ObjectID";
21
+ import TimeRange from "../../../Types/Time/TimeRange";
22
+ import {
23
+ UserFlowJourneysResponseDto,
24
+ UserFlowSessionDto,
25
+ } from "../../../Types/Rum/UserFlow";
26
+ import {
27
+ analyzeUserFlow,
28
+ UserFlowAnalysis,
29
+ UserFlowLink,
30
+ UserFlowNode,
31
+ UserFlowOptions,
32
+ UserFlowPageStats,
33
+ } from "../../../Utils/Rum/UserFlow";
34
+ import UserFlowOverview from "../../../../App/FeatureSet/Dashboard/src/Components/UserFlow/UserFlowOverview";
35
+ import UserFlowTables from "../../../../App/FeatureSet/Dashboard/src/Components/UserFlow/UserFlowTables";
36
+ import UserFlowDetailPanel from "../../../../App/FeatureSet/Dashboard/src/Components/UserFlow/UserFlowDetailPanel";
37
+ import { UserFlowSelection } from "../../../../App/FeatureSet/Dashboard/src/Components/UserFlow/UserFlowMap";
38
+ import {
39
+ RUM_USER_FLOW_METRIC_DESCRIPTIONS,
40
+ RumUserFlowMetric,
41
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/MetricDescriptions/RumMetricDescriptions";
42
+ import { expectReadableDescriptionRecord } from "../../App/Dashboard/MetricDescriptionRules";
43
+ import getJestMockFunction, { MockFunction } from "../../MockType";
44
+
45
+ /*
46
+ * The User Flows page's (i) tooltips, rendered: the five overview tiles,
47
+ * every metric column of the Top paths, Pages and Back and forth tabs, and
48
+ * every figure and page list in the detail panel a click on the map opens.
49
+ *
50
+ * Each (i) is hovered and must show its own text from
51
+ * RUM_USER_FLOW_METRIC_DESCRIPTIONS. The sortable Pages headers keep the
52
+ * (i) BESIDE the sort button - a button inside a button is invalid and
53
+ * would sort on every hover-click.
54
+ *
55
+ * The fixture is hand-worked so the numbers the texts talk about can be
56
+ * checked against what the page actually draws: Exit rate is Left over
57
+ * Sessions, a reload counts once, the Pages Errors column counts only
58
+ * errors placed on that page while "With errors" counts any error in the
59
+ * session, and the Sessions/Device filters change "Sessions analysed".
60
+ */
61
+
62
+ const ORIGIN: string = "https://shop.example.com";
63
+ const APP_ID: ObjectID = new ObjectID("0193c0de-1111-4aaa-8bbb-000000000001");
64
+ // Every (i) is a button named "About <what it explains>".
65
+ const INFO_BUTTON_NAME: RegExp = /^About /;
66
+
67
+ interface SessionSpec {
68
+ id: string;
69
+ pages: Array<string>;
70
+ errorCount?: number;
71
+ frustrationCount?: number;
72
+ durationMs?: number;
73
+ deviceType?: string;
74
+ /* [path, errors, frustration] */
75
+ signals?: Array<[string, number, number]>;
76
+ }
77
+
78
+ function response(specs: Array<SessionSpec>): UserFlowJourneysResponseDto {
79
+ const pages: Array<string> = [];
80
+ const indexOf: (path: string) => number = (path: string): number => {
81
+ const url: string = `${ORIGIN}${path}`;
82
+ let index: number = pages.indexOf(url);
83
+
84
+ if (index < 0) {
85
+ index = pages.length;
86
+ pages.push(url);
87
+ }
88
+
89
+ return index;
90
+ };
91
+
92
+ const sessions: Array<UserFlowSessionDto> = specs.map(
93
+ (spec: SessionSpec, position: number): UserFlowSessionDto => {
94
+ return {
95
+ sessionId: spec.id,
96
+ startUnixMs: 1_800_000_000_000 - position * 1000,
97
+ durationMs: spec.durationMs ?? 60_000,
98
+ deviceType: spec.deviceType ?? "desktop",
99
+ browserName: "Chrome",
100
+ countryCode: "DK",
101
+ errorCount: spec.errorCount ?? 0,
102
+ frustrationCount: spec.frustrationCount ?? 0,
103
+ pages: spec.pages.map(indexOf),
104
+ pageSignals: (spec.signals || []).map(
105
+ ([path, errors, frustration]: [string, number, number]): [
106
+ number,
107
+ number,
108
+ number,
109
+ ] => {
110
+ return [indexOf(path), errors, frustration];
111
+ },
112
+ ),
113
+ };
114
+ },
115
+ );
116
+
117
+ return {
118
+ pages: pages,
119
+ sessions: sessions,
120
+ sessionsInWindow: sessions.length,
121
+ isSampled: false,
122
+ maxSessions: 5000,
123
+ startUnixMs: 1_799_990_000_000,
124
+ endUnixMs: 1_800_000_000_000,
125
+ };
126
+ }
127
+
128
+ /*
129
+ * s1 /home -> /cart -> /checkout 2 errors, placed on /checkout, 60s
130
+ * s2 /home -> /cart -> /home frustrated on /cart (a loop), 30s
131
+ * s3 /home -> /home a reload: one page, 10s
132
+ * s4 /product/1 -> /cart -> /cart an error with no page to place it on, 20s
133
+ * s5 /home -> /cart -> /checkout clean, 40s, on a phone
134
+ */
135
+ const FIXTURE: UserFlowJourneysResponseDto = response([
136
+ {
137
+ id: "s1",
138
+ pages: ["/home", "/cart", "/checkout"],
139
+ errorCount: 2,
140
+ signals: [["/checkout", 2, 0]],
141
+ durationMs: 60_000,
142
+ },
143
+ {
144
+ id: "s2",
145
+ pages: ["/home", "/cart", "/home"],
146
+ frustrationCount: 1,
147
+ signals: [["/cart", 0, 1]],
148
+ durationMs: 30_000,
149
+ },
150
+ { id: "s3", pages: ["/home", "/home"], durationMs: 10_000 },
151
+ {
152
+ id: "s4",
153
+ pages: ["/product/1", "/cart", "/cart"],
154
+ errorCount: 1,
155
+ durationMs: 20_000,
156
+ },
157
+ {
158
+ id: "s5",
159
+ pages: ["/home", "/cart", "/checkout"],
160
+ durationMs: 40_000,
161
+ deviceType: "mobile",
162
+ },
163
+ ]);
164
+
165
+ const ANALYSIS: UserFlowAnalysis = analyzeUserFlow(FIXTURE);
166
+
167
+ function renderOverview(analysis: UserFlowAnalysis = ANALYSIS): void {
168
+ render(
169
+ <UserFlowOverview
170
+ summary={analysis.summary}
171
+ insights={analysis.insights}
172
+ sessionsInWindow={FIXTURE.sessionsInWindow}
173
+ isSampled={false}
174
+ onFocusPage={(): void => {
175
+ /* not under test */
176
+ }}
177
+ />,
178
+ );
179
+ }
180
+
181
+ let onAnchor: MockFunction = getJestMockFunction();
182
+
183
+ function renderDetail(
184
+ analysis: UserFlowAnalysis,
185
+ selection: UserFlowSelection,
186
+ ): void {
187
+ render(
188
+ <MemoryRouter>
189
+ <UserFlowDetailPanel
190
+ selection={selection}
191
+ analysis={analysis}
192
+ rumApplicationId={APP_ID}
193
+ timeRange={{ range: TimeRange.PAST_ONE_DAY }}
194
+ onAnchor={onAnchor}
195
+ onHide={(): void => {
196
+ /* not under test */
197
+ }}
198
+ onClose={(): void => {
199
+ /* not under test */
200
+ }}
201
+ />
202
+ </MemoryRouter>,
203
+ );
204
+ }
205
+
206
+ function nodeAt(
207
+ analysis: UserFlowAnalysis,
208
+ step: number,
209
+ page: string,
210
+ ): UserFlowNode {
211
+ const node: UserFlowNode | undefined = analysis.graph.nodes.find(
212
+ (candidate: UserFlowNode): boolean => {
213
+ return candidate.step === step && candidate.page === page;
214
+ },
215
+ );
216
+ expect(node).toBeDefined();
217
+ return node!;
218
+ }
219
+
220
+ function linkBetween(
221
+ analysis: UserFlowAnalysis,
222
+ from: UserFlowNode,
223
+ to: UserFlowNode,
224
+ ): UserFlowLink {
225
+ const link: UserFlowLink | undefined = analysis.graph.links.find(
226
+ (candidate: UserFlowLink): boolean => {
227
+ return candidate.sourceId === from.id && candidate.targetId === to.id;
228
+ },
229
+ );
230
+ expect(link).toBeDefined();
231
+ return link!;
232
+ }
233
+
234
+ /* A detail-panel figure: the box holding the (i) named "About <label>". */
235
+ function statBox(label: string): HTMLElement {
236
+ return infoButton(label).closest(".rounded-lg") as HTMLElement;
237
+ }
238
+
239
+ /* The big number in that box, exactly. */
240
+ function statValue(label: string): string {
241
+ return (statBox(label).querySelector("p.text-lg")?.textContent || "").trim();
242
+ }
243
+
244
+ function renderTables(analysis: UserFlowAnalysis = ANALYSIS): void {
245
+ render(
246
+ <MemoryRouter>
247
+ <UserFlowTables
248
+ paths={analysis.paths}
249
+ pages={analysis.pages}
250
+ loops={analysis.loops}
251
+ rumApplicationId={APP_ID}
252
+ onAnchor={(): void => {
253
+ /* not under test */
254
+ }}
255
+ />
256
+ </MemoryRouter>,
257
+ );
258
+ }
259
+
260
+ function infoButton(label: string): HTMLElement {
261
+ const found: Array<HTMLElement> = screen.getAllByRole("button", {
262
+ name: `About ${label}`,
263
+ });
264
+ expect(found).toHaveLength(1);
265
+ return found[0]!;
266
+ }
267
+
268
+ function allInfoLabels(): Array<string> {
269
+ return screen
270
+ .queryAllByRole("button", { name: INFO_BUTTON_NAME })
271
+ .map((button: HTMLElement): string => {
272
+ return (button.getAttribute("aria-label") || "").replace("About ", "");
273
+ });
274
+ }
275
+
276
+ async function tooltipTextOf(button: HTMLElement): Promise<string> {
277
+ fireEvent.mouseEnter(button);
278
+ await act(async () => {
279
+ jest.advanceTimersByTime(200);
280
+ });
281
+
282
+ const describedBy: string | null = button.getAttribute("aria-describedby");
283
+ expect(describedBy).toBeTruthy();
284
+
285
+ const text: string =
286
+ document.getElementById(describedBy as string)?.textContent || "";
287
+
288
+ fireEvent.mouseLeave(button);
289
+
290
+ return text;
291
+ }
292
+
293
+ async function expectExplained(
294
+ label: string,
295
+ key: RumUserFlowMetric,
296
+ ): Promise<void> {
297
+ const button: HTMLElement = infoButton(label);
298
+
299
+ // Never nested in another control (a sort button, a finding card).
300
+ expect(button.parentElement?.closest("button, a")).toBeNull();
301
+ expect(await tooltipTextOf(button)).toBe(
302
+ RUM_USER_FLOW_METRIC_DESCRIPTIONS[key],
303
+ );
304
+ }
305
+
306
+ function openTab(id: "paths" | "pages" | "loops"): void {
307
+ fireEvent.click(screen.getByTestId(`user-flow-tab-${id}`));
308
+ }
309
+
310
+ function pageRow(page: string): HTMLElement {
311
+ const row: HTMLElement | undefined = screen
312
+ .getAllByTestId("user-flow-page-row")
313
+ .find((candidate: HTMLElement): boolean => {
314
+ return candidate.getAttribute("data-page") === page;
315
+ });
316
+ expect(row).toBeDefined();
317
+ return row!;
318
+ }
319
+
320
+ function cellTexts(row: HTMLElement): Array<string> {
321
+ return Array.from(row.querySelectorAll("td")).map(
322
+ (cell: HTMLTableCellElement): string => {
323
+ return (cell.textContent || "").trim();
324
+ },
325
+ );
326
+ }
327
+
328
+ function sortState(): Record<string, string | null> {
329
+ const state: Record<string, string | null> = {};
330
+
331
+ for (const header of Array.from(
332
+ screen.getByTestId("user-flow-pages-table").querySelectorAll("th"),
333
+ )) {
334
+ const label: string = (header.textContent || "").trim();
335
+
336
+ if (label) {
337
+ state[label] = header.getAttribute("aria-sort");
338
+ }
339
+ }
340
+
341
+ return state;
342
+ }
343
+
344
+ beforeEach(() => {
345
+ jest.useFakeTimers();
346
+ onAnchor = getJestMockFunction();
347
+ });
348
+
349
+ afterEach(() => {
350
+ cleanup();
351
+ jest.useRealTimers();
352
+ });
353
+
354
+ describe("the User Flows descriptions", () => {
355
+ test("read as short, finished, distinct sentences", () => {
356
+ expectReadableDescriptionRecord(
357
+ RUM_USER_FLOW_METRIC_DESCRIPTIONS,
358
+ "RUM_USER_FLOW_METRIC_DESCRIPTIONS",
359
+ );
360
+ });
361
+ });
362
+
363
+ describe("User Flows overview tiles", () => {
364
+ test("every tile explains itself, and nothing else on the strip carries an (i)", async () => {
365
+ renderOverview();
366
+
367
+ expect(allInfoLabels()).toEqual([
368
+ "Sessions analysed",
369
+ "Pages per session",
370
+ "Single-page sessions",
371
+ "Top landing page",
372
+ "Top exit page",
373
+ ]);
374
+
375
+ await expectExplained("Sessions analysed", "sessionsAnalysed");
376
+ await expectExplained("Pages per session", "pagesPerSession");
377
+ await expectExplained("Single-page sessions", "singlePageSessions");
378
+ await expectExplained("Top landing page", "topLandingPage");
379
+ await expectExplained("Top exit page", "topExitPage");
380
+ });
381
+
382
+ test("the (i) sits in the tile's label row, beside the label it names", () => {
383
+ renderOverview();
384
+
385
+ for (const [testId, label] of [
386
+ ["user-flow-tile-sessions", "Sessions analysed"],
387
+ ["user-flow-tile-pages", "Pages per session"],
388
+ ["user-flow-tile-bounce", "Single-page sessions"],
389
+ ["user-flow-tile-entry", "Top landing page"],
390
+ ["user-flow-tile-exit", "Top exit page"],
391
+ ] as Array<[string, string]>) {
392
+ const tile: HTMLElement = screen.getByTestId(testId);
393
+ const info: HTMLElement = within(tile).getByTestId(`${testId}-info`);
394
+
395
+ expect(info).toHaveAttribute("aria-label", `About ${label}`);
396
+ expect(info.parentElement).toHaveTextContent(label);
397
+ // The value paragraph is not the (i)'s row.
398
+ expect(
399
+ info.parentElement?.contains(
400
+ within(tile).getByTestId(`${testId}-value`),
401
+ ),
402
+ ).toBe(false);
403
+ }
404
+ });
405
+
406
+ test("the tiles show what the texts describe: reloads count once, medians are of journey lengths", () => {
407
+ renderOverview();
408
+
409
+ // s3 reloaded /home: one page, so the journeys are 3, 3, 1, 2, 3 pages.
410
+ expect(
411
+ screen.getByTestId("user-flow-tile-sessions-value"),
412
+ ).toHaveTextContent("5");
413
+ expect(screen.getByTestId("user-flow-tile-pages-value")).toHaveTextContent(
414
+ "2.4",
415
+ );
416
+ expect(screen.getByTestId("user-flow-tile-pages")).toHaveTextContent(
417
+ "median 3",
418
+ );
419
+ // Only the reload session saw one page.
420
+ expect(screen.getByTestId("user-flow-tile-bounce-value")).toHaveTextContent(
421
+ "20%",
422
+ );
423
+ expect(screen.getByTestId("user-flow-tile-bounce")).toHaveTextContent(
424
+ "1 never navigated",
425
+ );
426
+ expect(screen.getByTestId("user-flow-tile-entry-value")).toHaveAttribute(
427
+ "data-value",
428
+ "/home",
429
+ );
430
+ expect(screen.getByTestId("user-flow-tile-entry")).toHaveTextContent(
431
+ "80% of sessions start here",
432
+ );
433
+ // /checkout and /home both end two sessions; the tie goes by name.
434
+ expect(screen.getByTestId("user-flow-tile-exit-value")).toHaveAttribute(
435
+ "data-value",
436
+ "/checkout",
437
+ );
438
+ expect(screen.getByTestId("user-flow-tile-exit")).toHaveTextContent(
439
+ "40% of sessions end here",
440
+ );
441
+ });
442
+
443
+ test("'Sessions analysed' follows the Sessions and Device filters and hidden pages, as its text says", () => {
444
+ const only: (options: Partial<UserFlowOptions>) => number = (
445
+ options: Partial<UserFlowOptions>,
446
+ ): number => {
447
+ return analyzeUserFlow(FIXTURE, options).summary.sessions;
448
+ };
449
+
450
+ expect(only({})).toBe(5);
451
+ expect(only({ sessionFilter: "errors" })).toBe(2);
452
+ expect(only({ sessionFilter: "frustration" })).toBe(1);
453
+ expect(only({ deviceType: "mobile" })).toBe(1);
454
+ // s3 saw nothing but /home, so hiding /home drops it.
455
+ expect(only({ hiddenPages: ["/home"] })).toBe(4);
456
+ expect(RUM_USER_FLOW_METRIC_DESCRIPTIONS.sessionsAnalysed).toContain(
457
+ "match the Sessions and Device filters",
458
+ );
459
+ expect(RUM_USER_FLOW_METRIC_DESCRIPTIONS.sessionsAnalysed).toContain(
460
+ "one whose pages are all hidden drops out",
461
+ );
462
+ });
463
+ });
464
+
465
+ describe("User Flows Top paths headers", () => {
466
+ test("Sessions, Avg. duration and With errors each explain themselves; # and Journey do not", async () => {
467
+ renderTables();
468
+
469
+ expect(allInfoLabels()).toEqual([
470
+ "Sessions",
471
+ "Avg. duration",
472
+ "With errors",
473
+ ]);
474
+
475
+ await expectExplained("Sessions", "pathSessions");
476
+ await expectExplained("Avg. duration", "pathAvgDuration");
477
+ await expectExplained("With errors", "pathWithErrors");
478
+ });
479
+
480
+ test("the path cells read the way the headers say", () => {
481
+ renderTables();
482
+
483
+ const rows: Array<HTMLElement> =
484
+ screen.getAllByTestId("user-flow-path-row");
485
+ const byPath: Record<string, Array<string>> = {};
486
+
487
+ for (const row of rows) {
488
+ byPath[row.getAttribute("data-path") || ""] = cellTexts(row);
489
+ }
490
+
491
+ // Two sessions, 40% of five; (60s + 40s) / 2; one of the two errored.
492
+ expect(byPath["/home > /cart > /checkout"]?.[2]).toContain("2");
493
+ expect(byPath["/home > /cart > /checkout"]?.[2]).toContain("40%");
494
+ expect(byPath["/home > /cart > /checkout"]?.[3]).toBe("50s");
495
+ expect(byPath["/home > /cart > /checkout"]?.[4]).toBe("50%");
496
+
497
+ /*
498
+ * s4's error was never placed on a page, yet its path reads 100% with
499
+ * errors: the column counts any error in the session.
500
+ */
501
+ expect(byPath["/product/:id > /cart"]?.[4]).toBe("100%");
502
+ expect(byPath["/home > /cart > /home"]?.[4]).toBe("—");
503
+ });
504
+ });
505
+
506
+ describe("User Flows Pages headers", () => {
507
+ test("all seven metric columns explain themselves; Page does not", async () => {
508
+ renderTables();
509
+ openTab("pages");
510
+
511
+ expect(allInfoLabels()).toEqual([
512
+ "Sessions",
513
+ "Visits",
514
+ "Landed",
515
+ "Left",
516
+ "Exit rate",
517
+ "Errors",
518
+ "Frustrated",
519
+ ]);
520
+
521
+ await expectExplained("Sessions", "pageSessions");
522
+ await expectExplained("Visits", "pageVisits");
523
+ await expectExplained("Landed", "pageLanded");
524
+ await expectExplained("Left", "pageLeft");
525
+ await expectExplained("Exit rate", "pageExitRate");
526
+ await expectExplained("Errors", "pageErrors");
527
+ await expectExplained("Frustrated", "pageFrustrated");
528
+ });
529
+
530
+ test("the (i) sits beside each sort button: hovering or clicking it never sorts, the button still does", async () => {
531
+ renderTables();
532
+ openTab("pages");
533
+
534
+ expect(sortState()["Sessions"]).toBe("descending");
535
+ expect(sortState()["Exit rate"]).toBe("none");
536
+
537
+ const exitInfo: HTMLElement = infoButton("Exit rate");
538
+ const header: HTMLElement = exitInfo.closest("th") as HTMLElement;
539
+ const sortButton: HTMLElement = within(header).getByRole("button", {
540
+ name: "Exit rate",
541
+ });
542
+
543
+ expect(sortButton.contains(exitInfo)).toBe(false);
544
+
545
+ await tooltipTextOf(exitInfo);
546
+ fireEvent.click(exitInfo);
547
+ fireEvent.keyDown(exitInfo, { key: "Enter" });
548
+
549
+ expect(sortState()["Sessions"]).toBe("descending");
550
+ expect(sortState()["Exit rate"]).toBe("none");
551
+
552
+ fireEvent.click(sortButton);
553
+
554
+ expect(sortState()["Exit rate"]).toBe("descending");
555
+ expect(sortState()["Sessions"]).toBe("none");
556
+ // Sorting does not lose the (i).
557
+ expect(infoButton("Exit rate")).toBeInTheDocument();
558
+ });
559
+
560
+ test("the page cells read the way the headers say", () => {
561
+ renderTables();
562
+ openTab("pages");
563
+
564
+ // [Page, Sessions, Visits, Landed, Left, Exit rate, Errors, Frustrated, actions]
565
+ const home: Array<string> = cellTexts(pageRow("/home"));
566
+ const cart: Array<string> = cellTexts(pageRow("/cart"));
567
+ const checkout: Array<string> = cellTexts(pageRow("/checkout"));
568
+
569
+ // s2 came back to /home: 4 sessions, 5 visits. s3's reload is one visit.
570
+ expect(home.slice(1, 6)).toEqual(["4", "5", "4", "2", "50%"]);
571
+ expect(cart.slice(1, 6)).toEqual(["4", "4", "0", "1", "25%"]);
572
+ expect(checkout.slice(1, 6)).toEqual(["2", "2", "0", "2", "100%"]);
573
+
574
+ // Only s1's error was placed on a page; s4's reached no page's column.
575
+ expect(checkout[6]).toBe("1");
576
+ expect(cart[6]).toBe("—");
577
+ expect(cart[7]).toBe("1");
578
+ expect(home[7]).toBe("—");
579
+ });
580
+
581
+ test("Exit rate is Left divided by Sessions on every row", () => {
582
+ for (const page of ANALYSIS.pages) {
583
+ expect(page.exitRate).toBeCloseTo(page.exits / page.sessions, 10);
584
+ }
585
+
586
+ // Per session, not per visit: /home has 5 visits but its rate is 2 of 4.
587
+ const home: UserFlowPageStats | undefined = ANALYSIS.pages.find(
588
+ (page: UserFlowPageStats): boolean => {
589
+ return page.page === "/home";
590
+ },
591
+ );
592
+
593
+ expect(home?.views).toBe(5);
594
+ expect(home?.exitRate).toBe(0.5);
595
+ expect(RUM_USER_FLOW_METRIC_DESCRIPTIONS.pageExitRate).toContain(
596
+ "Left divided by Sessions",
597
+ );
598
+ expect(RUM_USER_FLOW_METRIC_DESCRIPTIONS.pageExitRate).toContain(
599
+ "per session, not per visit",
600
+ );
601
+ });
602
+ });
603
+
604
+ describe("User Flows Back and forth", () => {
605
+ test("the loop list gets a header whose (i) explains the count and its bar", async () => {
606
+ renderTables();
607
+ openTab("loops");
608
+
609
+ expect(screen.getByTestId("user-flow-loops-header")).toHaveTextContent(
610
+ "Pages",
611
+ );
612
+ expect(allInfoLabels()).toEqual(["Sessions"]);
613
+
614
+ await expectExplained("Sessions", "loopSessions");
615
+
616
+ // s2 went /home -> /cart -> /home: one session of five.
617
+ const [row] = screen.getAllByTestId("user-flow-loop-row");
618
+
619
+ expect(row).toHaveTextContent("1 sessions");
620
+ expect(row).toHaveTextContent("20%");
621
+ });
622
+
623
+ test("with no loops there is no header and no (i), only the empty line", () => {
624
+ const noLoops: UserFlowAnalysis = analyzeUserFlow(
625
+ response([{ id: "a", pages: ["/home", "/cart"] }]),
626
+ );
627
+
628
+ renderTables(noLoops);
629
+ openTab("loops");
630
+
631
+ expect(screen.queryByTestId("user-flow-loops-header")).toBeNull();
632
+ expect(allInfoLabels()).toEqual([]);
633
+ expect(screen.getByTestId("user-flow-loops")).toHaveTextContent(
634
+ "No session in this range left a page and came straight back to it.",
635
+ );
636
+ });
637
+ });
638
+
639
+ describe("User Flows detail panel: a page on the map", () => {
640
+ test("every figure and both page lists explain themselves; the actions do not", async () => {
641
+ // From session start: /cart is the second page of s1, s2, s4 and s5.
642
+ renderDetail(ANALYSIS, {
643
+ kind: "node",
644
+ id: nodeAt(ANALYSIS, 1, "/cart").id,
645
+ });
646
+
647
+ expect(allInfoLabels()).toEqual([
648
+ "Sessions at this step",
649
+ "Of all sessions",
650
+ "Left the application here",
651
+ "Hit an error here",
652
+ "Where they came from (whole range)",
653
+ "Where they went next (whole range)",
654
+ ]);
655
+
656
+ await expectExplained("Sessions at this step", "detailStepSessions");
657
+ await expectExplained("Of all sessions", "detailOfAllSessions");
658
+ await expectExplained("Left the application here", "detailLeftHere");
659
+ await expectExplained("Hit an error here", "detailErrorsHere");
660
+ await expectExplained(
661
+ "Where they came from (whole range)",
662
+ "detailCameFrom",
663
+ );
664
+ await expectExplained(
665
+ "Where they went next (whole range)",
666
+ "detailWentNext",
667
+ );
668
+ });
669
+
670
+ test("the figures read the way their texts say", () => {
671
+ renderDetail(ANALYSIS, {
672
+ kind: "node",
673
+ id: nodeAt(ANALYSIS, 1, "/cart").id,
674
+ });
675
+
676
+ // Four sessions here; s3 never reached a second page, so they are the whole step.
677
+ expect(statValue("Sessions at this step")).toBe("4");
678
+ expect(statBox("Sessions at this step")).toHaveTextContent(
679
+ "100% of the step",
680
+ );
681
+ // Four of the five sessions the map is drawn from.
682
+ expect(statValue("Of all sessions")).toBe("80%");
683
+ // s4 (/product/1 -> /cart) ended here: one of four.
684
+ expect(statValue("Left the application here")).toBe("1");
685
+ expect(statBox("Left the application here")).toHaveTextContent(
686
+ "25% drop-off",
687
+ );
688
+ // s1's error was placed on /checkout and s4's on no page; s2 was frustrated here.
689
+ expect(statValue("Hit an error here")).toBe("0");
690
+ expect(statBox("Hit an error here")).toHaveTextContent("1 frustrated");
691
+ });
692
+
693
+ test("the page lists count whole journeys, as a share of the page's sessions", () => {
694
+ renderDetail(ANALYSIS, {
695
+ kind: "node",
696
+ id: nodeAt(ANALYSIS, 1, "/cart").id,
697
+ });
698
+
699
+ const cameFrom: HTMLElement = screen.getByTestId(
700
+ "user-flow-previous-pages",
701
+ );
702
+ const wentNext: HTMLElement = screen.getByTestId("user-flow-next-pages");
703
+
704
+ // /home before /cart for s1, s2, s5; /product/:id for s4 - of 4 sessions.
705
+ expect(
706
+ within(cameFrom).getByText("/home").closest("button"),
707
+ ).toHaveTextContent("3 · 75%");
708
+ expect(
709
+ within(cameFrom).getByText("/product/:id").closest("button"),
710
+ ).toHaveTextContent("1 · 25%");
711
+ // s4 left from /cart and adds nothing, so the shares add up to 75%.
712
+ expect(
713
+ within(wentNext).getByText("/checkout").closest("button"),
714
+ ).toHaveTextContent("2 · 50%");
715
+ expect(
716
+ within(wentNext).getByText("/home").closest("button"),
717
+ ).toHaveTextContent("1 · 25%");
718
+ });
719
+
720
+ test("the list (i) sits in the title row: clicking it never re-anchors the map, a row still does", () => {
721
+ renderDetail(ANALYSIS, {
722
+ kind: "node",
723
+ id: nodeAt(ANALYSIS, 1, "/cart").id,
724
+ });
725
+
726
+ const info: HTMLElement = screen.getByTestId(
727
+ "user-flow-previous-pages-info",
728
+ );
729
+ const firstRow: HTMLElement = within(
730
+ screen.getByTestId("user-flow-previous-pages"),
731
+ )
732
+ .getByText("/home")
733
+ .closest("button") as HTMLElement;
734
+
735
+ expect(firstRow.contains(info)).toBe(false);
736
+ expect(info.parentElement?.closest("button, a")).toBeNull();
737
+
738
+ fireEvent.click(info);
739
+ fireEvent.keyDown(info, { key: "Enter" });
740
+
741
+ expect(onAnchor).not.toHaveBeenCalled();
742
+
743
+ fireEvent.click(firstRow);
744
+
745
+ expect(onAnchor).toHaveBeenCalledWith("/home", "forward");
746
+ });
747
+
748
+ test("going backward the terminal figure is where sessions started, and the share is of anchored sessions", async () => {
749
+ const backward: UserFlowAnalysis = analyzeUserFlow(FIXTURE, {
750
+ anchorPage: "/checkout",
751
+ direction: "backward",
752
+ });
753
+
754
+ // s1 and s5 reached /checkout from /cart, having landed on /home.
755
+ renderDetail(backward, {
756
+ kind: "node",
757
+ id: nodeAt(backward, 2, "/home").id,
758
+ });
759
+
760
+ expect(allInfoLabels()).toEqual([
761
+ "Sessions at this step",
762
+ "Of anchored sessions",
763
+ "Started their session here",
764
+ "Hit an error here",
765
+ "Where they came from (whole range)",
766
+ "Where they went next (whole range)",
767
+ ]);
768
+
769
+ await expectExplained("Of anchored sessions", "detailOfAllSessions");
770
+ await expectExplained("Started their session here", "detailStartedHere");
771
+
772
+ expect(statValue("Started their session here")).toBe("2");
773
+ expect(statBox("Started their session here")).toHaveTextContent("100%");
774
+ // Two of the two sessions that visited /checkout, not two of five.
775
+ expect(statValue("Of anchored sessions")).toBe("100%");
776
+ });
777
+
778
+ test("an Other pages node explains its folded list", async () => {
779
+ const crowded: UserFlowAnalysis = analyzeUserFlow(
780
+ response([
781
+ { id: "a1", pages: ["/a"] },
782
+ { id: "a2", pages: ["/a"] },
783
+ { id: "a3", pages: ["/a"] },
784
+ { id: "b1", pages: ["/b"] },
785
+ { id: "b2", pages: ["/b"] },
786
+ { id: "c1", pages: ["/c"] },
787
+ { id: "d1", pages: ["/d"] },
788
+ ]),
789
+ { pagesPerStep: 2 },
790
+ );
791
+ const other: UserFlowNode | undefined = crowded.graph.nodes.find(
792
+ (node: UserFlowNode): boolean => {
793
+ return node.isOther;
794
+ },
795
+ );
796
+
797
+ expect(other).toBeDefined();
798
+
799
+ renderDetail(crowded, { kind: "node", id: other!.id });
800
+
801
+ expect(allInfoLabels()).toEqual([
802
+ "Pages folded together (click to follow one)",
803
+ ]);
804
+
805
+ await expectExplained(
806
+ "Pages folded together (click to follow one)",
807
+ "detailFoldedPages",
808
+ );
809
+
810
+ // /c and /d fold together: one session each, half of Other's two.
811
+ const folded: HTMLElement = screen.getByTestId("user-flow-other-pages");
812
+
813
+ expect(within(folded).getByText("/c").closest("button")).toHaveTextContent(
814
+ "1 · 50%",
815
+ );
816
+ expect(within(folded).getByText("/d").closest("button")).toHaveTextContent(
817
+ "1 · 50%",
818
+ );
819
+ });
820
+ });
821
+
822
+ describe("User Flows detail panel: a band between two pages", () => {
823
+ test("all four figures explain themselves", async () => {
824
+ // s2 went /home -> /cart -> /home: the band from /cart to /home.
825
+ const from: UserFlowNode = nodeAt(ANALYSIS, 1, "/cart");
826
+ const to: UserFlowNode = nodeAt(ANALYSIS, 2, "/home");
827
+
828
+ renderDetail(ANALYSIS, {
829
+ kind: "link",
830
+ id: linkBetween(ANALYSIS, from, to).id,
831
+ });
832
+
833
+ expect(allInfoLabels()).toEqual([
834
+ "Sessions",
835
+ "Share of /cart",
836
+ "Share of /home",
837
+ "Returning to a seen page",
838
+ ]);
839
+
840
+ await expectExplained("Sessions", "detailTransitionSessions");
841
+ await expectExplained("Share of /cart", "detailShareOfSource");
842
+ await expectExplained("Share of /home", "detailShareOfTarget");
843
+ await expectExplained("Returning to a seen page", "detailReturning");
844
+ });
845
+
846
+ test("the two shares divide by different pages, and a return to a page already seen is amber", () => {
847
+ const from: UserFlowNode = nodeAt(ANALYSIS, 1, "/cart");
848
+ const to: UserFlowNode = nodeAt(ANALYSIS, 2, "/home");
849
+
850
+ renderDetail(ANALYSIS, {
851
+ kind: "link",
852
+ id: linkBetween(ANALYSIS, from, to).id,
853
+ });
854
+
855
+ expect(statValue("Sessions")).toBe("1");
856
+ // One of the four sessions on /cart at step 2 went this way...
857
+ expect(statValue("Share of /cart")).toBe("25%");
858
+ // ...and it is the only session on /home at step 3.
859
+ expect(statValue("Share of /home")).toBe("100%");
860
+ // s2 had already seen /home, at step 1: amber, as the text says.
861
+ expect(statValue("Returning to a seen page")).toBe("100%");
862
+ expect(
863
+ statBox("Returning to a seen page").querySelector("p.text-amber-700"),
864
+ ).not.toBeNull();
865
+ });
866
+
867
+ test("a move to a new page is not a return", () => {
868
+ const from: UserFlowNode = nodeAt(ANALYSIS, 0, "/home");
869
+ const to: UserFlowNode = nodeAt(ANALYSIS, 1, "/cart");
870
+
871
+ renderDetail(ANALYSIS, {
872
+ kind: "link",
873
+ id: linkBetween(ANALYSIS, from, to).id,
874
+ });
875
+
876
+ // s1, s2 and s5: three of the four sessions that started on /home.
877
+ expect(statValue("Sessions")).toBe("3");
878
+ expect(statValue("Share of /home")).toBe("75%");
879
+ // Three of the four sessions on /cart at step 2 came from /home (s4 did not).
880
+ expect(statValue("Share of /cart")).toBe("75%");
881
+ expect(statValue("Returning to a seen page")).toBe("0%");
882
+ expect(
883
+ statBox("Returning to a seen page").querySelector("p.text-amber-700"),
884
+ ).toBeNull();
885
+ });
886
+ });