@buoy-gg/route-events 7.0.28 → 7.0.30

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 (27) 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/utils/testIDs.js +63 -0
  9. package/lib/module/components/NavigationStack.js +38 -3
  10. package/lib/module/components/RouteEventDetail.js +4 -0
  11. package/lib/module/components/RouteEventExpandedContent.js +15 -0
  12. package/lib/module/components/RouteEventItemCompact.js +16 -2
  13. package/lib/module/components/RouteEventsModalWithTabs.js +55 -18
  14. package/lib/module/components/RouteEventsTimeline.js +13 -4
  15. package/lib/module/components/RoutesSitemap.js +77 -11
  16. package/lib/module/utils/testIDs.js +55 -0
  17. package/lib/typescript/components/NavigationStack.d.ts.map +1 -1
  18. package/lib/typescript/components/RouteEventDetail.d.ts.map +1 -1
  19. package/lib/typescript/components/RouteEventExpandedContent.d.ts.map +1 -1
  20. package/lib/typescript/components/RouteEventItemCompact.d.ts.map +1 -1
  21. package/lib/typescript/components/RouteEventsModalWithTabs.d.ts.map +1 -1
  22. package/lib/typescript/components/RouteEventsTimeline.d.ts +3 -1
  23. package/lib/typescript/components/RouteEventsTimeline.d.ts.map +1 -1
  24. package/lib/typescript/components/RoutesSitemap.d.ts.map +1 -1
  25. package/lib/typescript/utils/testIDs.d.ts +39 -0
  26. package/lib/typescript/utils/testIDs.d.ts.map +1 -0
  27. package/package.json +5 -5
@@ -11,6 +11,7 @@ import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
11
11
  import { CompactRow, RelativeTime, buoyColors, night } from "@buoy-gg/shared-ui";
12
12
  import { RouteEventExpandedContent } from "./RouteEventExpandedContent";
13
13
  import { getRouteTemplate } from "../utils/routeTemplate";
14
+ import { eventRowTestID } from "../utils/testIDs";
14
15
 
15
16
  // Route type for color coding
16
17
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
@@ -112,6 +113,11 @@ export const RouteEventItemCompact = /*#__PURE__*/memo(function RouteEventItemCo
112
113
  const statusLabel = useMemo(() => getStatusLabel(routeType), [routeType]);
113
114
  const statusSublabel = useMemo(() => getStatusSublabel(event), [event]);
114
115
  const badgeText = useMemo(() => getBadgeText(event), [event]);
116
+ const testID = useMemo(() => eventRowTestID(event.pathname, visitNumber), [event.pathname, visitNumber]);
117
+ // Spelled out for assistive tech: the row's whole verdict is a coloured dot
118
+ // and two short words that Fabric merges into the row's container, so
119
+ // VoiceOver would otherwise announce a control it cannot describe.
120
+ const accessibilityLabel = useMemo(() => `${event.pathname}, ${statusLabel}, ${statusSublabel}`, [event.pathname, statusLabel, statusSublabel]);
115
121
  const expandedContent = useMemo(() => {
116
122
  if (!isExpanded) return undefined;
117
123
  return /*#__PURE__*/_jsx(RouteEventExpandedContent, {
@@ -130,6 +136,7 @@ export const RouteEventItemCompact = /*#__PURE__*/memo(function RouteEventItemCo
130
136
  style: [styles.badgeText, {
131
137
  color: statusColor
132
138
  }],
139
+ testID: `${testID}-badge`,
133
140
  children: badgeText
134
141
  }), /*#__PURE__*/_jsx(TouchableOpacity, {
135
142
  style: styles.goButton,
@@ -137,13 +144,16 @@ export const RouteEventItemCompact = /*#__PURE__*/memo(function RouteEventItemCo
137
144
  e.stopPropagation();
138
145
  onNavigate(event.pathname);
139
146
  },
147
+ testID: `${testID}-go`,
148
+ accessibilityRole: "button",
149
+ accessibilityLabel: `Go to ${event.pathname}`,
140
150
  children: /*#__PURE__*/_jsx(Text, {
141
151
  style: styles.goButtonText,
142
152
  children: "Go"
143
153
  })
144
154
  })]
145
155
  });
146
- }, [onNavigate, event.pathname, badgeText, statusColor]);
156
+ }, [onNavigate, event.pathname, badgeText, statusColor, testID]);
147
157
  return /*#__PURE__*/_jsx(CompactRow, {
148
158
  statusDotColor: statusColor,
149
159
  statusLabel: statusLabel,
@@ -161,7 +171,11 @@ export const RouteEventItemCompact = /*#__PURE__*/memo(function RouteEventItemCo
161
171
  expandedContent: expandedContent,
162
172
  isExpanded: isExpanded,
163
173
  expandedGlowColor: statusColor,
164
- onPress: () => onPress(index)
174
+ onPress: () => onPress(index),
175
+ testID: testID,
176
+ statusTestID: `${testID}-status`,
177
+ statusSublabelTestID: `${testID}-sublabel`,
178
+ accessibilityLabel: accessibilityLabel
165
179
  });
166
180
  });
