@buoy-gg/route-events 7.0.27 → 7.0.29

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 (31) hide show
  1. package/lib/commonjs/components/NavigationStack.js +38 -3
  2. package/lib/commonjs/components/RouteEventDetail.js +4 -0
  3. package/lib/commonjs/components/RouteEventExpandedContent.js +15 -0
  4. package/lib/commonjs/components/RouteEventItemCompact.js +16 -2
  5. package/lib/commonjs/components/RouteEventsModalWithTabs.js +55 -18
  6. package/lib/commonjs/components/RouteEventsTimeline.js +13 -4
  7. package/lib/commonjs/components/RoutesSitemap.js +77 -11
  8. package/lib/commonjs/sync/routeEventsSyncAdapter.js +9 -3
  9. package/lib/commonjs/utils/testIDs.js +63 -0
  10. package/lib/module/components/NavigationStack.js +38 -3
  11. package/lib/module/components/RouteEventDetail.js +4 -0
  12. package/lib/module/components/RouteEventExpandedContent.js +15 -0
  13. package/lib/module/components/RouteEventItemCompact.js +16 -2
  14. package/lib/module/components/RouteEventsModalWithTabs.js +55 -18
  15. package/lib/module/components/RouteEventsTimeline.js +13 -4
  16. package/lib/module/components/RoutesSitemap.js +77 -11
  17. package/lib/module/sync/routeEventsSyncAdapter.js +9 -3
  18. package/lib/module/utils/testIDs.js +55 -0
  19. package/lib/typescript/components/NavigationStack.d.ts.map +1 -1
  20. package/lib/typescript/components/RouteEventDetail.d.ts.map +1 -1
  21. package/lib/typescript/components/RouteEventExpandedContent.d.ts.map +1 -1
  22. package/lib/typescript/components/RouteEventItemCompact.d.ts.map +1 -1
  23. package/lib/typescript/components/RouteEventsModalWithTabs.d.ts.map +1 -1
  24. package/lib/typescript/components/RouteEventsTimeline.d.ts +3 -1
  25. package/lib/typescript/components/RouteEventsTimeline.d.ts.map +1 -1
  26. package/lib/typescript/components/RoutesSitemap.d.ts.map +1 -1
  27. package/lib/typescript/sync/routeEventsSyncAdapter.d.ts +1 -0
  28. package/lib/typescript/sync/routeEventsSyncAdapter.d.ts.map +1 -1
  29. package/lib/typescript/utils/testIDs.d.ts +39 -0
  30. package/lib/typescript/utils/testIDs.d.ts.map +1 -0
  31. package/package.json +5 -5
@@ -10,6 +10,7 @@ var _sharedUi = require("@buoy-gg/shared-ui");
10
10
  var _license = require("@buoy-gg/license");
11
11
  var _dataViewer = require("@buoy-gg/shared-ui/dataViewer");
12
12
  var _useNavigationStack = require("../useNavigationStack");
13
+ var _testIDs = require("../utils/testIDs");
13
14
  var _jsxRuntime = require("react/jsx-runtime");
