@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
@@ -9,6 +9,7 @@ var _sharedUi = require("@buoy-gg/shared-ui");
9
9
  var _reactNative = require("react-native");
10
10
  var _license = require("@buoy-gg/license");
11
11
  var _useRouteSitemap = require("../useRouteSitemap");
12
+ var _testIDs = require("../utils/testIDs");
12
13
  var _jsxRuntime = require("react/jsx-runtime");
13
14
  /**
14
15
  * RoutesSitemap - Visual sitemap of all app routes
@@ -256,6 +257,7 @@ function RoutesSitemap({
256
257
  style: styles.loadingContainer,
257
258
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
258
259
  style: styles.loadingText,
260
+ testID: "buoy-routes-sitemap-loading",
259
261
  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."
260
262
  })
261
263
  })
@@ -263,8 +265,14 @@ function RoutesSitemap({
263
265
  }
264
266
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
265
267
  style: [styles.container, style],
266
- children: [isSearching && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
268
+ children: [isSearching &&
269
+ /*#__PURE__*/
270
+ // The wrapper carries the handle as well as the field: a TextInput's
271
+ // own testID never reaches the XCUITest element tree on this RN
272
+ // version, so the a11y lane has nothing else to aim at.
273
+ (0, _jsxRuntime.jsxs)(_reactNative.View, {
267
274
  style: styles.searchContainer,
275
+ testID: "buoy-routes-sitemap-search-field",
268
276
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Search, {
269
277
  size: 16,
270
278
  color: _sharedUi.night.color.textTertiary
@@ -276,10 +284,15 @@ function RoutesSitemap({
276
284
  onChangeText: setSearchQuery,
277
285
  autoCapitalize: "none",
278
286
  autoCorrect: false,
279
- autoFocus: true
287
+ autoFocus: true,
288
+ testID: "buoy-routes-sitemap-search",
289
+ accessibilityLabel: "Search routes"
280
290
  }), searchQuery.length > 0 && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
281
291
  onPress: () => setSearchQuery(""),
282
292
  style: styles.clearButton,
293
+ testID: "buoy-routes-sitemap-search-clear",
294
+ accessibilityRole: "button",
295
+ accessibilityLabel: "Clear route search",
283
296
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
284
297
  style: styles.clearButtonText,
285
298
  children: "\u2715"
@@ -290,12 +303,16 @@ function RoutesSitemap({
290
303
  setIsSearching(false);
291
304
  setSearchQuery("");
292
305
  },
306
+ testID: "buoy-routes-sitemap-search-done",
307
+ accessibilityRole: "button",
308
+ accessibilityLabel: "Done searching routes",
293
309
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
294
310
  style: styles.closeSearchText,
295
311
  children: "Done"
296
312
  })
297
313
  })]