167
181
  const styles = StyleSheet.create({
@@ -186,7 +186,11 @@ export function RouteEventsModalWithTabs({
186
186
  const loadTabState = async () => {
187
187
  try {
188
188
  const storedTab = await persistentStorage.getItem(devToolsStorageKeys.routeEvents.activeTab());
189
- if (storedTab && (storedTab === "routes" || storedTab === "events")) {
189
+ // Every tab the selector offers, including "stack": the save side has
190
+ // always written it, so leaving it off this list meant the Stack tab
191
+ // was persisted and then silently discarded on the next launch —
192
+ // you came back to Events having chosen otherwise.
193
+ if (storedTab === "routes" || storedTab === "events" || storedTab === "stack") {
190
194
  setActiveTab(storedTab);
191
195
  }
192
196
  hasLoadedTabState.current = true;
@@ -358,20 +362,28 @@ export function RouteEventsModalWithTabs({
358
362
  const visibleEvents = useMemo(() => filteredEvents.slice(0, eventLimit), [filteredEvents, eventLimit]);
359
363
  const lockedEventCount = useMemo(() => Math.max(0, filteredEvents.length - eventLimit), [filteredEvents.length, eventLimit]);
360
364
 
361
- // Calculate visit counts for each route (for duplicate detection)
365
+ /**
366
+ * "This was the Nth time you were here", per event.
367
+ *
368
+ * Keyed by the EVENT, not by its index. Indices belong to the unfiltered
369
+ * list, and every consumer below reads them off the FILTERED one — so with
370
+ * a search typed, or any ignore pattern active (there are three by
371
+ * default), each row was handed the visit count of whatever event sat at
372
+ * that position in the full history. Rows read plausibly and were simply
373
+ * about a different navigation.
374
+ */
362
375
  const visitCounts = useMemo(() => {
363
376
  const counts = new Map();
364
- const eventVisitNumbers = new Map();
377
+ const byEvent = new Map();
365
378
 
366
- // Iterate from oldest to newest to assign visit numbers correctly
379
+ // Oldest to newest, so the first time you visited a route is visit 1.
367
380
  for (let i = events.length - 1; i >= 0; i--) {
368
381
  const event = events[i];
369
- const currentCount = counts.get(event.pathname) || 0;
370
- const visitNumber = currentCount + 1;
382
+ const visitNumber = (counts.get(event.pathname) || 0) + 1;
371
383
  counts.set(event.pathname, visitNumber);
372
- eventVisitNumbers.set(i, visitNumber);
384
+ byEvent.set(event, visitNumber);
373
385
  }
374
- return eventVisitNumbers;
386
+ return byEvent;
375
387
  }, [events]);
376
388
 
377
389
  // Prepare copy data - memoized so it only rebuilds when dependencies change
@@ -386,7 +398,7 @@ export function RouteEventsModalWithTabs({
386
398
  },
387
399
  events: filteredEvents.map((event, index) => ({
388
400
  index,
389
- visitNumber: visitCounts.get(index) || 1,
401
+ visitNumber: visitCounts.get(event) || 1,
390
402
  pathname: event.pathname,
391
403
  timestamp: event.timestamp,
392
404
  timestampRelative: formatRelativeTime(event.timestamp),
@@ -448,23 +460,29 @@ export function RouteEventsModalWithTabs({
448
460
  if (filteredEvents.length === 0) {
449
461
  return /*#__PURE__*/_jsxs(View, {
450
462
  style: styles.contentWrapper,
463
+ testID: "buoy-routes-events",
451
464
  children: [showSearchBar && /*#__PURE__*/_jsx(View, {
452
465
  style: styles.searchContainer,
466
+ testID: "buoy-routes-search-field",
453
467
  children: /*#__PURE__*/_jsx(SearchBar, {
454
468
  value: searchQuery,
455
469
  onChange: setSearchQuery,
456
- placeholder: "Search pathname or params..."
470
+ placeholder: "Search pathname or params...",
471
+ testID: "buoy-routes-search"
457
472
  })
458
473
  }), /*#__PURE__*/_jsxs(View, {
459
474
  style: styles.emptyState,
475
+ testID: "buoy-routes-empty",
460
476
  children: [/*#__PURE__*/_jsx(Navigation, {
461
477
  size: 48,
462
478
  color: night.color.textTertiary
463
479
  }), /*#__PURE__*/_jsx(Text, {
464
480
  style: styles.emptyTitle,
481
+ testID: "buoy-routes-empty-title",
465
482
  children: searchQuery.trim() ? "No matching events" : isListening ? "No route events yet" : "Event listener is paused"
466
483
  }), /*#__PURE__*/_jsx(Text, {
467
484
  style: styles.emptySubtitle,
485
+ testID: "buoy-routes-empty-subtitle",
468
486
  children: searchQuery.trim() ? "Try a different search term" : isListening ? "Navigation events will appear here" : "Press play to start monitoring"
469
487
  })]
470
488
  })]
@@ -474,20 +492,25 @@ export function RouteEventsModalWithTabs({
474
492
  // Show chronological timeline of events
475
493
  return /*#__PURE__*/_jsxs(View, {
476
494
  style: styles.contentWrapper,
495
+ testID: "buoy-routes-events",
477
496
  children: [/*#__PURE__*/_jsx(View, {
478
497
  style: styles.searchContainer,
498
+ testID: "buoy-routes-search-field",
479
499
  children: /*#__PURE__*/_jsx(SearchBar, {
480
500
  value: searchQuery,
481
501
  onChange: setSearchQuery,
482
- placeholder: "Search pathname or params..."
502
+ placeholder: "Search pathname or params...",
503
+ testID: "buoy-routes-search"
483
504
  })
484
505
  }), lockedEventCount > 0 && /*#__PURE__*/_jsxs(View, {
485
506
  style: styles.lockedEventsBanner,
507
+ testID: "buoy-routes-locked-banner",
486
508
  children: [/*#__PURE__*/_jsx(Lock, {
487
509
  size: 14,
488
510
  color: night.color.warning
489
511
  }), /*#__PURE__*/_jsxs(Text, {
490
512
  style: styles.lockedEventsBannerText,
513
+ testID: "buoy-routes-locked-banner-text",
491
514
  children: [lockedEventCount, " older ", lockedEventCount === 1 ? 'event' : 'events', " locked"]
492
515
  }), /*#__PURE__*/_jsx(Text, {
493
516
  style: styles.lockedEventsBannerSubtext,
@@ -505,9 +528,11 @@ export function RouteEventsModalWithTabs({
505
528
  children: [/*#__PURE__*/_jsx(ModalHeader.Navigation, {
506
529
  onBack: handleBackFromDetail
507
530
  }), /*#__PURE__*/_jsx(ModalHeader.Content, {
508
- title: selectedEvent.pathname
531
+ title: selectedEvent.pathname,
532
+ testID: "buoy-routes-detail-title"
509
533
  }), /*#__PURE__*/_jsx(ModalHeader.Actions, {
510
534
  children: /*#__PURE__*/_jsx(ToolbarCopyButton, {
535
+ testID: "buoy-routes-detail-copy",
511
536
  value: {
512
537
  pathname: selectedEvent.pathname,
513
538
  previousPathname: selectedEvent.previousPathname,
@@ -525,7 +550,8 @@ export function RouteEventsModalWithTabs({
525
550
  children: [/*#__PURE__*/_jsx(ModalHeader.Navigation, {
526
551
  onBack: () => setShowFilters(false)
527
552
  }), /*#__PURE__*/_jsx(ModalHeader.Content, {
528
- title: "Filters"
553
+ title: "Filters",
554
+ testID: "buoy-routes-filters-title"
529
555
  })]
530
556
  }) : /*#__PURE__*/_jsxs(ModalHeader, {
531
557
  children: [onBack && /*#__PURE__*/_jsx(ModalHeader.Navigation, {
@@ -545,19 +571,25 @@ export function RouteEventsModalWithTabs({
545
571
  label: "Stack"
546
572
  }],
547
573
  activeTab: activeTab,
548
- onTabChange: tab => handleTabChange(tab)
574
+ onTabChange: tab => handleTabChange(tab),
575
+ testIDPrefix: "buoy-routes-tab"
549
576
  })
550
577
  }), /*#__PURE__*/_jsxs(ModalHeader.Actions, {
551
578
  children: [activeTab === "stack" && /*#__PURE__*/_jsx(ToolbarCopyButton, {
552
579
  value: stackCopyValue,
553
- buttonStyle: styles.iconButton
580
+ buttonStyle: styles.iconButton,
581
+ testID: "buoy-routes-stack-copy"
554
582
  }), activeTab === "events" && /*#__PURE__*/_jsxs(_Fragment, {
555
583
  children: [/*#__PURE__*/_jsx(ToolbarCopyButton, {
556
584
  value: copyAllEventsData,
557
- buttonStyle: styles.iconButton
585
+ buttonStyle: styles.iconButton,
586
+ testID: "buoy-routes-copy"
558
587
  }), /*#__PURE__*/_jsx(TouchableOpacity, {
559
588
  onPress: handleToggleFilters,
560
589
  style: [styles.iconButton, ignoredPatterns.size > 0 && styles.activeFilterButton],
590
+ testID: "buoy-routes-filters",
591
+ accessibilityRole: "button",
592
+ accessibilityLabel: ignoredPatterns.size > 0 ? `Route filters, ${ignoredPatterns.size} active` : "Route filters",
561
593
  children: /*#__PURE__*/_jsx(Filter, {
562
594
  size: 14,
563
595
  color: ignoredPatterns.size > 0 ? night.color.accent : night.color.textSecondary
@@ -565,10 +597,14 @@ export function RouteEventsModalWithTabs({
565
597
  }), /*#__PURE__*/_jsx(PowerToggleButton, {
566
598
  isEnabled: isListening,
567
599
  onToggle: handleToggleListening,
568
- accessibilityLabel: "Toggle route event monitoring"
600
+ accessibilityLabel: "Toggle route event monitoring",
601
+ testID: "buoy-routes-power"
569
602
  }), /*#__PURE__*/_jsx(TouchableOpacity, {
570
603
  onPress: handleClearEvents,
571
604
  style: styles.iconButton,
605
+ testID: "buoy-routes-clear",
606
+ accessibilityRole: "button",
607
+ accessibilityLabel: "Clear route events",
572
608
  children: /*#__PURE__*/_jsx(Trash2, {
573
609
  size: 14,
574
610
  color: night.color.danger
@@ -664,7 +700,8 @@ const styles = StyleSheet.create({
664
700
  paddingVertical: 8,
665
701
  borderBottomWidth: 1,
666
702
  borderBottomColor: buoyColors.border,
667
- backgroundColor: buoyColors.card
703
+ // Clear, like Bench — the host's hoisted sky shows through the header.
704
+ backgroundColor: "transparent"
668
705
  },
669
706
  iconButton: {
670
707
  padding: 6,
@@ -24,19 +24,22 @@ export function RouteEventsTimeline({
24
24
  const handleItemPress = useCallback(index => {
25
25
  const event = events[index];
26
26
  if (!event) return;
27
- onSelectEvent(event, visitCounts.get(index) || 1);
27
+ onSelectEvent(event, visitCounts.get(event) || 1);
28
28
  }, [events, visitCounts, onSelectEvent]);
29
29
  if (events.length === 0) {
30
30
  return /*#__PURE__*/_jsxs(View, {
31
31
  style: styles.emptyState,
32
+ testID: "buoy-routes-empty",
32
33
  children: [/*#__PURE__*/_jsx(Navigation, {
33
34
  size: 48,
34
35
  color: night.color.textTertiary
35
36
  }), /*#__PURE__*/_jsx(Text, {
36
37
  style: styles.emptyTitle,
38
+ testID: "buoy-routes-empty-title",
37
39
  children: "No events yet"
38
40
  }), /*#__PURE__*/_jsx(Text, {
39
41
  style: styles.emptySubtitle,
42
+ testID: "buoy-routes-empty-subtitle",
40
43
  children: "Navigation events will appear here"
41
44
  })]
42
45
  });
@@ -47,6 +50,7 @@ export function RouteEventsTimeline({
47
50
  // the 500-event cap that measured 32.8 JS FPS during an idle dwell,
48
51
  // tool-bench 2026-07-06).
49
52
  _jsx(FlatList, {
53
+ testID: "buoy-routes-timeline",
50
54
  contentContainerStyle: styles.listContent,
51
55
  data: events
52
56
  // STABLE content keys — index-based keys remount every row when a new
@@ -59,14 +63,19 @@ export function RouteEventsTimeline({
59
63
  index
60
64
  }) => /*#__PURE__*/_jsx(RouteEventItemCompact, {
61
65
  event: event,
62
- visitNumber: visitCounts.get(index) || 1,
66
+ visitNumber: visitCounts.get(event) || 1,
63
67
  isExpanded: false,
64
68
  index: index,
65
69
  onPress: handleItemPress,
66
70
  onNavigate: onNavigate
67
71
  }),
68
- showsVerticalScrollIndicator: true,
69
- removeClippedSubviews: true,
72
+ showsVerticalScrollIndicator: true
73
+ // Clipped cells fail Detox's hittability check even when they are on
74
+ // screen, so the e2e bundle keeps every mounted row targetable. The
75
+ // env var is inlined at bundle time; guarded so a non-inlining bundler
76
+ // keeps production behaviour.
77
+ ,
78
+ removeClippedSubviews: !(typeof process !== "undefined" && process.env?.EXPO_PUBLIC_E2E),
70
79
  initialNumToRender: 12,
71
80
  maxToRenderPerBatch: 8,
72
81
  windowSize: 5,
@@ -18,6 +18,7 @@ import { useSafeRouter } from "@buoy-gg/shared-ui";
18
18
  import { Search, ChevronDown, ChevronRight, Home, InlineCopyButton, CopyButton, RefreshCw, formatRelativeTime, ProUpgradeModal } from "@buoy-gg/shared-ui";
19
19
  import { useIsPro } from "@buoy-gg/license";
20
20
  import { useRouteSitemap } from "../useRouteSitemap";
21
+ import { sitemapGroupTestID, sitemapRouteTestID } from "../utils/testIDs";
21
22
 
22
23
  // ============================================================================
23
24
  // Types
@@ -254,6 +255,7 @@ export function RoutesSitemap({
254
255
  style: styles.loadingContainer,
255
256
  children: /*#__PURE__*/_jsx(Text, {
256
257
  style: styles.loadingText,
258
+ testID: "buoy-routes-sitemap-loading",
257
259
  children: isSupported ? "Loading routes..." : "Route sitemap isn't available on the dashboard — the route tree lives on the device. Use the Events tab to see navigation here."
258
260
  })
259
261
  })
@@ -261,8 +263,14 @@ export function RoutesSitemap({
261
263
  }
262
264
  return /*#__PURE__*/_jsxs(View, {
263
265
  style: [styles.container, style],
264
- children: [isSearching && /*#__PURE__*/_jsxs(View, {
266
+ children: [isSearching &&
267
+ /*#__PURE__*/
268
+ // The wrapper carries the handle as well as the field: a TextInput's
269
+ // own testID never reaches the XCUITest element tree on this RN
270
+ // version, so the a11y lane has nothing else to aim at.
271
+ _jsxs(View, {
265
272
  style: styles.searchContainer,
273
+ testID: "buoy-routes-sitemap-search-field",
266
274
  children: [/*#__PURE__*/_jsx(Search, {
267
275
  size: 16,
268
276
  color: night.color.textTertiary
@@ -274,10 +282,15 @@ export function RoutesSitemap({
274
282
  onChangeText: setSearchQuery,
275
283
  autoCapitalize: "none",
276
284
  autoCorrect: false,
277
- autoFocus: true
285
+ autoFocus: true,
286
+ testID: "buoy-routes-sitemap-search",
287
+ accessibilityLabel: "Search routes"
278
288
  }), searchQuery.length > 0 && /*#__PURE__*/_jsx(TouchableOpacity, {
279
289
  onPress: () => setSearchQuery(""),
280
290
  style: styles.clearButton,
291
+ testID: "buoy-routes-sitemap-search-clear",
292
+ accessibilityRole: "button",
293
+ accessibilityLabel: "Clear route search",
281
294
  children: /*#__PURE__*/_jsx(Text, {
282
295
  style: styles.clearButtonText,
283
296
  children: "\u2715"
@@ -288,12 +301,16 @@ export function RoutesSitemap({
288
301
  setIsSearching(false);
289
302
  setSearchQuery("");
290
303
  },
304
+ testID: "buoy-routes-sitemap-search-done",
305
+ accessibilityRole: "button",
306
+ accessibilityLabel: "Done searching routes",
291
307
  children: /*#__PURE__*/_jsx(Text, {
292
308
  style: styles.closeSearchText,
293
309
  children: "Done"
294
310
  })
295
311
  })]
296
312
  }), /*#__PURE__*/_jsxs(ScrollView, {
313
+ testID: "buoy-routes-sitemap-scroll",
297
314
  style: styles.scrollView,
298
315
  contentContainerStyle: styles.scrollContent,
299
316
  children: [!isSearching && /*#__PURE__*/_jsxs(View, {
@@ -306,6 +323,8 @@ export function RoutesSitemap({
306
323
  style: [styles.iconButton, IS_DESKTOP && styles.iconButtonDesktop],
307
324
  onPress: handleGoHome,
308
325
  accessibilityLabel: "Go to home route",
326
+ accessibilityRole: "button",
327
+ testID: "buoy-routes-sitemap-home",
309
328
  children: /*#__PURE__*/_jsx(Home, {
310
329
  size: ACTION_ICON_SIZE,
311
330
  color: night.color.textSecondary
@@ -318,6 +337,7 @@ export function RoutesSitemap({
318
337
  style: [styles.actionWrapper, IS_DESKTOP && styles.actionWrapperDesktop],
319
338
  children: [/*#__PURE__*/_jsx(CopyButton, {
320
339
  value: copyAllData,
340
+ testID: "buoy-routes-sitemap-copy",
321
341
  size: IS_DESKTOP ? ACTION_ICON_SIZE : 14,
322
342
  buttonStyle: IS_DESKTOP ? styles.actionButtonHeaderDesktop : styles.actionButtonHeader
323
343
  }), /*#__PURE__*/_jsx(Text, {
@@ -330,6 +350,9 @@ export function RoutesSitemap({
330
350
  style: [styles.iconButton, IS_DESKTOP && styles.iconButtonDesktop, isRefreshing && styles.refreshButtonDisabled],
331
351
  onPress: handleManualRefresh,
332
352
  disabled: isRefreshing,
353
+ testID: "buoy-routes-sitemap-refresh",
354
+ accessibilityRole: "button",
355
+ accessibilityLabel: "Refresh route tree",
333
356
  children: /*#__PURE__*/_jsx(RefreshCw, {
334
357
  size: ACTION_ICON_SIZE,
335
358
  color: isRefreshing ? night.color.textTertiary : night.color.textSecondary
@@ -343,6 +366,9 @@ export function RoutesSitemap({
343
366
  children: [/*#__PURE__*/_jsx(TouchableOpacity, {
344
367
  style: [styles.iconButton, IS_DESKTOP && styles.iconButtonDesktop],
345
368
  onPress: () => setIsSearching(true),
369
+ testID: "buoy-routes-sitemap-search-open",
370
+ accessibilityRole: "button",
371
+ accessibilityLabel: "Search routes",
346
372
  children: /*#__PURE__*/_jsx(Search, {
347
373
  size: ACTION_ICON_SIZE,
348
374
  color: night.color.textSecondary
@@ -356,28 +382,36 @@ export function RoutesSitemap({
356
382
  style: styles.statsGrid,
357
383
  children: [/*#__PURE__*/_jsx(StatItem, {
358
384
  value: stats.total,
359
- label: "Total"
385
+ label: "Total",
386
+ id: "total"
360
387
  }), /*#__PURE__*/_jsx(StatItem, {
361
388
  value: stats.static,
362
- label: "Static"
389
+ label: "Static",
390
+ id: "static"
363
391
  }), /*#__PURE__*/_jsx(StatItem, {
364
392
  value: stats.dynamic,
365
- label: "Dynamic"
393
+ label: "Dynamic",
394
+ id: "dynamic"
366
395
  }), /*#__PURE__*/_jsx(StatItem, {
367
396
  value: stats.catchAll,
368
- label: "Catch-All"
397
+ label: "Catch-All",
398
+ id: "catch-all"
369
399
  }), /*#__PURE__*/_jsx(StatItem, {
370
400
  value: stats.layouts,
371
- label: "Layouts"
401
+ label: "Layouts",
402
+ id: "layouts"
372
403
  }), /*#__PURE__*/_jsx(StatItem, {
373
404
  value: stats.groups,
374
- label: "Groups"
405
+ label: "Groups",
406
+ id: "groups"
375
407
  })]
376
408
  })]
377
409
  }), displayGroups.length === 0 ? /*#__PURE__*/_jsx(View, {
378
410
  style: styles.emptyState,
411
+ testID: "buoy-routes-sitemap-empty",
379
412
  children: /*#__PURE__*/_jsx(Text, {
380
413
  style: styles.emptyText,
414
+ testID: "buoy-routes-sitemap-empty-text",
381
415
  children: "No routes found"
382
416
  })
383
417
  }) : displayGroups.map(group => /*#__PURE__*/_jsx(RouteGroupView, {
@@ -389,11 +423,13 @@ export function RoutesSitemap({
389
423
  style: styles.metaFooter,
390
424
  children: [/*#__PURE__*/_jsx(Text, {
391
425
  style: styles.metaText,
426
+ testID: "buoy-routes-sitemap-updated",
392
427
  children: lastRefreshLabel
393
428
  }), /*#__PURE__*/_jsx(View, {
394
429
  style: styles.sourceBadge,
395
430
  children: /*#__PURE__*/_jsx(Text, {
396
431
  style: styles.sourceText,
432
+ testID: "buoy-routes-sitemap-source",
397
433
  children: sourceLabel
398
434
  })
399
435
  })]
@@ -412,12 +448,19 @@ export function RoutesSitemap({
412
448
 
413
449
  function StatItem({
414
450
  value,
415
- label
451
+ label,
452
+ id
416
453
  }) {
417
454
  return /*#__PURE__*/_jsxs(View, {
418
455
  style: styles.statItem,
456
+ testID: `buoy-routes-stat-${id}`
457
+ // Fabric merges the number and its caption into this container, so a
458
+ // stat that isn't spelled out announces as a bare digit.
459
+ ,
460
+ accessibilityLabel: `${label}, ${value}`,
419
461
  children: [/*#__PURE__*/_jsx(Text, {
420
462
  style: styles.statValue,
463
+ testID: `buoy-routes-stat-${id}-value`,
421
464
  children: value
422
465
  }), /*#__PURE__*/_jsx(Text, {
423
466
  style: styles.statLabel,
@@ -437,6 +480,9 @@ function RouteGroupView({
437
480
  style: styles.groupHeader,
438
481
  onPress: onToggleExpand,
439
482
  activeOpacity: 0.7,
483
+ testID: sitemapGroupTestID(group.title),
484
+ accessibilityRole: "button",
485
+ accessibilityLabel: `${group.title}, ${group.routes.length} routes`,
440
486
  children: [/*#__PURE__*/_jsxs(View, {
441
487
  style: styles.groupHeaderLeft,
442
488
  children: [isExpanded ? /*#__PURE__*/_jsx(ChevronDown, {
@@ -453,6 +499,7 @@ function RouteGroupView({
453
499
  style: styles.groupBadge,
454
500
  children: /*#__PURE__*/_jsx(Text, {
455
501
  style: styles.groupCount,
502
+ testID: `${sitemapGroupTestID(group.title)}-count`,
456
503
  children: group.routes.length
457
504
  })
458
505
  })]
@@ -480,6 +527,7 @@ function RouteItemView({
480
527
  const hasChildren = route.children.length > 0;
481
528
  const typeColor = getRouteTypeColor(route.type);
482
529
  const canNavigate = route.type !== "layout" && route.type !== "group";
530
+ const testID = sitemapRouteTestID(route.path);
483
531
 
484
532
  // Leaf routes (no children) have nothing to drill into — expanding would only
485
533
  // reveal the Copy/Go actions, so show those inline instead of behind a toggle.
@@ -489,6 +537,7 @@ function RouteItemView({
489
537
  style: [styles.routeItem, {
490
538
  marginLeft: depth * 12
491
539
  }],
540
+ testID: testID,
492
541
  children: /*#__PURE__*/_jsxs(View, {
493
542
  style: styles.routeCard,
494
543
  children: [/*#__PURE__*/_jsxs(View, {
@@ -497,6 +546,9 @@ function RouteItemView({
497
546
  style: styles.routeHeaderLeft,
498
547
  onPress: () => setIsExpanded(!isExpanded),
499
548
  activeOpacity: 0.7,
549
+ testID: `${testID}-toggle`,
550
+ accessibilityRole: "button",
551
+ accessibilityLabel: `${route.path}, ${route.type}, ${route.children.length} nested`,
500
552
  children: [/*#__PURE__*/_jsx(View, {
501
553
  style: styles.expandIndicator,
502
554
  children: isExpanded ? /*#__PURE__*/_jsx(ChevronDown, {
@@ -509,6 +561,7 @@ function RouteItemView({
509
561
  }), /*#__PURE__*/_jsx(Text, {
510
562
  style: styles.routePath,
511
563
  numberOfLines: 3,
564
+ testID: `${testID}-path`,
512
565
  children: route.path
513
566
  })]
514
567
  }) :
@@ -516,12 +569,19 @@ function RouteItemView({
516
569
  // Non-toggling header for leaf routes (keeps the chevron column as a
517
570
  // spacer so paths stay aligned with expandable siblings).
518
571
  _jsxs(View, {
519
- style: styles.routeHeaderLeft,
572
+ style: styles.routeHeaderLeft
573
+ // `accessible` as well as the label: a plain View is not an
574
+ // element, so without it the label is never announced and the
575
+ // path is read as a bare text node with no type beside it.
576
+ ,
577
+ accessible: true,
578
+ accessibilityLabel: `${route.path}, ${route.type}`,
520
579
  children: [/*#__PURE__*/_jsx(View, {
521
580
  style: styles.expandIndicator
522
581
  }), /*#__PURE__*/_jsx(Text, {
523
582
  style: styles.routePath,
524
583
  numberOfLines: 3,
584
+ testID: `${testID}-path`,
525
585
  children: route.path
526
586
  })]
527
587
  }), /*#__PURE__*/_jsxs(View, {
@@ -530,6 +590,7 @@ function RouteItemView({
530
590
  style: styles.childCountBadge,
531
591
  children: /*#__PURE__*/_jsx(Text, {
532
592
  style: styles.childCountText,
593
+ testID: `${testID}-children-count`,
533
594
  children: route.children.length
534
595
  })
535
596
  }), /*#__PURE__*/_jsx(View, {
@@ -541,15 +602,20 @@ function RouteItemView({
541
602
  style: [styles.typeText, {
542
603
  color: typeColor
543
604
  }],
605
+ testID: `${testID}-type`,
544
606
  children: route.type.toUpperCase()
545
607
  })
546
608
  }), /*#__PURE__*/_jsx(InlineCopyButton, {
547
609
  value: route.path,
548
- buttonStyle: styles.iconAction
610
+ buttonStyle: styles.iconAction,
611
+ testID: `${testID}-copy`
549
612
  }), canNavigate && /*#__PURE__*/_jsx(TouchableOpacity, {
550
613
  style: styles.goButton,
551
614
  onPress: () => onNavigate(route),
552
615
  activeOpacity: 0.7,
616
+ testID: `${testID}-go`,
617
+ accessibilityRole: "button",
618
+ accessibilityLabel: `Go to ${route.path}`,
553
619
  children: /*#__PURE__*/_jsx(Text, {
554
620
  style: styles.goButtonText,
555
621
  children: "Go"
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Stable e2e handles for the routes tool.
5
+ *
6
+ * Everything in this tool is addressed by its PATH, never by a list index or a
7
+ * per-session id. Route events are a prepend-list whose indices shift on every
8
+ * navigation, the sitemap re-sorts and regroups as you search, and the stack
9
+ * renumbers itself on every push — the pathname is the one thing that means the
10
+ * same screen on the next launch, and the only thing a test can name up front.
11
+ *
12
+ * A pathname is not testID-safe as it stands (`/blog/2024/react-native-tips`,
13
+ * `/pokemon/[id]`), so it is slugged the way the rest of Buoy's handles read:
14
+ * `buoy-routes-route-blog-year-slug`.
15
+ */
16
+
17
+ /** Any path-ish string → a testID-safe slug (`/` → `-`, brackets dropped). */
18
+ export function pathSlug(path) {
19
+ return String(path).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "root";
20
+ }
21
+
22
+ /**
23
+ * `buoy-routes-event-row-<slug>-<visit>` for one row of the events timeline.
24
+ *
25
+ * The visit number is part of the handle because revisiting a screen is the
26
+ * normal case here: three trips to `/about` would otherwise be three views
27
+ * sharing one testID, which Detox reports as "multiple elements found" — an
28
+ * ambiguity error where the test meant "the second visit". It also makes the
29
+ * tool's own visit counting addressable rather than merely visible.
30
+ */
31
+ export function eventRowTestID(pathname, visitNumber) {
32
+ return `buoy-routes-event-row-${pathSlug(pathname)}-${visitNumber}`;
33
+ }
34
+
35
+ /** `buoy-routes-route-<slug>` for one row of the sitemap. */
36
+ export function sitemapRouteTestID(path) {
37
+ return `buoy-routes-route-${pathSlug(path)}`;
38
+ }
39
+
40
+ /** `buoy-routes-group-<slug>` for a sitemap group header. */
41
+ export function sitemapGroupTestID(title) {
42
+ return `buoy-routes-group-${pathSlug(title)}`;
43
+ }
44
+
45
+ /**
46
+ * `buoy-routes-stack-item-<index>` for one frame of the navigation stack.
47
+ *
48
+ * The exception to addressing by path: a stack legitimately holds the same
49
+ * route twice (push `/route-bench/1` from `/route-bench/1`), and its whole
50
+ * subject is POSITION — "index 2 of 4" is what the row means, so the position
51
+ * is what the handle should say.
52
+ */
53
+ export function stackItemTestID(index) {
54
+ return `buoy-routes-stack-item-${index}`;
55
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"NavigationStack.d.ts","sourceRoot":"","sources":["../../../src/components/NavigationStack.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAuBH,OAAO,EAEL,KAAK,gBAAgB,EACtB,MAAM,uBAAuB,CAAC;AAM/B,uEAAuE;AACvE,MAAM,MAAM,yBAAyB,GACjC,iBAAiB,GACjB,YAAY,GACZ,QAAQ,GACR,UAAU,CAAC;AAEf,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,GAAG,CAAC;IAEZ;;;;OAIG;IACH,aAAa,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAEnC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CACT,MAAM,EAAE,yBAAyB,EACjC,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,KACzB,IAAI,CAAC;IAEV;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7C;AAMD,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,aAAa,EACb,QAAQ,EACR,iBAAiB,GAClB,EAAE,oBAAoB,+BAgbtB"}
1
+ {"version":3,"file":"NavigationStack.d.ts","sourceRoot":"","sources":["../../../src/components/NavigationStack.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAuBH,OAAO,EAEL,KAAK,gBAAgB,EACtB,MAAM,uBAAuB,CAAC;AAO/B,uEAAuE;AACvE,MAAM,MAAM,yBAAyB,GACjC,iBAAiB,GACjB,YAAY,GACZ,QAAQ,GACR,UAAU,CAAC;AAEf,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,GAAG,CAAC;IAEZ;;;;OAIG;IACH,aAAa,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAEnC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CACT,MAAM,EAAE,yBAAyB,EACjC,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,KACzB,IAAI,CAAC;IAEV;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7C;AAMD,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,aAAa,EACb,QAAQ,EACR,iBAAiB,GAClB,EAAE,oBAAoB,+BA6dtB"}
@@ -1 +1 @@
1
- {"version":3,"file":"RouteEventDetail.d.ts","sourceRoot":"","sources":["../../../src/components/RouteEventDetail.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAIzD,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,gBAAgB,CAAC;IACxB,sFAAsF;IACtF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qFAAqF;IACrF,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AAED,wBAAgB,gBAAgB,CAAC,EAC/B,KAAK,EACL,WAAe,EACf,UAAU,GACX,EAAE,qBAAqB,+BA+BvB"}
1
+ {"version":3,"file":"RouteEventDetail.d.ts","sourceRoot":"","sources":["../../../src/components/RouteEventDetail.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAIzD,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,gBAAgB,CAAC;IACxB,sFAAsF;IACtF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qFAAqF;IACrF,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AAED,wBAAgB,gBAAgB,CAAC,EAC/B,KAAK,EACL,WAAe,EACf,UAAU,GACX,EAAE,qBAAqB,+BAmCvB"}
@@ -1 +1 @@
1
- {"version":3,"file":"RouteEventExpandedContent.d.ts","sourceRoot":"","sources":["../../../src/components/RouteEventExpandedContent.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAEzD,MAAM,WAAW,8BAA8B;IAC7C,KAAK,EAAE,gBAAgB,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAeD,wBAAgB,yBAAyB,CAAC,EACxC,KAAK,EACL,WAAW,EACX,aAAa,GACd,EAAE,8BAA8B,+BAiHhC"}
1
+ {"version":3,"file":"RouteEventExpandedContent.d.ts","sourceRoot":"","sources":["../../../src/components/RouteEventExpandedContent.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAEzD,MAAM,WAAW,8BAA8B;IAC7C,KAAK,EAAE,gBAAgB,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAeD,wBAAgB,yBAAyB,CAAC,EACxC,KAAK,EACL,WAAW,EACX,aAAa,GACd,EAAE,8BAA8B,+BA+IhC"}
@@ -1 +1 @@
1
- {"version":3,"file":"RouteEventItemCompact.d.ts","sourceRoot":"","sources":["../../../src/components/RouteEventItemCompact.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAKH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAIzD,MAAM,WAAW,0BAA0B;IACzC,KAAK,EAAE,gBAAgB,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,OAAO,CAAC;IACpB;0DACsD;IACtD,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AA6FD,eAAO,MAAM,qBAAqB,kEAmEhC,CAAC"}
1
+ {"version":3,"file":"RouteEventItemCompact.d.ts","sourceRoot":"","sources":["../../../src/components/RouteEventItemCompact.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAKH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAKzD,MAAM,WAAW,0BAA0B;IACzC,KAAK,EAAE,gBAAgB,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,OAAO,CAAC;IACpB;0DACsD;IACtD,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AA6FD,eAAO,MAAM,qBAAqB,kEA0FhC,CAAC"}