14
15
  /**
15
16
  * NavigationStack - Visual representation of current navigation stack
@@ -52,6 +53,9 @@ function NavigationStack({
52
53
  const navigateToIndex = index => onAction ? onAction("navigateToIndex", {
53
54
  index
54
55
  }) : hookResult.navigateToIndex(index);
56
+ const popToIndex = index => onAction ? onAction("popToIndex", {
57
+ index
58
+ }) : hookResult.popToIndex(index);
55
59
  const goBack = () => onAction ? onAction("goBack") : hookResult.goBack();
56
60
  const popToTop = () => onAction ? onAction("popToTop") : hookResult.popToTop();
57
61
  const [expandedIndex, setExpandedIndex] = (0, _react.useState)(null);
@@ -106,6 +110,7 @@ function NavigationStack({
106
110
  style: styles.loadingContainer,
107
111
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
108
112
  style: styles.loadingText,
113
+ testID: "buoy-routes-stack-loading",
109
114
  children: "Loading navigation stack..."
110
115
  })
111
116
  })
@@ -118,11 +123,13 @@ function NavigationStack({
118
123
  style: [styles.container, style],
119
124
  children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
120
125
  style: styles.errorContainer,
126
+ testID: "buoy-routes-stack-error",
121
127
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
122
128
  style: styles.errorText,
123
129
  children: "Error loading stack"
124
130
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
125
131
  style: styles.errorDetail,
132
+ testID: "buoy-routes-stack-error-detail",
126
133
  children: error.message
127
134
  })]
128
135
  })
@@ -135,8 +142,10 @@ function NavigationStack({
135
142
  style: [styles.container, style],
136
143
  children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
137
144
  style: styles.emptyContainer,
145
+ testID: "buoy-routes-stack-empty",
138
146
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
139
147
  style: styles.emptyText,
148
+ testID: "buoy-routes-stack-empty-text",
140
149
  children: "No navigation stack"
141
150
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
142
151
  style: styles.emptySubtext,
@@ -233,12 +242,19 @@ function NavigationStack({
233
242
  const screensToRemove = stackDepth - 1 - selectedRoute.index;
234
243
  const targetIndex = selectedRoute.index;
235
244
  confirmDestructive("Pop to Route", `Remove ${screensToRemove} screen${screensToRemove !== 1 ? "s" : ""} above ${selectedRoute.pathname}?`, "Pop",
236
- // Navigate to the selected route, which effectively pops everything above it
237
- () => navigateToIndex(targetIndex));
245
+ // POP, not navigate. `navigate` only returns to a mounted screen when
246
+ // the name AND the params match, so on any dynamic route — the common
247
+ // case for a stack you would want to unwind — it pushed a fresh copy
248
+ // instead: the dialog promised to remove two screens and the stack came
249
+ // back one deeper, with the route in it twice. `popToIndex` is the
250
+ // action the hook has always exported for this, and the same one the
251
+ // desktop host already dispatches over the socket.
252
+ () => popToIndex(targetIndex));
238
253
  };
239
254
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
240
255
  style: [styles.container, style],
241
256
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ScrollView, {
257
+ testID: "buoy-routes-stack-scroll",
242
258
  style: styles.stackScroll,
243
259
  contentContainerStyle: [styles.stackContent, {
244
260
  paddingBottom: styles.stackContent.padding + insets.bottom
@@ -247,14 +263,19 @@ function NavigationStack({
247
263
  const actualIndex = stack.length - 1 - reverseIndex;
248
264
  const isExpanded = expandedIndex === actualIndex;
249
265
  const hasParams = Object.keys(item.params).length > 0;
266
+ const itemTestID = (0, _testIDs.stackItemTestID)(actualIndex);
250
267
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
251
268
  style: styles.stackItemWrapper,
269
+ testID: itemTestID,
252
270
  children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
253
271
  style: [styles.stackItem, isExpanded && styles.stackItemSelected],
254
272
  children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.TouchableOpacity, {
255
273
  style: styles.stackItemHeader,
256
274
  onPress: () => toggleExpand(actualIndex),
257
275
  activeOpacity: 0.7,
276
+ testID: `${itemTestID}-toggle`,
277
+ accessibilityRole: "button",
278
+ accessibilityLabel: `${item.pathname}, ${item.isFocused ? "focused" : "mounted"}, position ${actualIndex} of ${stackDepth - 1}`,
258
279
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
259
280
  style: styles.expandIndicator,
260
281
  children: isExpanded ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ChevronDown, {
@@ -267,10 +288,12 @@ function NavigationStack({
267
288
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
268
289
  style: styles.stackItemPath,
269
290
  numberOfLines: 1,
291
+ testID: `${itemTestID}-path`,
270
292
  children: item.pathname
271
293
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.InlineCopyButton, {
272
294
  value: item.pathname,
273
- buttonStyle: styles.copyRouteButton
295
+ buttonStyle: styles.copyRouteButton,
296
+ testID: `${itemTestID}-copy`
274
297
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
275
298
  style: [styles.statusBadge, {
276
299
  backgroundColor: item.isFocused ? `${_sharedUi.buoyColors.success}15` : "#3B82F615",
@@ -280,6 +303,7 @@ function NavigationStack({
280
303
  style: [styles.statusBadgeText, {
281
304
  color: item.isFocused ? _sharedUi.buoyColors.success : "#3B82F6"
282
305
  }],
306
+ testID: `${itemTestID}-status`,
283
307
  children: item.isFocused ? "FOCUSED" : "MOUNTED"
284
308
  })
285
309
  })]
@@ -292,6 +316,7 @@ function NavigationStack({
292
316
  children: "Route Name:"
293
317
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
294
318
  style: styles.detailValue,
319
+ testID: `${itemTestID}-name`,
295
320
  children: item.name
296
321
  })]
297
322
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
@@ -301,6 +326,7 @@ function NavigationStack({
301
326
  children: "Position:"
302
327
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Text, {
303
328
  style: styles.detailValue,
329
+ testID: `${itemTestID}-position`,
304
330
  children: [actualIndex, " / ", stackDepth - 1]
305
331
  })]
306
332
  }), hasParams && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
@@ -318,6 +344,9 @@ function NavigationStack({
318
344
  style: [styles.actionButton, isAtRoot && styles.actionButtonDisabled],
319
345
  onPress: handleGoBack,
320
346
  disabled: isAtRoot,
347
+ testID: `${itemTestID}-back`,
348
+ accessibilityRole: "button",
349
+ accessibilityLabel: "Go back one screen",
321
350
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
322
351
  style: [styles.actionButtonText, isAtRoot && styles.actionButtonTextDisabled],
323
352
  children: "Back"
@@ -332,6 +361,9 @@ function NavigationStack({
332
361
  style: [styles.actionButton, item.isFocused && styles.actionButtonDisabled],
333
362
  onPress: handleGo,
334
363
  disabled: item.isFocused,
364
+ testID: `${itemTestID}-go`,
365
+ accessibilityRole: "button",
366
+ accessibilityLabel: `Navigate to ${item.pathname}`,
335
367
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
336
368
  style: [styles.actionButtonText, item.isFocused && styles.actionButtonTextDisabled],
337
369
  children: "Go"
@@ -346,6 +378,9 @@ function NavigationStack({
346
378
  style: [styles.actionButton, (item.isFocused || actualIndex === stackDepth - 1) && styles.actionButtonDisabled],
347
379
  onPress: handlePopTo,
348
380
  disabled: item.isFocused || actualIndex === stackDepth - 1,
381
+ testID: `${itemTestID}-pop-to`,
382
+ accessibilityRole: "button",
383
+ accessibilityLabel: `Remove screens above ${item.pathname}`,
349
384
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
350
385
  style: [styles.actionButtonText, (item.isFocused || actualIndex === stackDepth - 1) && styles.actionButtonTextDisabled],
351
386
  children: "Pop To"
@@ -27,6 +27,7 @@ function RouteEventDetail({
27
27
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
28
28
  style: styles.container,
29
29
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ScrollView, {
30
+ testID: "buoy-routes-detail-scroll",
30
31
  style: styles.scroll,
31
32
  contentContainerStyle: styles.scrollContent,
32
33
  showsVerticalScrollIndicator: true,
@@ -41,6 +42,9 @@ function RouteEventDetail({
41
42
  style: styles.goButton,
42
43
  onPress: () => onNavigate(event.pathname),
43
44
  activeOpacity: 0.8,
45
+ testID: "buoy-routes-detail-go",
46
+ accessibilityRole: "button",
47
+ accessibilityLabel: `Go to route ${event.pathname}`,
44
48
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Navigation, {
45
49
  size: 16,
46
50
  color: _sharedUi.night.color.onAccent
@@ -41,11 +41,13 @@ function RouteEventExpandedContent({
41
41
  style: styles.section,
42
42
  children: [routeTemplate && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
43
43
  style: styles.detailRow,
44
+ testID: "buoy-routes-detail-template",
44
45
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
45
46
  style: styles.detailLabel,
46
47
  children: "Template:"
47
48
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
48
49
  style: styles.detailValue,
50
+ testID: "buoy-routes-detail-template-value",
49
51
  children: routeTemplate
50
52
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.InlineCopyButton, {
51
53
  value: routeTemplate,
@@ -53,11 +55,13 @@ function RouteEventExpandedContent({
53
55
  })]
54
56
  }), event.previousPathname && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
55
57
  style: styles.detailRow,
58
+ testID: "buoy-routes-detail-from",
56
59
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
57
60
  style: styles.detailLabel,
58
61
  children: "From:"
59
62
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
60
63
  style: styles.detailValue,
64
+ testID: "buoy-routes-detail-from-value",
61
65
  children: event.previousPathname
62
66
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.InlineCopyButton, {
63
67
  value: event.previousPathname,
@@ -65,11 +69,13 @@ function RouteEventExpandedContent({
65
69
  })]
66
70
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
67
71
  style: styles.detailRow,
72
+ testID: "buoy-routes-detail-to",
68
73
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
69
74
  style: styles.detailLabel,
70
75
  children: "To:"
71
76
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
72
77
  style: styles.detailValue,
78
+ testID: "buoy-routes-detail-to-value",
73
79
  children: event.pathname
74
80
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.InlineCopyButton, {
75
81
  value: event.pathname,
@@ -80,20 +86,24 @@ function RouteEventExpandedContent({
80
86
  style: styles.section,
81
87
  children: [event.timeSincePrevious !== undefined && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
82
88
  style: styles.detailRow,
89
+ testID: "buoy-routes-detail-duration",
83
90
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
84
91
  style: styles.detailLabel,
85
92
  children: "Duration:"
86
93
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
87
94
  style: styles.detailValue,
95
+ testID: "buoy-routes-detail-duration-value",
88
96
  children: formatDuration(event.timeSincePrevious)
89
97
  })]
90
98
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
91
99
  style: styles.detailRow,
100
+ testID: "buoy-routes-detail-time",
92
101
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
93
102
  style: styles.detailLabel,
94
103
  children: "Time:"
95
104
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
96
105
  style: styles.detailValue,
106
+ testID: "buoy-routes-detail-time-value",
97
107
  children: new Date(event.timestamp).toLocaleString()
98
108
  })]
99
109
  })]
@@ -101,11 +111,13 @@ function RouteEventExpandedContent({
101
111
  style: styles.section,
102
112
  children: [event.segments && event.segments.length > 0 && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
103
113
  style: styles.detailRow,
114
+ testID: "buoy-routes-detail-segments",
104
115
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
105
116
  style: styles.detailLabel,
106
117
  children: "Segments:"
107
118
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
108
119
  style: styles.detailValue,
120
+ testID: "buoy-routes-detail-segments-value",
109
121
  children: event.segments.join(' → ')
110
122
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.InlineCopyButton, {
111
123
  value: JSON.stringify(event.segments),
@@ -117,6 +129,7 @@ function RouteEventExpandedContent({
117
129
  style: styles.dataViewerHeader,
118
130
  children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Text, {
119
131
  style: styles.dataViewerTitle,
132
+ testID: "buoy-routes-detail-params-title",
120
133
  children: ["Parameters (", Object.keys(event.params).length, ")"]
121
134
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.InlineCopyButton, {
122
135
  value: JSON.stringify(event.params, null, 2),
@@ -131,11 +144,13 @@ function RouteEventExpandedContent({
131
144
  })]
132
145
  }), visitNumber > 1 && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
133
146
  style: styles.detailRow,
147
+ testID: "buoy-routes-detail-visited",
134
148
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
135
149
  style: styles.detailLabel,
136
150
  children: "Visited:"
137
151
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Text, {
138
152
  style: styles.detailValue,
153
+ testID: "buoy-routes-detail-visited-value",
139
154
  children: [visitNumber, " time", visitNumber !== 1 ? 's' : '']
140
155
  })]
141
156
  })]
@@ -9,6 +9,7 @@ var _reactNative = require("react-native");
9
9
  var _sharedUi = require("@buoy-gg/shared-ui");
10
10
  var _RouteEventExpandedContent = require("./RouteEventExpandedContent");
11
11
  var _routeTemplate = require("../utils/routeTemplate");
12
+ var _testIDs = require("../utils/testIDs");
12
13
  var _jsxRuntime = require("react/jsx-runtime");
13
14
  /**
14
15
  * RouteEventItemCompact - Compact list item for route events
@@ -116,6 +117,11 @@ const RouteEventItemCompact = exports.RouteEventItemCompact = /*#__PURE__*/(0, _
116
117
  const statusLabel = (0, _react.useMemo)(() => getStatusLabel(routeType), [routeType]);
117
118
  const statusSublabel = (0, _react.useMemo)(() => getStatusSublabel(event), [event]);
118
119
  const badgeText = (0, _react.useMemo)(() => getBadgeText(event), [event]);
120
+ const testID = (0, _react.useMemo)(() => (0, _testIDs.eventRowTestID)(event.pathname, visitNumber), [event.pathname, visitNumber]);
121
+ // Spelled out for assistive tech: the row's whole verdict is a coloured dot
122
+ // and two short words that Fabric merges into the row's container, so
123
+ // VoiceOver would otherwise announce a control it cannot describe.
124
+ const accessibilityLabel = (0, _react.useMemo)(() => `${event.pathname}, ${statusLabel}, ${statusSublabel}`, [event.pathname, statusLabel, statusSublabel]);
119
125
  const expandedContent = (0, _react.useMemo)(() => {
120
126
  if (!isExpanded) return undefined;
121
127
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_RouteEventExpandedContent.RouteEventExpandedContent, {
@@ -134,6 +140,7 @@ const RouteEventItemCompact = exports.RouteEventItemCompact = /*#__PURE__*/(0, _
134
140
  style: [styles.badgeText, {
135
141
  color: statusColor
136
142
  }],
143
+ testID: `${testID}-badge`,
137
144
  children: badgeText
138
145
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
139
146
  style: styles.goButton,
@@ -141,13 +148,16 @@ const RouteEventItemCompact = exports.RouteEventItemCompact = /*#__PURE__*/(0, _
141
148
  e.stopPropagation();
142
149
  onNavigate(event.pathname);
143
150
  },
151
+ testID: `${testID}-go`,
152
+ accessibilityRole: "button",
153
+ accessibilityLabel: `Go to ${event.pathname}`,
144
154
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
145
155
  style: styles.goButtonText,
146
156
  children: "Go"
147
157
  })
148
158
  })]