298
314
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.ScrollView, {
315
+ testID: "buoy-routes-sitemap-scroll",
299
316
  style: styles.scrollView,
300
317
  contentContainerStyle: styles.scrollContent,
301
318
  children: [!isSearching && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
@@ -308,6 +325,8 @@ function RoutesSitemap({
308
325
  style: [styles.iconButton, IS_DESKTOP && styles.iconButtonDesktop],
309
326
  onPress: handleGoHome,
310
327
  accessibilityLabel: "Go to home route",
328
+ accessibilityRole: "button",
329
+ testID: "buoy-routes-sitemap-home",
311
330
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Home, {
312
331
  size: ACTION_ICON_SIZE,
313
332
  color: _sharedUi.night.color.textSecondary
@@ -320,6 +339,7 @@ function RoutesSitemap({
320
339
  style: [styles.actionWrapper, IS_DESKTOP && styles.actionWrapperDesktop],
321
340
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.CopyButton, {
322
341
  value: copyAllData,
342
+ testID: "buoy-routes-sitemap-copy",
323
343
  size: IS_DESKTOP ? ACTION_ICON_SIZE : 14,
324
344
  buttonStyle: IS_DESKTOP ? styles.actionButtonHeaderDesktop : styles.actionButtonHeader
325
345
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
@@ -332,6 +352,9 @@ function RoutesSitemap({
332
352
  style: [styles.iconButton, IS_DESKTOP && styles.iconButtonDesktop, isRefreshing && styles.refreshButtonDisabled],
333
353
  onPress: handleManualRefresh,
334
354
  disabled: isRefreshing,
355
+ testID: "buoy-routes-sitemap-refresh",
356
+ accessibilityRole: "button",
357
+ accessibilityLabel: "Refresh route tree",
335
358
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.RefreshCw, {
336
359
  size: ACTION_ICON_SIZE,
337
360
  color: isRefreshing ? _sharedUi.night.color.textTertiary : _sharedUi.night.color.textSecondary
@@ -345,6 +368,9 @@ function RoutesSitemap({
345
368
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
346
369
  style: [styles.iconButton, IS_DESKTOP && styles.iconButtonDesktop],
347
370
  onPress: () => setIsSearching(true),
371
+ testID: "buoy-routes-sitemap-search-open",
372
+ accessibilityRole: "button",
373
+ accessibilityLabel: "Search routes",
348
374
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Search, {
349
375
  size: ACTION_ICON_SIZE,
350
376
  color: _sharedUi.night.color.textSecondary
@@ -358,28 +384,36 @@ function RoutesSitemap({
358
384
  style: styles.statsGrid,
359
385
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(StatItem, {
360
386
  value: stats.total,
361
- label: "Total"
387
+ label: "Total",
388
+ id: "total"
362
389
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(StatItem, {
363
390
  value: stats.static,
364
- label: "Static"
391
+ label: "Static",
392
+ id: "static"
365
393
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(StatItem, {
366
394
  value: stats.dynamic,
367
- label: "Dynamic"
395
+ label: "Dynamic",
396
+ id: "dynamic"
368
397
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(StatItem, {
369
398
  value: stats.catchAll,
370
- label: "Catch-All"
399
+ label: "Catch-All",
400
+ id: "catch-all"
371
401
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(StatItem, {
372
402
  value: stats.layouts,
373
- label: "Layouts"
403
+ label: "Layouts",
404
+ id: "layouts"
374
405
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(StatItem, {
375
406
  value: stats.groups,
376
- label: "Groups"
407
+ label: "Groups",
408
+ id: "groups"
377
409
  })]
378
410
  })]
379
411
  }), displayGroups.length === 0 ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
380
412
  style: styles.emptyState,
413
+ testID: "buoy-routes-sitemap-empty",
381
414
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
382
415
  style: styles.emptyText,
416
+ testID: "buoy-routes-sitemap-empty-text",
383
417
  children: "No routes found"
384
418
  })
385
419
  }) : displayGroups.map(group => /*#__PURE__*/(0, _jsxRuntime.jsx)(RouteGroupView, {
@@ -391,11 +425,13 @@ function RoutesSitemap({
391
425
  style: styles.metaFooter,
392
426
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
393
427
  style: styles.metaText,
428
+ testID: "buoy-routes-sitemap-updated",
394
429
  children: lastRefreshLabel
395
430
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
396
431
  style: styles.sourceBadge,
397
432
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
398
433
  style: styles.sourceText,
434
+ testID: "buoy-routes-sitemap-source",
399
435
  children: sourceLabel
400
436
  })
401
437
  })]
@@ -414,12 +450,19 @@ function RoutesSitemap({
414
450
 
415
451
  function StatItem({
416
452
  value,
417
- label
453
+ label,
454
+ id
418
455
  }) {
419
456
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
420
457
  style: styles.statItem,
458
+ testID: `buoy-routes-stat-${id}`
459
+ // Fabric merges the number and its caption into this container, so a
460
+ // stat that isn't spelled out announces as a bare digit.
461
+ ,
462
+ accessibilityLabel: `${label}, ${value}`,
421
463
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
422
464
  style: styles.statValue,
465
+ testID: `buoy-routes-stat-${id}-value`,
423
466
  children: value
424
467
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
425
468
  style: styles.statLabel,
@@ -439,6 +482,9 @@ function RouteGroupView({
439
482
  style: styles.groupHeader,
440
483
  onPress: onToggleExpand,
441
484
  activeOpacity: 0.7,
485
+ testID: (0, _testIDs.sitemapGroupTestID)(group.title),
486
+ accessibilityRole: "button",
487
+ accessibilityLabel: `${group.title}, ${group.routes.length} routes`,
442
488
  children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
443
489
  style: styles.groupHeaderLeft,
444
490
  children: [isExpanded ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ChevronDown, {
@@ -455,6 +501,7 @@ function RouteGroupView({
455
501
  style: styles.groupBadge,
456
502
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
457
503
  style: styles.groupCount,
504
+ testID: `${(0, _testIDs.sitemapGroupTestID)(group.title)}-count`,
458
505
  children: group.routes.length
459
506
  })
460
507
  })]
@@ -482,6 +529,7 @@ function RouteItemView({
482
529
  const hasChildren = route.children.length > 0;
483
530
  const typeColor = getRouteTypeColor(route.type);
484
531
  const canNavigate = route.type !== "layout" && route.type !== "group";
532
+ const testID = (0, _testIDs.sitemapRouteTestID)(route.path);
485
533
 
486
534
  // Leaf routes (no children) have nothing to drill into — expanding would only
487
535
  // reveal the Copy/Go actions, so show those inline instead of behind a toggle.
@@ -491,6 +539,7 @@ function RouteItemView({
491
539
  style: [styles.routeItem, {
492
540
  marginLeft: depth * 12
493
541
  }],
542
+ testID: testID,
494
543
  children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
495
544
  style: styles.routeCard,
496
545
  children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
@@ -499,6 +548,9 @@ function RouteItemView({
499
548
  style: styles.routeHeaderLeft,
500
549
  onPress: () => setIsExpanded(!isExpanded),
501
550
  activeOpacity: 0.7,
551
+ testID: `${testID}-toggle`,
552
+ accessibilityRole: "button",
553
+ accessibilityLabel: `${route.path}, ${route.type}, ${route.children.length} nested`,
502
554
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
503
555
  style: styles.expandIndicator,
504
556
  children: isExpanded ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ChevronDown, {
@@ -511,6 +563,7 @@ function RouteItemView({
511
563
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
512
564
  style: styles.routePath,
513
565
  numberOfLines: 3,
566
+ testID: `${testID}-path`,
514
567
  children: route.path
515
568
  })]
516
569
  }) :
@@ -518,12 +571,19 @@ function RouteItemView({
518
571
  // Non-toggling header for leaf routes (keeps the chevron column as a
519
572
  // spacer so paths stay aligned with expandable siblings).
520
573
  (0, _jsxRuntime.jsxs)(_reactNative.View, {
521
- style: styles.routeHeaderLeft,
574
+ style: styles.routeHeaderLeft
575
+ // `accessible` as well as the label: a plain View is not an
576
+ // element, so without it the label is never announced and the
577
+ // path is read as a bare text node with no type beside it.
578
+ ,
579
+ accessible: true,
580
+ accessibilityLabel: `${route.path}, ${route.type}`,
522
581
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
523
582
  style: styles.expandIndicator
524
583
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
525
584
  style: styles.routePath,
526
585
  numberOfLines: 3,
586
+ testID: `${testID}-path`,
527
587
  children: route.path
528
588
  })]
529
589
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
@@ -532,6 +592,7 @@ function RouteItemView({
532
592
  style: styles.childCountBadge,
533
593
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
534
594
  style: styles.childCountText,
595
+ testID: `${testID}-children-count`,
535
596
  children: route.children.length
536
597
  })
537
598
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
@@ -543,15 +604,20 @@ function RouteItemView({
543
604
  style: [styles.typeText, {
544
605
  color: typeColor
545
606
  }],
607
+ testID: `${testID}-type`,
546
608
  children: route.type.toUpperCase()
547
609
  })
548
610
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.InlineCopyButton, {
549
611
  value: route.path,
550
- buttonStyle: styles.iconAction
612
+ buttonStyle: styles.iconAction,
613
+ testID: `${testID}-copy`
551
614
  }), canNavigate && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
552
615
  style: styles.goButton,
553
616
  onPress: () => onNavigate(route),
554
617
  activeOpacity: 0.7,
618
+ testID: `${testID}-go`,
619
+ accessibilityRole: "button",
620
+ accessibilityLabel: `Go to ${route.path}`,
555
621
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
556
622
  style: styles.goButtonText,
557
623
  children: "Go"
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.eventRowTestID = eventRowTestID;
7
+ exports.pathSlug = pathSlug;
8
+ exports.sitemapGroupTestID = sitemapGroupTestID;
9
+ exports.sitemapRouteTestID = sitemapRouteTestID;
10
+ exports.stackItemTestID = stackItemTestID;
11
+ /**
12
+ * Stable e2e handles for the routes tool.
13
+ *
14
+ * Everything in this tool is addressed by its PATH, never by a list index or a
15
+ * per-session id. Route events are a prepend-list whose indices shift on every
16
+ * navigation, the sitemap re-sorts and regroups as you search, and the stack
17
+ * renumbers itself on every push — the pathname is the one thing that means the
18
+ * same screen on the next launch, and the only thing a test can name up front.
19
+ *
20
+ * A pathname is not testID-safe as it stands (`/blog/2024/react-native-tips`,
21
+ * `/pokemon/[id]`), so it is slugged the way the rest of Buoy's handles read:
22
+ * `buoy-routes-route-blog-year-slug`.
23
+ */
24
+
25
+ /** Any path-ish string → a testID-safe slug (`/` → `-`, brackets dropped). */
26
+ function pathSlug(path) {
27
+ return String(path).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "root";
28
+ }
29
+
30
+ /**
31
+ * `buoy-routes-event-row-<slug>-<visit>` for one row of the events timeline.
32
+ *
33
+ * The visit number is part of the handle because revisiting a screen is the
34
+ * normal case here: three trips to `/about` would otherwise be three views
35
+ * sharing one testID, which Detox reports as "multiple elements found" — an
36
+ * ambiguity error where the test meant "the second visit". It also makes the
37
+ * tool's own visit counting addressable rather than merely visible.
38
+ */
39
+ function eventRowTestID(pathname, visitNumber) {
40
+ return `buoy-routes-event-row-${pathSlug(pathname)}-${visitNumber}`;
41
+ }
42
+
43
+ /** `buoy-routes-route-<slug>` for one row of the sitemap. */
44
+ function sitemapRouteTestID(path) {
45
+ return `buoy-routes-route-${pathSlug(path)}`;
46
+ }
47
+
48
+ /** `buoy-routes-group-<slug>` for a sitemap group header. */
49
+ function sitemapGroupTestID(title) {
50
+ return `buoy-routes-group-${pathSlug(title)}`;
51
+ }
52
+
53
+ /**
54
+ * `buoy-routes-stack-item-<index>` for one frame of the navigation stack.
55
+ *
56
+ * The exception to addressing by path: a stack legitimately holds the same
57
+ * route twice (push `/route-bench/1` from `/route-bench/1`), and its whole
58
+ * subject is POSITION — "index 2 of 4" is what the row means, so the position
59
+ * is what the handle should say.
60
+ */
61
+ function stackItemTestID(index) {
62
+ return `buoy-routes-stack-item-${index}`;
63
+ }
@@ -13,6 +13,7 @@ import { ChevronDown, ChevronRight, InlineCopyButton, ProUpgradeModal, buoyColor
13
13
  import { useIsPro } from "@buoy-gg/license";
14
14
  import { DataViewer } from "@buoy-gg/shared-ui/dataViewer";
15
15
  import { useNavigationStack } from "../useNavigationStack";
16
+ import { stackItemTestID } from "../utils/testIDs";
16
17
 
17
18
  // ============================================================================
18
19
  // Types
@@ -48,6 +49,9 @@ export function NavigationStack({
48
49
  const navigateToIndex = index => onAction ? onAction("navigateToIndex", {
49
50
  index
50
51
  }) : hookResult.navigateToIndex(index);
52
+ const popToIndex = index => onAction ? onAction("popToIndex", {
53
+ index
54
+ }) : hookResult.popToIndex(index);
51
55
  const goBack = () => onAction ? onAction("goBack") : hookResult.goBack();
52
56
  const popToTop = () => onAction ? onAction("popToTop") : hookResult.popToTop();
53
57
  const [expandedIndex, setExpandedIndex] = useState(null);
@@ -102,6 +106,7 @@ export function NavigationStack({
102
106
  style: styles.loadingContainer,
103
107
  children: /*#__PURE__*/_jsx(Text, {
104
108
  style: styles.loadingText,
109
+ testID: "buoy-routes-stack-loading",
105
110
  children: "Loading navigation stack..."
106
111
  })
107
112
  })
@@ -114,11 +119,13 @@ export function NavigationStack({
114
119
  style: [styles.container, style],
115
120
  children: /*#__PURE__*/_jsxs(View, {
116
121
  style: styles.errorContainer,
122
+ testID: "buoy-routes-stack-error",
117
123
  children: [/*#__PURE__*/_jsx(Text, {
118
124
  style: styles.errorText,
119
125
  children: "Error loading stack"
120
126
  }), /*#__PURE__*/_jsx(Text, {
121
127
  style: styles.errorDetail,
128
+ testID: "buoy-routes-stack-error-detail",
122
129
  children: error.message
123
130
  })]
124
131
  })
@@ -131,8 +138,10 @@ export function NavigationStack({
131
138
  style: [styles.container, style],
132
139
  children: /*#__PURE__*/_jsxs(View, {
133
140
  style: styles.emptyContainer,
141
+ testID: "buoy-routes-stack-empty",
134
142
  children: [/*#__PURE__*/_jsx(Text, {
135
143
  style: styles.emptyText,
144
+ testID: "buoy-routes-stack-empty-text",
136
145
  children: "No navigation stack"
137
146
  }), /*#__PURE__*/_jsx(Text, {
138
147
  style: styles.emptySubtext,
@@ -229,12 +238,19 @@ export function NavigationStack({
229
238
  const screensToRemove = stackDepth - 1 - selectedRoute.index;
230
239
  const targetIndex = selectedRoute.index;
231
240
  confirmDestructive("Pop to Route", `Remove ${screensToRemove} screen${screensToRemove !== 1 ? "s" : ""} above ${selectedRoute.pathname}?`, "Pop",
232
- // Navigate to the selected route, which effectively pops everything above it
233
- () => navigateToIndex(targetIndex));
241
+ // POP, not navigate. `navigate` only returns to a mounted screen when
242
+ // the name AND the params match, so on any dynamic route — the common
243
+ // case for a stack you would want to unwind — it pushed a fresh copy
244
+ // instead: the dialog promised to remove two screens and the stack came
245
+ // back one deeper, with the route in it twice. `popToIndex` is the
246
+ // action the hook has always exported for this, and the same one the
247
+ // desktop host already dispatches over the socket.
248
+ () => popToIndex(targetIndex));
234
249
  };
235
250
  return /*#__PURE__*/_jsxs(View, {
236
251
  style: [styles.container, style],
237
252
  children: [/*#__PURE__*/_jsx(ScrollView, {
253
+ testID: "buoy-routes-stack-scroll",
238
254
  style: styles.stackScroll,
239
255
  contentContainerStyle: [styles.stackContent, {
240
256
  paddingBottom: styles.stackContent.padding + insets.bottom
@@ -243,14 +259,19 @@ export function NavigationStack({
243
259
  const actualIndex = stack.length - 1 - reverseIndex;
244
260
  const isExpanded = expandedIndex === actualIndex;
245
261
  const hasParams = Object.keys(item.params).length > 0;
262
+ const itemTestID = stackItemTestID(actualIndex);
246
263
  return /*#__PURE__*/_jsx(View, {
247
264
  style: styles.stackItemWrapper,
265
+ testID: itemTestID,
248
266
  children: /*#__PURE__*/_jsxs(View, {
249
267
  style: [styles.stackItem, isExpanded && styles.stackItemSelected],
250
268
  children: [/*#__PURE__*/_jsxs(TouchableOpacity, {
251
269
  style: styles.stackItemHeader,
252
270
  onPress: () => toggleExpand(actualIndex),
253
271
  activeOpacity: 0.7,
272
+ testID: `${itemTestID}-toggle`,
273
+ accessibilityRole: "button",
274
+ accessibilityLabel: `${item.pathname}, ${item.isFocused ? "focused" : "mounted"}, position ${actualIndex} of ${stackDepth - 1}`,
254
275
  children: [/*#__PURE__*/_jsx(View, {
255
276
  style: styles.expandIndicator,
256
277
  children: isExpanded ? /*#__PURE__*/_jsx(ChevronDown, {
@@ -263,10 +284,12 @@ export function NavigationStack({
263
284
  }), /*#__PURE__*/_jsx(Text, {
264
285
  style: styles.stackItemPath,
265
286
  numberOfLines: 1,
287
+ testID: `${itemTestID}-path`,
266
288
  children: item.pathname
267
289
  }), /*#__PURE__*/_jsx(InlineCopyButton, {
268
290
  value: item.pathname,
269
- buttonStyle: styles.copyRouteButton
291
+ buttonStyle: styles.copyRouteButton,
292
+ testID: `${itemTestID}-copy`
270
293
  }), /*#__PURE__*/_jsx(View, {
271
294
  style: [styles.statusBadge, {
272
295
  backgroundColor: item.isFocused ? `${buoyColors.success}15` : "#3B82F615",
@@ -276,6 +299,7 @@ export function NavigationStack({
276
299
  style: [styles.statusBadgeText, {
277
300
  color: item.isFocused ? buoyColors.success : "#3B82F6"
278
301
  }],
302
+ testID: `${itemTestID}-status`,
279
303
  children: item.isFocused ? "FOCUSED" : "MOUNTED"
280
304
  })
281
305
  })]
@@ -288,6 +312,7 @@ export function NavigationStack({
288
312
  children: "Route Name:"
289
313
  }), /*#__PURE__*/_jsx(Text, {
290
314
  style: styles.detailValue,
315
+ testID: `${itemTestID}-name`,
291
316
  children: item.name
292
317
  })]
293
318
  }), /*#__PURE__*/_jsxs(View, {
@@ -297,6 +322,7 @@ export function NavigationStack({
297
322
  children: "Position:"
298
323
  }), /*#__PURE__*/_jsxs(Text, {
299
324
  style: styles.detailValue,
325
+ testID: `${itemTestID}-position`,
300
326
  children: [actualIndex, " / ", stackDepth - 1]
301
327
  })]
302
328
  }), hasParams && /*#__PURE__*/_jsx(View, {
@@ -314,6 +340,9 @@ export function NavigationStack({
314
340
  style: [styles.actionButton, isAtRoot && styles.actionButtonDisabled],
315
341
  onPress: handleGoBack,
316
342
  disabled: isAtRoot,
343
+ testID: `${itemTestID}-back`,
344
+ accessibilityRole: "button",
345
+ accessibilityLabel: "Go back one screen",
317
346
  children: /*#__PURE__*/_jsx(Text, {
318
347
  style: [styles.actionButtonText, isAtRoot && styles.actionButtonTextDisabled],
319
348
  children: "Back"
@@ -328,6 +357,9 @@ export function NavigationStack({
328
357
  style: [styles.actionButton, item.isFocused && styles.actionButtonDisabled],
329
358
  onPress: handleGo,
330
359
  disabled: item.isFocused,
360
+ testID: `${itemTestID}-go`,
361
+ accessibilityRole: "button",
362
+ accessibilityLabel: `Navigate to ${item.pathname}`,
331
363
  children: /*#__PURE__*/_jsx(Text, {
332
364
  style: [styles.actionButtonText, item.isFocused && styles.actionButtonTextDisabled],
333
365
  children: "Go"
@@ -342,6 +374,9 @@ export function NavigationStack({
342
374
  style: [styles.actionButton, (item.isFocused || actualIndex === stackDepth - 1) && styles.actionButtonDisabled],
343
375
  onPress: handlePopTo,
344
376
  disabled: item.isFocused || actualIndex === stackDepth - 1,
377
+ testID: `${itemTestID}-pop-to`,
378
+ accessibilityRole: "button",
379
+ accessibilityLabel: `Remove screens above ${item.pathname}`,
345
380
  children: /*#__PURE__*/_jsx(Text, {
346
381
  style: [styles.actionButtonText, (item.isFocused || actualIndex === stackDepth - 1) && styles.actionButtonTextDisabled],
347
382
  children: "Pop To"
@@ -23,6 +23,7 @@ export function RouteEventDetail({
23
23
  return /*#__PURE__*/_jsxs(View, {
24
24
  style: styles.container,
25
25
  children: [/*#__PURE__*/_jsx(ScrollView, {
26
+ testID: "buoy-routes-detail-scroll",
26
27
  style: styles.scroll,
27
28
  contentContainerStyle: styles.scrollContent,
28
29
  showsVerticalScrollIndicator: true,
@@ -37,6 +38,9 @@ export function RouteEventDetail({
37
38
  style: styles.goButton,
38
39
  onPress: () => onNavigate(event.pathname),
39
40
  activeOpacity: 0.8,
41
+ testID: "buoy-routes-detail-go",
42
+ accessibilityRole: "button",
43
+ accessibilityLabel: `Go to route ${event.pathname}`,
40
44
  children: [/*#__PURE__*/_jsx(Navigation, {
41
45
  size: 16,
42
46
  color: night.color.onAccent
@@ -37,11 +37,13 @@ export function RouteEventExpandedContent({
37
37
  style: styles.section,
38
38
  children: [routeTemplate && /*#__PURE__*/_jsxs(View, {
39
39
  style: styles.detailRow,
40
+ testID: "buoy-routes-detail-template",
40
41
  children: [/*#__PURE__*/_jsx(Text, {
41
42
  style: styles.detailLabel,
42
43
  children: "Template:"
43
44
  }), /*#__PURE__*/_jsx(Text, {
44
45
  style: styles.detailValue,
46
+ testID: "buoy-routes-detail-template-value",
45
47
  children: routeTemplate
46
48
  }), /*#__PURE__*/_jsx(InlineCopyButton, {
47
49
  value: routeTemplate,
@@ -49,11 +51,13 @@ export function RouteEventExpandedContent({
49
51
  })]
50
52
  }), event.previousPathname && /*#__PURE__*/_jsxs(View, {
51
53
  style: styles.detailRow,
54
+ testID: "buoy-routes-detail-from",
52
55
  children: [/*#__PURE__*/_jsx(Text, {
53
56
  style: styles.detailLabel,
54
57
  children: "From:"
55
58
  }), /*#__PURE__*/_jsx(Text, {
56
59
  style: styles.detailValue,
60
+ testID: "buoy-routes-detail-from-value",
57
61
  children: event.previousPathname
58
62
  }), /*#__PURE__*/_jsx(InlineCopyButton, {
59
63
  value: event.previousPathname,
@@ -61,11 +65,13 @@ export function RouteEventExpandedContent({
61
65
  })]
62
66
  }), /*#__PURE__*/_jsxs(View, {
63
67
  style: styles.detailRow,
68
+ testID: "buoy-routes-detail-to",
64
69
  children: [/*#__PURE__*/_jsx(Text, {
65
70
  style: styles.detailLabel,
66
71
  children: "To:"
67
72
  }), /*#__PURE__*/_jsx(Text, {
68
73
  style: styles.detailValue,
74
+ testID: "buoy-routes-detail-to-value",
69
75
  children: event.pathname
70
76
  }), /*#__PURE__*/_jsx(InlineCopyButton, {
71
77
  value: event.pathname,
@@ -76,20 +82,24 @@ export function RouteEventExpandedContent({
76
82
  style: styles.section,
77
83
  children: [event.timeSincePrevious !== undefined && /*#__PURE__*/_jsxs(View, {
78
84
  style: styles.detailRow,
85
+ testID: "buoy-routes-detail-duration",
79
86
  children: [/*#__PURE__*/_jsx(Text, {
80
87
  style: styles.detailLabel,
81
88
  children: "Duration:"
82
89
  }), /*#__PURE__*/_jsx(Text, {
83
90
  style: styles.detailValue,
91
+ testID: "buoy-routes-detail-duration-value",
84
92
  children: formatDuration(event.timeSincePrevious)
85
93
  })]
86
94
  }), /*#__PURE__*/_jsxs(View, {
87
95
  style: styles.detailRow,
96
+ testID: "buoy-routes-detail-time",
88
97
  children: [/*#__PURE__*/_jsx(Text, {
89
98
  style: styles.detailLabel,
90
99
  children: "Time:"
91
100
  }), /*#__PURE__*/_jsx(Text, {
92
101
  style: styles.detailValue,
102
+ testID: "buoy-routes-detail-time-value",
93
103
  children: new Date(event.timestamp).toLocaleString()
94
104
  })]
95
105
  })]
@@ -97,11 +107,13 @@ export function RouteEventExpandedContent({
97
107
  style: styles.section,
98
108
  children: [event.segments && event.segments.length > 0 && /*#__PURE__*/_jsxs(View, {
99
109
  style: styles.detailRow,
110
+ testID: "buoy-routes-detail-segments",
100
111
  children: [/*#__PURE__*/_jsx(Text, {
101
112
  style: styles.detailLabel,
102
113
  children: "Segments:"
103
114
  }), /*#__PURE__*/_jsx(Text, {
104
115
  style: styles.detailValue,
116
+ testID: "buoy-routes-detail-segments-value",
105
117
  children: event.segments.join(' → ')
106
118
  }), /*#__PURE__*/_jsx(InlineCopyButton, {
107
119
  value: JSON.stringify(event.segments),
@@ -113,6 +125,7 @@ export function RouteEventExpandedContent({
113
125
  style: styles.dataViewerHeader,
114
126
  children: [/*#__PURE__*/_jsxs(Text, {
115
127
  style: styles.dataViewerTitle,
128
+ testID: "buoy-routes-detail-params-title",
116
129
  children: ["Parameters (", Object.keys(event.params).length, ")"]
117
130
  }), /*#__PURE__*/_jsx(InlineCopyButton, {
118
131
  value: JSON.stringify(event.params, null, 2),
@@ -127,11 +140,13 @@ export function RouteEventExpandedContent({
127
140
  })]
128
141
  }), visitNumber > 1 && /*#__PURE__*/_jsxs(View, {
129
142
  style: styles.detailRow,
143
+ testID: "buoy-routes-detail-visited",
130
144
  children: [/*#__PURE__*/_jsx(Text, {
131
145
  style: styles.detailLabel,
132
146
  children: "Visited:"
133
147
  }), /*#__PURE__*/_jsxs(Text, {
134
148
  style: styles.detailValue,
149
+ testID: "buoy-routes-detail-visited-value",
135
150
  children: [visitNumber, " time", visitNumber !== 1 ? 's' : '']
136
151
  })]
137
152
  })]