149
159
  });
150
- }, [onNavigate, event.pathname, badgeText, statusColor]);
160
+ }, [onNavigate, event.pathname, badgeText, statusColor, testID]);
151
161
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.CompactRow, {
152
162
  statusDotColor: statusColor,
153
163
  statusLabel: statusLabel,
@@ -165,7 +175,11 @@ const RouteEventItemCompact = exports.RouteEventItemCompact = /*#__PURE__*/(0, _
165
175
  expandedContent: expandedContent,
166
176
  isExpanded: isExpanded,
167
177
  expandedGlowColor: statusColor,
168
- onPress: () => onPress(index)
178
+ onPress: () => onPress(index),
179
+ testID: testID,
180
+ statusTestID: `${testID}-status`,
181
+ statusSublabelTestID: `${testID}-sublabel`,
182
+ accessibilityLabel: accessibilityLabel
169
183
  });
170
184
  });
171
185
  const styles = _reactNative.StyleSheet.create({
@@ -189,7 +189,11 @@ function RouteEventsModalWithTabs({
189
189
  const loadTabState = async () => {
190
190
  try {
191
191
  const storedTab = await _sharedUi.persistentStorage.getItem(_sharedUi.devToolsStorageKeys.routeEvents.activeTab());
192
- if (storedTab && (storedTab === "routes" || storedTab === "events")) {
192
+ // Every tab the selector offers, including "stack": the save side has
193
+ // always written it, so leaving it off this list meant the Stack tab
194
+ // was persisted and then silently discarded on the next launch —
195
+ // you came back to Events having chosen otherwise.
196
+ if (storedTab === "routes" || storedTab === "events" || storedTab === "stack") {
193
197
  setActiveTab(storedTab);
194
198
  }
195
199
  hasLoadedTabState.current = true;
@@ -361,20 +365,28 @@ function RouteEventsModalWithTabs({
361
365
  const visibleEvents = (0, _react.useMemo)(() => filteredEvents.slice(0, eventLimit), [filteredEvents, eventLimit]);
362
366
  const lockedEventCount = (0, _react.useMemo)(() => Math.max(0, filteredEvents.length - eventLimit), [filteredEvents.length, eventLimit]);
363
367
 
364
- // Calculate visit counts for each route (for duplicate detection)
368
+ /**
369
+ * "This was the Nth time you were here", per event.
370
+ *
371
+ * Keyed by the EVENT, not by its index. Indices belong to the unfiltered
372
+ * list, and every consumer below reads them off the FILTERED one — so with
373
+ * a search typed, or any ignore pattern active (there are three by
374
+ * default), each row was handed the visit count of whatever event sat at
375
+ * that position in the full history. Rows read plausibly and were simply
376
+ * about a different navigation.
377
+ */
365
378
  const visitCounts = (0, _react.useMemo)(() => {
366
379
  const counts = new Map();
367
- const eventVisitNumbers = new Map();
380
+ const byEvent = new Map();
368
381
 
369
- // Iterate from oldest to newest to assign visit numbers correctly
382
+ // Oldest to newest, so the first time you visited a route is visit 1.
370
383
  for (let i = events.length - 1; i >= 0; i--) {
371
384
  const event = events[i];
372
- const currentCount = counts.get(event.pathname) || 0;
373
- const visitNumber = currentCount + 1;
385
+ const visitNumber = (counts.get(event.pathname) || 0) + 1;
374
386
  counts.set(event.pathname, visitNumber);
375
- eventVisitNumbers.set(i, visitNumber);
387
+ byEvent.set(event, visitNumber);
376
388
  }
377
- return eventVisitNumbers;
389
+ return byEvent;
378
390
  }, [events]);
379
391
 
380
392
  // Prepare copy data - memoized so it only rebuilds when dependencies change
@@ -389,7 +401,7 @@ function RouteEventsModalWithTabs({
389
401
  },
390
402
  events: filteredEvents.map((event, index) => ({
391
403
  index,
392
- visitNumber: visitCounts.get(index) || 1,
404
+ visitNumber: visitCounts.get(event) || 1,
393
405
  pathname: event.pathname,
394
406
  timestamp: event.timestamp,
395
407
  timestampRelative: (0, _sharedUi.formatRelativeTime)(event.timestamp),
@@ -451,23 +463,29 @@ function RouteEventsModalWithTabs({
451
463
  if (filteredEvents.length === 0) {
452
464
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
453
465
  style: styles.contentWrapper,
466
+ testID: "buoy-routes-events",
454
467
  children: [showSearchBar && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
455
468
  style: styles.searchContainer,
469
+ testID: "buoy-routes-search-field",
456
470
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.SearchBar, {
457
471
  value: searchQuery,
458
472
  onChange: setSearchQuery,
459
- placeholder: "Search pathname or params..."
473
+ placeholder: "Search pathname or params...",
474
+ testID: "buoy-routes-search"
460
475
  })
461
476
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
462
477
  style: styles.emptyState,
478
+ testID: "buoy-routes-empty",
463
479
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Navigation, {
464
480
  size: 48,
465
481
  color: _sharedUi.night.color.textTertiary
466
482
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
467
483
  style: styles.emptyTitle,
484
+ testID: "buoy-routes-empty-title",
468
485
  children: searchQuery.trim() ? "No matching events" : isListening ? "No route events yet" : "Event listener is paused"
469
486
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
470
487
  style: styles.emptySubtitle,
488
+ testID: "buoy-routes-empty-subtitle",
471
489
  children: searchQuery.trim() ? "Try a different search term" : isListening ? "Navigation events will appear here" : "Press play to start monitoring"
472
490
  })]
473
491
  })]
@@ -477,20 +495,25 @@ function RouteEventsModalWithTabs({
477
495
  // Show chronological timeline of events
478
496
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
479
497
  style: styles.contentWrapper,
498
+ testID: "buoy-routes-events",
480
499
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
481
500
  style: styles.searchContainer,
501
+ testID: "buoy-routes-search-field",
482
502
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.SearchBar, {
483
503
  value: searchQuery,
484
504
  onChange: setSearchQuery,
485
- placeholder: "Search pathname or params..."
505
+ placeholder: "Search pathname or params...",
506
+ testID: "buoy-routes-search"
486
507
  })
487
508
  }), lockedEventCount > 0 && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
488
509
  style: styles.lockedEventsBanner,
510
+ testID: "buoy-routes-locked-banner",
489
511
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Lock, {
490
512
  size: 14,
491
513
  color: _sharedUi.night.color.warning
492
514
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Text, {
493
515
  style: styles.lockedEventsBannerText,
516
+ testID: "buoy-routes-locked-banner-text",
494
517
  children: [lockedEventCount, " older ", lockedEventCount === 1 ? 'event' : 'events', " locked"]
495
518
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
496
519
  style: styles.lockedEventsBannerSubtext,
@@ -508,9 +531,11 @@ function RouteEventsModalWithTabs({
508
531
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Navigation, {
509
532
  onBack: handleBackFromDetail
510
533
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Content, {
511
- title: selectedEvent.pathname
534
+ title: selectedEvent.pathname,
535
+ testID: "buoy-routes-detail-title"
512
536
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Actions, {
513
537
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ToolbarCopyButton, {
538
+ testID: "buoy-routes-detail-copy",
514
539
  value: {
515
540
  pathname: selectedEvent.pathname,
516
541
  previousPathname: selectedEvent.previousPathname,
@@ -528,7 +553,8 @@ function RouteEventsModalWithTabs({
528
553
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Navigation, {
529
554
  onBack: () => setShowFilters(false)
530
555
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Content, {
531
- title: "Filters"
556
+ title: "Filters",
557
+ testID: "buoy-routes-filters-title"
532
558
  })]
533
559
  }) : /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader, {
534
560
  children: [onBack && /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Navigation, {
@@ -548,19 +574,25 @@ function RouteEventsModalWithTabs({
548
574
  label: "Stack"
549
575
  }],
550
576
  activeTab: activeTab,
551
- onTabChange: tab => handleTabChange(tab)
577
+ onTabChange: tab => handleTabChange(tab),
578
+ testIDPrefix: "buoy-routes-tab"
552
579
  })
553
580
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader.Actions, {
554
581
  children: [activeTab === "stack" && /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ToolbarCopyButton, {
555
582
  value: stackCopyValue,
556
- buttonStyle: styles.iconButton
583
+ buttonStyle: styles.iconButton,
584
+ testID: "buoy-routes-stack-copy"
557
585
  }), activeTab === "events" && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
558
586
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ToolbarCopyButton, {
559
587
  value: copyAllEventsData,
560
- buttonStyle: styles.iconButton
588
+ buttonStyle: styles.iconButton,
589
+ testID: "buoy-routes-copy"
561
590
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
562
591
  onPress: handleToggleFilters,
563
592
  style: [styles.iconButton, ignoredPatterns.size > 0 && styles.activeFilterButton],
593
+ testID: "buoy-routes-filters",
594
+ accessibilityRole: "button",
595
+ accessibilityLabel: ignoredPatterns.size > 0 ? `Route filters, ${ignoredPatterns.size} active` : "Route filters",
564
596
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Filter, {
565
597
  size: 14,
566
598
  color: ignoredPatterns.size > 0 ? _sharedUi.night.color.accent : _sharedUi.night.color.textSecondary
@@ -568,10 +600,14 @@ function RouteEventsModalWithTabs({
568
600
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.PowerToggleButton, {
569
601
  isEnabled: isListening,
570
602
  onToggle: handleToggleListening,
571
- accessibilityLabel: "Toggle route event monitoring"
603
+ accessibilityLabel: "Toggle route event monitoring",
604
+ testID: "buoy-routes-power"
572
605
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
573
606
  onPress: handleClearEvents,
574
607
  style: styles.iconButton,
608
+ testID: "buoy-routes-clear",
609
+ accessibilityRole: "button",
610
+ accessibilityLabel: "Clear route events",
575
611
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Trash2, {
576
612
  size: 14,
577
613
  color: _sharedUi.night.color.danger
@@ -667,7 +703,8 @@ const styles = _reactNative.StyleSheet.create({
667
703
  paddingVertical: 8,
668
704
  borderBottomWidth: 1,
669
705
  borderBottomColor: _sharedUi.buoyColors.border,
670
- backgroundColor: _sharedUi.buoyColors.card
706
+ // Clear, like Bench — the host's hoisted sky shows through the header.
707
+ backgroundColor: "transparent"
671
708
  },
672
709
  iconButton: {
673
710
  padding: 6,
@@ -28,19 +28,22 @@ function RouteEventsTimeline({
28
28
  const handleItemPress = (0, _react.useCallback)(index => {
29
29
  const event = events[index];
30
30
  if (!event) return;
31
- onSelectEvent(event, visitCounts.get(index) || 1);
31
+ onSelectEvent(event, visitCounts.get(event) || 1);
32
32
  }, [events, visitCounts, onSelectEvent]);
33
33
  if (events.length === 0) {
34
34
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
35
35
  style: styles.emptyState,
36
+ testID: "buoy-routes-empty",
36
37
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Navigation, {
37
38
  size: 48,
38
39
  color: _sharedUi.night.color.textTertiary
39
40
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
40
41
  style: styles.emptyTitle,
42
+ testID: "buoy-routes-empty-title",
41
43
  children: "No events yet"
42
44
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
43
45
  style: styles.emptySubtitle,
46
+ testID: "buoy-routes-empty-subtitle",
44
47
  children: "Navigation events will appear here"
45
48
  })]
46
49
  });
@@ -51,6 +54,7 @@ function RouteEventsTimeline({
51
54
  // the 500-event cap that measured 32.8 JS FPS during an idle dwell,
52
55
  // tool-bench 2026-07-06).
53
56
  (0, _jsxRuntime.jsx)(_reactNative.FlatList, {
57
+ testID: "buoy-routes-timeline",
54
58
  contentContainerStyle: styles.listContent,
55
59
  data: events
56
60
  // STABLE content keys — index-based keys remount every row when a new
@@ -63,14 +67,19 @@ function RouteEventsTimeline({
63
67
  index
64
68
  }) => /*#__PURE__*/(0, _jsxRuntime.jsx)(_RouteEventItemCompact.RouteEventItemCompact, {
65
69
  event: event,
66
- visitNumber: visitCounts.get(index) || 1,
70
+ visitNumber: visitCounts.get(event) || 1,
67
71
  isExpanded: false,
68
72
  index: index,
69
73
  onPress: handleItemPress,
70
74
  onNavigate: onNavigate
71
75
  }),
72
- showsVerticalScrollIndicator: true,
73
- removeClippedSubviews: true,
76
+ showsVerticalScrollIndicator: true
77
+ // Clipped cells fail Detox's hittability check even when they are on
78
+ // screen, so the e2e bundle keeps every mounted row targetable. The
79
+ // env var is inlined at bundle time; guarded so a non-inlining bundler
80
+ // keeps production behaviour.
81
+ ,
82
+ removeClippedSubviews: !(typeof process !== "undefined" && process.env?.EXPO_PUBLIC_E2E),
74
83
  initialNumToRender: 12,
75
84
  maxToRenderPerBatch: 8,
76
85
  windowSize: 5,