car-browser 0.52.1

Browser automation and perception pipeline for Common Agent Runtime
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
//! Headless Chromium backend via chromiumoxide.
//!
//! Implements `BrowserBackend` using Chrome DevTools Protocol (CDP).
//! Requires a Chromium/Chrome binary on the system.

use async_trait::async_trait;
use chromiumoxide::browser::{Browser, BrowserConfig};
use chromiumoxide::cdp::browser_protocol::accessibility::GetFullAxTreeParams;
use chromiumoxide::cdp::browser_protocol::dom::{BackendNodeId, FocusParams, GetBoxModelParams};
use chromiumoxide::cdp::browser_protocol::input::{
    DispatchKeyEventParams, DispatchKeyEventType, DispatchMouseEventParams, DispatchMouseEventType,
    InsertTextParams, MouseButton,
};
use chromiumoxide::cdp::browser_protocol::page::{
    CaptureScreenshotFormat, GetNavigationHistoryParams, NavigateToHistoryEntryParams, ReloadParams,
};
use chromiumoxide::Page;
use futures::StreamExt;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::time::Duration;

/// Per-tab deadline for the nav-state refresh `list_tabs` does.
///
/// The drawer refreshes its tab strip through that call, so an unbounded
/// sequential walk let one wedged target block the whole strip. Generous
/// relative to a healthy round trip (milliseconds), short enough that a hung
/// target costs one refresh rather than the session.
const NAV_STATE_TIMEOUT: Duration = Duration::from_secs(2);
use tokio::sync::{watch, RwLock};
use tokio::task::JoinHandle;
use tokio::time::timeout;

use crate::backend::{BrowserBackend, BrowserError};
use crate::models::{A11yNode, Bounds, Modifier, Viewport, WaitCondition};
use crate::tabs::{TabId, TabInfo, TabRegistry, TabsSnapshot};

/// Cached mapping from ax_N node IDs to their CDP backend DOM node IDs.
/// Populated during `get_accessibility_tree()`, consumed by `click_element()`
/// and `focus_element()`.
type AxNodeCache = HashMap<String, BackendNodeId>;

/// Headless Chromium browser backend.
pub struct ChromiumBackend {
    /// Every open tab, plus which one is active. Every existing
    /// perception/navigation/input method below routes to whichever page
    /// [`Self::get_page`] reports as active — that's what makes "acts on the
    /// ACTIVE page" hold automatically as tabs are opened, closed, and
    /// switched; nothing below `get_page()` needed to change when tab
    /// support was added, only what "the page" means did.
    ///
    /// `std::sync::RwLock`, not tokio's, so the synchronous
    /// `BrowserBackend::get_current_url` can read from it — same reasoning
    /// as `ax_node_cache` below.
    tabs: std::sync::RwLock<TabRegistry<Page>>,
    browser: Arc<RwLock<Option<Browser>>>,
    viewport_width: u32,
    viewport_height: u32,
    /// Cached URL of the currently (or most recently) active tab. Kept as
    /// its own field — rather than always derived fresh from `tabs` — so
    /// `get_current_url()` keeps returning `Ok` with the last known value
    /// even in the empty state (no tabs open, e.g. after `shutdown()`),
    /// exactly like the pre-tabs single-page backend, whose `cached_url` was
    /// never reset by `shutdown()` either.
    /// Uses std::sync::RwLock so get_current_url() can be synchronous.
    cached_url: std::sync::RwLock<String>,
    /// Cached mapping from ax_N IDs to BackendNodeId, populated by
    /// get_accessibility_tree(). Used by click_element/focus_element/type_into_element
    /// to resolve ax_N to real DOM coordinates. Cleared whenever the active
    /// page identity changes (tab open/switch/close-with-neighbor) — an ax_N
    /// id only makes sense against whichever page's tree produced it.
    ax_node_cache: std::sync::RwLock<AxNodeCache>,
    /// Per-instance Chromium profile directory. Held so the
    /// directory survives until ChromiumBackend is dropped, then
    /// auto-cleans (TempDir runs `remove_dir_all` in its Drop).
    /// `None` when this backend launched against a PERSISTENT directory —
    /// the caller owns that directory's lifecycle.
    /// See #148: under Playwright workers running in parallel, the
    /// chromiumoxide default profile dir caused SingletonLock
    /// contention; per-instance dirs eliminate the collision.
    _profile_dir: Option<tempfile::TempDir>,
    /// This backend's hold on a persistent profile directory, released on
    /// drop. `None` when it launched ephemeral (nothing to hold) — including
    /// the fallback case where a persistent directory was wanted but another
    /// live backend in this process already held it. See [`crate::profile`].
    _profile_claim: Option<crate::profile::ProfileClaim>,
    /// PID of the Chrome subprocess. Captured at launch so `Drop`
    /// can synchronously SIGKILL it without needing the tokio
    /// runtime. chromiumoxide relies on tokio's `kill_on_drop`,
    /// which only fires if the runtime is still alive when the
    /// `Browser` drops — on process panic/abort the Chrome
    /// subprocess is reparented to PID 1 and leaks. We bypass that
    /// by remembering the PID ourselves.
    chrome_pid: Option<u32>,
    /// Handle to the spawned tokio task that drains chromiumoxide's
    /// CDP event stream. Aborted in `shutdown()` and `Drop` so the
    /// task doesn't outlive the backend (it holds the CDP channel,
    /// which keeps the chromiumoxide `Browser` from quiescing).
    handler_task: StdMutex<Option<JoinHandle<()>>>,
    /// Serializes `navigate`'s empty-state reopen across its check AND the
    /// `open_tab()` await — see the call site. An async mutex because the
    /// critical section contains an await, which is exactly what the registry
    /// lock could not span.
    reopen: tokio::sync::Mutex<()>,
}

/// True iff `url` is on the same (scheme, host, port) as `origin`.
///
/// Used to enforce origin-locality for `set_local_storage`: localStorage is
/// origin-scoped in the browser, so setting items from the wrong page would
/// either fail silently or mutate the wrong origin's state. We require the
/// caller to navigate to the origin first.
fn current_origin_matches(url: &str, origin: &str) -> bool {
    // Empty or `about:blank` is allowed — treat as a pre-page state where
    // localStorage operations will be attached to the first real
    // navigation. Chromium's about:blank has no persistent storage so the
    // `evaluate` call will simply set localStorage on the next real page.
    if url.is_empty() || url.starts_with("about:") {
        return true;
    }
    let extract = |s: &str| {
        let (scheme, rest) = s.split_once("://")?;
        let host_part = rest.split('/').next().unwrap_or("");
        Some(format!("{}://{}", scheme, host_part))
    };
    match (extract(url), extract(origin)) {
        (Some(a), Some(b)) => a == b,
        _ => false,
    }
}

/// Convert our `Modifier` enum to CDP modifier bitmask.
/// CDP defines: Alt=1, Ctrl=2, Meta/Command=4, Shift=8.
fn modifiers_to_cdp_flags(modifiers: &[Modifier]) -> i64 {
    let mut flags: i64 = 0;
    for m in modifiers {
        flags |= match m {
            Modifier::Alt => 1,
            Modifier::Control => 2,
            Modifier::Meta => 4,
            Modifier::Shift => 8,
        };
    }
    flags
}

/// Map a poisoned `tabs` lock to a `BrowserError`. Shared by every tab
/// operation so the error text is consistent in one place.
fn tabs_lock_poisoned<E: std::fmt::Display>(e: E) -> BrowserError {
    BrowserError::PlatformInternal(format!("tabs lock poisoned: {e}"))
}

/// Best-effort live nav-state read for one page: url, title, and
/// back/forward availability, all from a single `Page.getNavigationHistory`
/// call. Its response's `entries[current_index]` already carries the
/// current entry's `url` and `title` (`NavigationEntry` in chromiumoxide_cdp)
/// — no need for the separate `page.url()` / `page.evaluate("document.title")`
/// round trips a JS-eval-based title read would cost, which also keeps this
/// off the perception boundary's DOM-read surface. `None` if the CDP call
/// fails, or if `current_index` doesn't land on an entry (empty/out-of-range
/// — shouldn't happen against a real page, but guarded rather than assumed)
/// — the caller keeps the tab's last-known values rather than blanking them
/// out on a transient error.
async fn fetch_nav_state(page: &Page) -> Option<(String, String, bool, bool)> {
    let history = page
        .execute(GetNavigationHistoryParams::default())
        .await
        .ok()?;
    let idx = history.result.current_index;
    let entries = &history.result.entries;
    let current = usize::try_from(idx).ok().and_then(|i| entries.get(i))?;
    let floor = history_floor(entries.first().map(|e| e.url.as_str()));
    let can_go_back = usize::try_from(idx).is_ok_and(|i| i > floor);
    let can_go_forward = idx < entries.len() as i64 - 1;
    Some((
        current.url.clone(),
        current.title.clone(),
        can_go_back,
        can_go_forward,
    ))
}

/// Which way [`ChromiumBackend::step_history`] moves through a tab's history.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HistoryStep {
    Back,
    Forward,
}

impl HistoryStep {
    /// How the failure to move reads to a caller — the nav bar's Back and
    /// Forward buttons are disabled precisely when this would happen, so it
    /// is a race or a client bug, not an ordinary outcome.
    fn nothing_there(self) -> &'static str {
        match self {
            HistoryStep::Back => "no page to go back to",
            HistoryStep::Forward => "no page to go forward to",
        }
    }
}

/// The entry id one step from `current_index` in `entry_ids`, or `None` when
/// there is nothing in that direction.
///
/// Split out from the CDP call for the same reason `fetch_nav_state`'s index
/// guard is written the way it is: `Page.getNavigationHistory` hands back a
/// `currentIndex` that is only *documented* to be in range, and this is the
/// one piece of history navigation that can be tested without a live Chrome.
/// A negative index, an index past the end, and an empty history all answer
/// `None` rather than panicking on the slice.
fn adjacent_entry_id(
    current_index: i64,
    entry_ids: &[i64],
    step: HistoryStep,
    floor: usize,
) -> Option<i64> {
    let current = usize::try_from(current_index).ok()?;
    if current >= entry_ids.len() {
        return None;
    }
    let target = match step {
        HistoryStep::Back => {
            let target = current.checked_sub(1)?;
            // Never step onto the tab's birth entry: Back on a page reached
            // by a single navigation would land on a blank page that reads as
            // the empty state, which is not "the prior page" by any reading.
            if target < floor {
                return None;
            }
            target
        }
        HistoryStep::Forward => current + 1,
    };
    entry_ids.get(target).copied()
}

/// Index of the first history entry that represents a page somebody actually
/// went to.
///
/// A tab is born at `about:blank`, and Chromium records that as history entry
/// zero — so after ONE navigation `currentIndex` is already 1 and the naive
/// `index > 0` test reports "you can go back", to a blank page. The outcome is
/// explicit that Back "enables after a second navigation", so entry zero is
/// excluded when it is the birth entry.
///
/// Only entry ZERO is treated this way. A caller who deliberately navigates to
/// `about:blank` later has genuinely been there, and going back to it is
/// correct.
fn history_floor(first_url: Option<&str>) -> usize {
    match first_url {
        Some(url) if url == "about:blank" || url.is_empty() => 1,
        _ => 0,
    }
}

/// Whether [`ChromiumBackend::navigate`] needs to open a tab before it can
/// act: true exactly when `tabs` is in the empty state (no active page —
/// e.g. every tab was just closed). Pure and generic over the page handle
/// type, so it is unit-testable the same way `tabs.rs` tests `TabRegistry`
/// itself (a synthetic `FakePage`, no live Chromium) — only the actual CDP
/// tab-open (`ChromiumBackend::open_tab`) needs one.
fn needs_a_tab_before_navigating<P: Clone>(tabs: &TabRegistry<P>) -> bool {
    tabs.active_page().is_none()
}

/// Whether a Chromium launch failure means "another live instance already
/// holds this profile directory".
///
/// Chromium's `ProcessSingleton` reports it precisely, so reacting to the
/// error is enough — and is strictly better than the alternatives. An
/// in-process registry cannot see another PROCESS (two supervised agents both
/// derive `$CAR_HOME/browser-profile`, which is the case that actually
/// happens), and a lock file's post-crash staleness is its own failure mode.
///
/// Matched on the two stable fragments of the message rather than the whole
/// string: the path and errno text vary, `SingletonLock` and "profile" do not.
/// Deliberately narrow — a fallback triggered by an unrelated failure would
/// silently strand a user's sign-ins in a directory nothing reads.
fn is_profile_in_use(error: &str) -> bool {
    let lower = error.to_ascii_lowercase();
    lower.contains("singletonlock")
        || (lower.contains("profile") && lower.contains("already"))
        || lower.contains("cannot create a profile directory")
}

/// One launch attempt against one profile directory.
///
/// Split out so the fallback path can repeat it verbatim: a retry that
/// rebuilt the config differently would be a second, untested configuration.
async fn launch_chromium(
    opts: &LaunchOptions,
    profile_dir: &std::path::Path,
) -> Result<(Browser, chromiumoxide::handler::Handler), String> {
    let mut builder = BrowserConfig::builder().window_size(opts.width, opts.height);
    builder = if opts.headless {
        builder.new_headless_mode()
    } else {
        builder.with_head()
    };
    if !opts.extra_args.is_empty() {
        // chromiumoxide's BrowserConfig::builder().args takes
        // an `IntoIterator<Item = impl Into<String>>` and
        // appends each verbatim to Chromium's argv.
        builder = builder.args(opts.extra_args.iter().map(String::as_str));
    }
    let config = builder
        .user_data_dir(profile_dir)
        .build()
        .map_err(|e| format!("Config error: {e}"))?;
    Browser::launch(config).await.map_err(|e| e.to_string())
}

/// Options for launching a `ChromiumBackend`.
///
/// `headless = false` shows a real visible Chromium window — intended for
/// interactive flows like first-time authentication (LinkedIn, OAuth, SSO)
/// where a human needs to complete sign-in / 2FA / captcha before the rest
/// of the script runs headless against the persisted cookies.
#[derive(Debug, Clone)]
pub struct LaunchOptions {
    pub width: u32,
    pub height: u32,
    pub headless: bool,
    /// Extra command-line flags appended to Chromium's argv at
    /// launch. Use cases include the Google Meet bot (#112) which
    /// needs `--use-fake-ui-for-media-stream`,
    /// `--autoplay-policy=no-user-gesture-required`, and the
    /// container-friendly trio (`--no-sandbox`,
    /// `--disable-dev-shm-usage`, `--disable-setuid-sandbox`).
    /// Flags are passed verbatim — callers responsible for the
    /// correctness of what they append.
    pub extra_args: Vec<String>,
    /// Persistent Chromium profile to launch against, or `None` for the
    /// per-instance ephemeral tempdir that is this crate's default.
    ///
    /// This is how CAR code asks for persistence. It is an OPTION rather
    /// than an environment variable because the env var is process-global:
    /// one component setting it silently changed every other browser in the
    /// process, including `browser.run`'s per-connection browsers, which is
    /// how a drawer launch used to make every later `browser.run` die on
    /// SingletonLock. `CAR_BROWSER_PROFILE_DIR` remains the user-facing
    /// knob, read below when no explicit directory is given — but CAR code
    /// never writes it.
    ///
    /// A directory already held by a live backend in this process is not an
    /// error: the launch falls back to the ephemeral default and logs, which
    /// starts a working (signed-out) browser instead of none at all. See
    /// [`crate::profile`].
    pub profile_dir: Option<std::path::PathBuf>,
}

impl Default for LaunchOptions {
    fn default() -> Self {
        Self {
            width: 1280,
            height: 720,
            headless: true,
            extra_args: Vec::new(),
            profile_dir: None,
        }
    }
}

impl ChromiumBackend {
    /// Launch a new headless Chromium instance.
    pub async fn launch() -> Result<Self, BrowserError> {
        Self::launch_with_viewport(1280, 720).await
    }

    /// Launch with specific viewport dimensions (headless).
    pub async fn launch_with_viewport(width: u32, height: u32) -> Result<Self, BrowserError> {
        Self::launch_with_options(LaunchOptions {
            width,
            height,
            headless: true,
            extra_args: Vec::new(),
            profile_dir: None,
        })
        .await
    }

    /// Launch with full options, including headless/headed toggle.
    ///
    /// `headless = true` uses Chromium's new headless mode (`--headless=new`);
    /// `headless = false` shows a visible window (for interactive auth etc.).
    pub async fn launch_with_options(opts: LaunchOptions) -> Result<Self, BrowserError> {
        // Per-instance Chromium profile directory.
        //
        // chromiumoxide defaults to a fixed path under $TMPDIR;
        // when the same default is reused by N parallel processes
        // (e.g. Playwright workers each constructing a CarRuntime),
        // they contend over Chromium's `SingletonLock` and the
        // losers either silently get a wrong-port DevTools handshake
        // or hang on navigate (#148).
        //
        // Each ChromiumBackend now gets its own tempdir, scoped to
        // its lifetime. Callers who *want* persistence (cookies +
        // localStorage between runs) can set CAR_BROWSER_PROFILE_DIR
        // and accept they're back on the hook for parallel-launch
        // coordination.
        //
        // Resolution order: the caller's explicit `profile_dir`, then the
        // user's `CAR_BROWSER_PROFILE_DIR`, then the ephemeral default. A
        // persistent directory is CLAIMED for this backend's lifetime; a
        // second launch wanting the same one falls back to ephemeral rather
        // than dying on Chromium's SingletonLock (see `crate::profile`).
        let requested = opts.profile_dir.clone().or_else(|| {
            std::env::var("CAR_BROWSER_PROFILE_DIR")
                .ok()
                .filter(|p| !p.is_empty())
                .map(std::path::PathBuf::from)
        });
        let claim = requested.as_deref().and_then(|dir| {
            let claim = crate::profile::ProfileClaim::acquire(dir);
            if claim.is_none() {
                tracing::warn!(
                    profile_dir = %dir.display(),
                    "another browser in this process already holds this Chromium profile; \
                     launching against a throwaway profile instead — this browser starts \
                     signed out"
                );
            }
            claim
        });
        let (profile_dir, profile_handle) = match (&requested, &claim) {
            // Persistent, and ours.
            (Some(dir), Some(_)) => (dir.clone(), None),
            // Either no persistence was asked for, or it was asked for and
            // somebody else holds it — both land on a fresh tempdir.
            _ => {
                let td = tempfile::Builder::new()
                    .prefix("car-browser-profile-")
                    .tempdir()
                    .map_err(|e| {
                        BrowserError::NotAvailable(format!("create per-instance profile dir: {e}"))
                    })?;
                (td.path().to_path_buf(), Some(td))
            }
        };

        // Launch, and if a SHARED profile directory turns out to be held by
        // another PROCESS, fall back to a throwaway one.
        //
        // The in-process claim above cannot see across processes, and the
        // shipped topology has two of them: two supervised agent processes
        // both derive `$CAR_HOME/browser-profile`, so the second one's
        // Chromium dies at startup on `SingletonLock: File exists`. A file
        // lock would catch it, but its own post-crash staleness is a worse
        // failure mode — and Chromium already tells us, precisely, in the
        // error. Reacting to that is the smaller and more honest mechanism:
        // the same degradation as the in-process fallback (this browser works
        // and starts signed out) instead of no browser at all.
        //
        // Once only. A second SingletonLock failure on a FRESH tempdir would
        // not mean contention, so retrying again would just be a loop.
        // `profile_dir` is not needed past this point — the config carries it
        // and `profile_handle` owns the tempdir's lifetime — so the launched
        // directory is bound out only to keep the two arms symmetric.
        let (browser, handler, _launched_in, profile_handle, claim) =
            match launch_chromium(&opts, &profile_dir).await {
                Ok((browser, handler)) => (browser, handler, profile_dir, profile_handle, claim),
                Err(error) if profile_handle.is_none() && is_profile_in_use(&error) => {
                    tracing::warn!(
                        profile_dir = %profile_dir.display(),
                        %error,
                        "another process already holds this Chromium profile; relaunching \
                         against a throwaway profile — this browser starts signed out"
                    );
                    let td = tempfile::Builder::new()
                        .prefix("car-browser-profile-")
                        .tempdir()
                        .map_err(|e| {
                            BrowserError::NotAvailable(format!(
                                "create per-instance profile dir: {e}"
                            ))
                        })?;
                    let fallback = td.path().to_path_buf();
                    let (browser, handler) =
                        launch_chromium(&opts, &fallback).await.map_err(|e| {
                            BrowserError::NotAvailable(format!("Failed to launch Chrome: {e}"))
                        })?;
                    // Release the claim: this backend is not using that
                    // directory after all, so the next launch in this process
                    // may try it.
                    (browser, handler, fallback, Some(td), None)
                }
                Err(e) => {
                    return Err(BrowserError::NotAvailable(format!(
                        "Failed to launch Chrome: {e}"
                    )))
                }
            };
        let mut browser = browser;
        let mut handler = handler;

        // Capture the Chrome subprocess PID up front. `get_mut_child`
        // returns the underlying tokio Child; its `id()` is `Some`
        // until the process is reaped. We keep this so `Drop` can
        // SIGKILL synchronously without awaiting (see field docs).
        let chrome_pid = browser.get_mut_child().and_then(|c| c.inner.id());

        // Spawn the CDP event handler. We hold the JoinHandle so
        // shutdown()/Drop can abort it — otherwise it lives for the
        // process lifetime and keeps the CDP channel open, which
        // prevents chromiumoxide from cleanly tearing down the
        // Browser when callers expect `_browser.take()` to suffice.
        let handler_task =
            tokio::spawn(async move { while let Some(_event) = handler.next().await {} });

        let page = browser
            .new_page("about:blank")
            .await
            .map_err(|e| BrowserError::NotAvailable(format!("Failed to create page: {}", e)))?;

        // Exactly one tab, open and active, mirroring the single-page
        // backend this replaces — multi-tab support only changes behavior
        // once a caller actually opens a second tab.
        let (mut tabs, _initial_rx) = TabRegistry::new();
        tabs.open(page, "about:blank", "");

        Ok(Self {
            tabs: std::sync::RwLock::new(tabs),
            browser: Arc::new(RwLock::new(Some(browser))),
            viewport_width: opts.width,
            viewport_height: opts.height,
            cached_url: std::sync::RwLock::new("about:blank".to_string()),
            ax_node_cache: std::sync::RwLock::new(HashMap::new()),
            _profile_dir: profile_handle,
            _profile_claim: claim,
            chrome_pid,
            handler_task: StdMutex::new(Some(handler_task)),
            reopen: tokio::sync::Mutex::new(()),
        })
    }

    /// OS process ID of the spawned Chrome subprocess, if known.
    ///
    /// Returns `None` when chromiumoxide did not spawn the process
    /// itself (e.g. attached to an existing browser, which we do
    /// not currently do but the API allows). Stable across the
    /// lifetime of this backend — Chrome is not respawned on its
    /// own — so callers can use it to assert cleanup in tests.
    pub fn chrome_pid(&self) -> Option<u32> {
        self.chrome_pid
    }

    async fn get_page(&self) -> Result<Page, BrowserError> {
        self.tabs
            .read()
            .map_err(tabs_lock_poisoned)?
            .active_page()
            .ok_or(BrowserError::NotAvailable("Page closed".into()))
    }

    /// The live CDP page handle for the ACTIVE tab.
    ///
    /// Exposed for callers that need to drive CDP directly rather than through
    /// [`BrowserBackend`] — screen recording ([`crate::recorder`]) subscribes to
    /// `Page.screencastFrame`, which has no equivalent on the backend trait
    /// (that trait is deliberately about ACTIONS, not raw protocol access).
    /// A caller that wants a live preview to follow the active tab as it
    /// changes should pair this with [`Self::subscribe_tabs`]: watch for a
    /// `TabEvent::ActiveChanged`, then call this again to move capture to
    /// the new page.
    pub async fn page_handle(&self) -> Result<Page, BrowserError> {
        self.get_page().await
    }

    // =========================================================================
    // Tabs
    // =========================================================================
    //
    // Inherent methods, not part of `BrowserBackend` — the trait is
    // deliberately scoped to "act on THE page" (Manifesto Principles 3-5:
    // perception + human-equivalent input on one page at a time), and every
    // method on it keeps meaning exactly that, now routed through whichever
    // tab is active. Tab management is a separate, control-surface-facing
    // capability layered on top, which is why it lives here instead of on
    // the trait: a caller that only ever drives one page (the FFI bindings,
    // `car-server-core`'s `browse_*` tools) never needs to know tabs exist.

    /// List every open tab, in strip order, refreshed against live CDP state.
    ///
    /// A background tab can navigate on its own — a clicked link, a JS
    /// redirect — without ever going through [`Self::navigate`], so a purely
    /// cached value would go stale for anything but the tab that was active
    /// at the time. Best-effort per tab: a refresh failure on one tab (mid
    /// navigation, target gone) leaves its last-known values rather than
    /// failing the whole list.
    pub async fn list_tabs(&self) -> Result<Vec<TabInfo>, BrowserError> {
        let pages: Vec<(TabId, Page)> = self.tabs.read().map_err(tabs_lock_poisoned)?.pages();

        // Bounded per tab, and fetched concurrently.
        //
        // This is the drawer's hot path — every presentation refresh calls
        // it — and it was a sequential walk with no deadline, so ONE wedged
        // target (a page in a modal beforeunload, a hung renderer) blocked
        // the whole tab strip indefinitely. The documented behaviour is
        // best-effort per tab: `fetch_nav_state` returning `None` already
        // means "keep the last known state for this tab", which was only
        // reachable for a target that errored PROMPTLY. The timeout is what
        // makes it reachable for one that hangs.
        let refreshed = futures::future::join_all(pages.iter().map(|(id, page)| async move {
            let state = tokio::time::timeout(NAV_STATE_TIMEOUT, fetch_nav_state(page))
                .await
                .ok()
                .flatten();
            (*id, state)
        }))
        .await;
        if let Ok(mut tabs) = self.tabs.write() {
            for (id, state) in refreshed {
                if let Some((url, title, can_back, can_fwd)) = state {
                    tabs.update_nav_state(id, url, title, can_back, can_fwd);
                }
            }
        }

        self.tabs
            .read()
            .map(|tabs| tabs.list())
            .map_err(tabs_lock_poisoned)
    }

    /// Open a new blank tab and make it active. Returns the new tab's id.
    pub async fn open_tab(&self) -> Result<TabId, BrowserError> {
        let page = {
            let guard = self.browser.read().await;
            let browser = guard
                .as_ref()
                .ok_or_else(|| BrowserError::NotAvailable("Browser closed".into()))?;
            browser
                .new_page("about:blank")
                .await
                .map_err(|e| BrowserError::PlatformInternal(format!("open tab: {e}")))?
        };
        let id = self
            .tabs
            .write()
            .map_err(tabs_lock_poisoned)?
            .open(page, "about:blank", "");
        // Opening a tab always changes which one is active (the new tab).
        self.on_active_page_changed();
        Ok(id)
    }

    /// Close the tab `id`. Closing the active tab activates a neighbor;
    /// closing the last tab leaves the well-defined empty state (no page)
    /// rather than a crash or a dangling handle. A safe no-op if `id` does
    /// not name an open tab — closing something already closed is not an
    /// error.
    pub async fn close_tab(&self, id: TabId) -> Result<(), BrowserError> {
        let (page, active_changed) = {
            let mut tabs = self.tabs.write().map_err(tabs_lock_poisoned)?;
            let before = tabs.active_id();
            let page = tabs.close(id);
            (page, before != tabs.active_id())
        };
        if let Some(page) = page {
            let _ = timeout(Duration::from_secs(2), page.close()).await;
        }
        if active_changed {
            self.on_active_page_changed();
        }
        Ok(())
    }

    /// Make `id` the active tab. Errors if no tab has that id.
    ///
    /// **This does NOT send `Page.bringToFront`,** and that is load-bearing
    /// for the drawer rather than an oversight: CAR's "active tab" is which
    /// target its own calls address, and the screencast attaches per target,
    /// so a background target still composites and still emits frames. The
    /// assumption being relied on is exactly that — CDP screencast works on a
    /// non-foreground target. If a future Chromium stops compositing
    /// background targets, the symptom is a drawer that goes still after a
    /// tab switch, and the fix is a `bringToFront` here (which would also
    /// raise a real window for a HEADED browser, so it is not free).
    pub async fn switch_tab(&self, id: TabId) -> Result<(), BrowserError> {
        let active_changed = {
            let mut tabs = self.tabs.write().map_err(tabs_lock_poisoned)?;
            let before = tabs.active_id();
            tabs.switch(id)
                .map_err(|e| BrowserError::NotAvailable(e.to_string()))?;
            before != tabs.active_id()
        };
        if active_changed {
            self.on_active_page_changed();
        }
        Ok(())
    }

    /// The active tab's id, if any (`None` in the empty state — no tabs
    /// open).
    pub fn active_tab_id(&self) -> Option<TabId> {
        self.tabs.read().ok().and_then(|t| t.active_id())
    }

    /// Move the ACTIVE tab one step through its own history.
    ///
    /// `Page.navigateToHistoryEntry` on the adjacent entry, which is what
    /// actually drives Chromium's session history — a synthesised Cmd+Left
    /// keystroke does not, because the shortcut is browser chrome the CDP
    /// input domain never reaches.
    ///
    /// Per-tab by construction: the history lives on the page, and
    /// [`Self::get_page`] resolves the active tab, so switching tabs switches
    /// which history this walks. Errors when there is nothing in that
    /// direction rather than silently doing nothing — the nav buttons are
    /// disabled exactly then, so arriving here means a race or a client bug,
    /// and a silent success would be indistinguishable from a page that
    /// failed to move.
    pub async fn step_history(&self, step: HistoryStep) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        let history = page
            .execute(GetNavigationHistoryParams::default())
            .await
            .map_err(|e| BrowserError::NavigationFailed(format!("getNavigationHistory: {e}")))?;
        let entries = &history.result.entries;
        let floor = history_floor(entries.first().map(|e| e.url.as_str()));
        let entry_ids: Vec<i64> = entries.iter().map(|e| e.id).collect();
        let entry_id = adjacent_entry_id(history.result.current_index, &entry_ids, step, floor)
            .ok_or_else(|| BrowserError::NavigationFailed(step.nothing_there().to_string()))?;

        page.execute(NavigateToHistoryEntryParams::new(entry_id))
            .await
            .map_err(|e| BrowserError::NavigationFailed(format!("navigateToHistoryEntry: {e}")))?;
        self.settle_after_history_move(&page).await;
        Ok(())
    }

    /// Reload the ACTIVE tab.
    ///
    /// Plain reload, not cache-bypassing: this is the nav bar's reload button,
    /// which is the ordinary one.
    pub async fn reload(&self) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        page.execute(ReloadParams::default())
            .await
            .map_err(|e| BrowserError::NavigationFailed(format!("reload: {e}")))?;
        self.settle_after_history_move(&page).await;
        Ok(())
    }

    /// Shared tail of the three history operations: wait for the resulting
    /// navigation, then resync the caches `navigate()` resyncs, so a caller
    /// listing tabs immediately after does not see pre-move values.
    ///
    /// Best-effort on the wait: a same-document history move (an in-page
    /// anchor) may not produce a navigation event at all, and that is a
    /// successful move, not a failure.
    async fn settle_after_history_move(&self, page: &Page) {
        let _ = timeout(Duration::from_secs(10), page.wait_for_navigation()).await;
        self.refresh_cached_url().await;
        self.sync_active_tab_nav_state(page).await;
    }

    /// Subscribe to tab lifecycle and nav-state change notifications.
    /// `watch::Receiver::borrow()` sees the current full snapshot
    /// immediately; `.changed().await` waits for the next mutation
    /// (open/close/switch/nav-state update). This is the seam a
    /// presentation surface uses to keep a tab strip live, and the one a
    /// screencast pump owner uses to notice the active tab changed and move
    /// capture to the new page (see [`Self::page_handle`] and
    /// `crate::screencast`).
    pub fn subscribe_tabs(&self) -> watch::Receiver<TabsSnapshot> {
        // The registry always holds its own anchor receiver (see
        // `TabRegistry`'s field doc), so this lock can't be poisoned by a
        // panic inside `subscribe()` itself — `expect` here only fires if a
        // PRIOR operation panicked while holding the write lock.
        self.tabs.read().expect("tabs lock poisoned").subscribe()
    }

    /// Invalidate state that's only valid for whichever page was PREVIOUSLY
    /// active, after the active page identity has just changed (tab
    /// open/switch/close-with-neighbor).
    ///
    /// Clears `ax_node_cache` (ax_N ids only make sense against whichever
    /// page's tree produced them — carrying them over would resolve a
    /// click/type against the wrong page's element; callers must call
    /// `get_accessibility_tree()` again on the new active page first, same
    /// as they already must after any navigation) and resyncs `cached_url`
    /// to the new active tab's last-known URL, so `get_current_url()`
    /// reflects the tab that's now active rather than the one that was.
    fn on_active_page_changed(&self) {
        if let Ok(mut cache) = self.ax_node_cache.write() {
            cache.clear();
        }
        let active_url = self.tabs.read().ok().and_then(|t| t.active_url());
        if let Some(url) = active_url {
            if let Ok(mut cached) = self.cached_url.write() {
                *cached = url;
            }
        }
        // Empty state (no active tab, e.g. the last tab just closed):
        // deliberately leave cached_url at its last value, matching the
        // pre-tabs backend's shutdown() behavior — see the field doc.
    }

    /// Update the cached URL by querying the page asynchronously.
    async fn refresh_cached_url(&self) {
        if let Ok(page) = self.get_page().await {
            // Bounded, like every other post-navigation CDP read here. This
            // runs from `settle_after_history_move`, which carefully bounds its
            // navigation wait and then made two unbounded round trips straight
            // after it — so a target that wedged during the history move hung
            // the nav-bar Back/Forward/Reload call itself, indefinitely.
            if let Ok(Ok(Some(url))) = timeout(NAV_STATE_TIMEOUT, page.url()).await {
                if let Ok(mut cached) = self.cached_url.write() {
                    *cached = url;
                }
            }
        }
    }

    /// Refresh the active tab's registry entry (url/title/back-forward) from
    /// `page`'s live CDP state, after a navigation this backend just drove.
    /// Best-effort: a failed refresh just leaves the registry's prior
    /// values, same tolerance as `list_tabs`'s per-tab refresh.
    async fn sync_active_tab_nav_state(&self, page: &Page) {
        let Some(id) = self.active_tab_id() else {
            return;
        };
        // The same `NAV_STATE_TIMEOUT` `list_tabs` wraps this identical call
        // in, put in the HELPER so both call sites get it. Best-effort already
        // means "keep the last known state for this tab" — the deadline is what
        // makes that reachable for a target that hangs rather than errors.
        if let Ok(Some((url, title, can_back, can_fwd))) =
            timeout(NAV_STATE_TIMEOUT, fetch_nav_state(page)).await
        {
            if let Ok(mut tabs) = self.tabs.write() {
                tabs.update_nav_state(id, url, title, can_back, can_fwd);
            }
        }
    }

    /// Look up the BackendNodeId for an ax_N node ID from the cache.
    fn resolve_backend_node_id(&self, node_id: &str) -> Result<BackendNodeId, BrowserError> {
        let cache = self.ax_node_cache.read().map_err(|e| {
            BrowserError::PlatformInternal(format!("Failed to read ax_node_cache: {}", e))
        })?;
        cache.get(node_id).copied().ok_or_else(|| {
            BrowserError::ElementNotFound(format!(
                "No cached BackendNodeId for '{}'. Call get_accessibility_tree() first.",
                node_id
            ))
        })
    }

    /// Get the bounding box center for a BackendNodeId via CDP DOM.getBoxModel.
    async fn get_element_center(
        &self,
        backend_node_id: BackendNodeId,
    ) -> Result<(f64, f64), BrowserError> {
        let page = self.get_page().await?;
        let params = GetBoxModelParams::builder()
            .backend_node_id(backend_node_id)
            .build();
        let result = page
            .execute(params)
            .await
            .map_err(|e| BrowserError::ElementNotFound(format!("DOM.getBoxModel failed: {}", e)))?;

        // The content quad is 8 floats: [x1,y1, x2,y2, x3,y3, x4,y4]
        let quad = result.result.model.content.inner();
        if quad.len() < 8 {
            return Err(BrowserError::PlatformInternal(
                "Content quad has fewer than 8 values".into(),
            ));
        }
        // Compute center from the four corners
        let cx = (quad[0] + quad[2] + quad[4] + quad[6]) / 4.0;
        let cy = (quad[1] + quad[3] + quad[5] + quad[7]) / 4.0;
        Ok((cx, cy))
    }

    /// Focus a DOM element by BackendNodeId via CDP DOM.focus.
    async fn focus_by_backend_node_id(
        &self,
        backend_node_id: BackendNodeId,
    ) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        let params = FocusParams::builder()
            .backend_node_id(backend_node_id)
            .build();
        page.execute(params)
            .await
            .map_err(|e| BrowserError::InputFailed(format!("DOM.focus failed: {}", e)))?;
        Ok(())
    }
}

#[async_trait]
impl BrowserBackend for ChromiumBackend {
    async fn capture_screenshot(&self) -> Result<Vec<u8>, BrowserError> {
        let page = self.get_page().await?;
        page.screenshot(
            chromiumoxide::page::ScreenshotParams::builder()
                .format(CaptureScreenshotFormat::Png)
                .build(),
        )
        .await
        .map_err(|e| BrowserError::ScreenshotFailed(e.to_string()))
    }

    async fn get_accessibility_tree(&self) -> Result<Vec<A11yNode>, BrowserError> {
        let page = self.get_page().await?;
        let result = page
            .execute(GetFullAxTreeParams::default())
            .await
            .map_err(|e| BrowserError::AccessibilityFailed(e.to_string()))?;

        // Update cached URL while we have the page
        self.refresh_cached_url().await;

        let mut new_cache = AxNodeCache::new();

        let mut nodes: Vec<A11yNode> = Vec::new();
        for (i, n) in result.result.nodes.iter().enumerate() {
            if n.ignored {
                continue;
            }

            let ax_id = format!("ax_{}", i);

            // Cache the BackendNodeId for later use by click_element/focus_element
            if let Some(backend_id) = n.backend_dom_node_id {
                new_cache.insert(ax_id.clone(), backend_id);
            }

            let role = n
                .role
                .as_ref()
                .and_then(|r| r.value.as_ref())
                .and_then(|v| v.as_str())
                .unwrap_or("unknown")
                .to_string();

            let name = n
                .name
                .as_ref()
                .and_then(|v| v.value.as_ref())
                .and_then(|v| v.as_str())
                .filter(|s| !s.is_empty())
                .map(|s| s.to_string());

            let value = n
                .value
                .as_ref()
                .and_then(|v| v.value.as_ref())
                .and_then(|v| v.as_str())
                .filter(|s| !s.is_empty())
                .map(|s| s.to_string());

            let children: Vec<String> = n
                .child_ids
                .as_ref()
                .map(|ids| ids.iter().map(|id| format!("ax_{}", id.as_ref())).collect())
                .unwrap_or_default();

            // Resolve real bounds via DOM.getBoxModel if we have a backend node ID.
            // Fall back to zero-sized bounds for nodes without a DOM backing (e.g. root).
            let bounds = if let Some(backend_id) = n.backend_dom_node_id {
                let bm_params = GetBoxModelParams::builder()
                    .backend_node_id(backend_id)
                    .build();
                if let Ok(bm_result) = page.execute(bm_params).await {
                    let quad = bm_result.result.model.content.inner();
                    if quad.len() >= 8 {
                        let x = quad[0];
                        let y = quad[1];
                        let width = quad[2] - quad[0];
                        let height = quad[5] - quad[1];
                        Bounds::new(x, y, width.max(0.0), height.max(0.0))
                    } else {
                        Bounds::new(0.0, 0.0, 0.0, 0.0)
                    }
                } else {
                    Bounds::new(0.0, 0.0, 0.0, 0.0)
                }
            } else {
                Bounds::new(0.0, 0.0, 0.0, 0.0)
            };

            nodes.push(A11yNode {
                node_id: ax_id,
                role,
                name,
                value,
                bounds,
                children,
                focusable: true,
                focused: false,
                disabled: false,
            });
        }

        // Update the shared cache
        if let Ok(mut cache) = self.ax_node_cache.write() {
            *cache = new_cache;
        }

        Ok(nodes)
    }

    fn get_viewport(&self) -> Result<Viewport, BrowserError> {
        Ok(Viewport {
            width: self.viewport_width,
            height: self.viewport_height,
            device_pixel_ratio: 1.0,
        })
    }

    fn get_current_url(&self) -> Result<String, BrowserError> {
        self.cached_url
            .read()
            .map(|url| url.clone())
            .map_err(|e| BrowserError::PlatformInternal(format!("URL cache lock poisoned: {}", e)))
    }

    async fn get_page_title(&self) -> Result<String, BrowserError> {
        let page = self.get_page().await?;
        page.evaluate("document.title")
            .await
            .map_err(|e| BrowserError::PlatformInternal(e.to_string()))?
            .into_value::<String>()
            .map_err(|e| BrowserError::PlatformInternal(e.to_string()))
    }

    async fn navigate(&self, url: &str) -> Result<(), BrowserError> {
        // Closing the last tab is an explicit, well-defined outcome (the
        // drawer returns to the empty state — "Enter a URL to open a
        // page") — but `get_page()` has nothing to resolve once the tab
        // registry is empty, and would fail this call with "Page closed".
        // Reopen a tab first instead: this is also the ONLY way the
        // supervised agent can recover once the user has closed every tab,
        // since `browse_*` has no separate tab-open tool — without this,
        // every subsequent browse_* call fails the same way for the rest
        // of the run. `open_tab()` itself errors if the browser is gone,
        // so this only ever opens a tab when the browser is actually alive.
        // Serialized across the check AND the reopen, by an async mutex.
        //
        // Taking the registry's own write lock here did NOT do that, whatever
        // the comment used to claim: `open_tab()` is async and a
        // `std::sync::RwLockWriteGuard` cannot be held across an await without
        // making the future `!Send`, so the guard was released at the end of
        // its block — a plain read wearing a write lock's clothes. Two
        // concurrent navigations into the empty state (the drawer's URL bar
        // and an in-flight `browse_navigate` during a take-control handover is
        // the live pair) could both see "no tabs", both open one, and strand
        // one of the two navigations on a page nobody is watching.
        //
        // The re-check inside the guard is what makes the loser cheap: it
        // sees the winner's tab and opens nothing.
        let _reopen = self.reopen.lock().await;
        let needs_tab = {
            let tabs = self.tabs.read().map_err(tabs_lock_poisoned)?;
            needs_a_tab_before_navigating(&tabs)
        };
        if needs_tab {
            self.open_tab().await?;
        }
        drop(_reopen);
        let page = self.get_page().await?;
        page.goto(url)
            .await
            .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?;
        page.wait_for_navigation()
            .await
            .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?;

        // Update cached URL after navigation
        if let Ok(mut cached) = self.cached_url.write() {
            *cached = url.to_string();
        }
        // Also refresh from the page in case of redirects
        self.refresh_cached_url().await;
        // Keep the tab registry's per-tab nav state (url/title/back-forward)
        // current for whichever tab this navigation happened on, so a
        // caller listing tabs right after doesn't see stale pre-navigation
        // values while waiting for the next list_tabs() refresh sweep.
        self.sync_active_tab_nav_state(&page).await;

        Ok(())
    }

    async fn inject_click(&self, x: f64, y: f64) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        page.execute(
            DispatchMouseEventParams::builder()
                .r#type(DispatchMouseEventType::MousePressed)
                .x(x)
                .y(y)
                .button(MouseButton::Left)
                .click_count(1)
                .build()
                .unwrap(),
        )
        .await
        .map_err(|e| BrowserError::InputFailed(e.to_string()))?;

        page.execute(
            DispatchMouseEventParams::builder()
                .r#type(DispatchMouseEventType::MouseReleased)
                .x(x)
                .y(y)
                .button(MouseButton::Left)
                .click_count(1)
                .build()
                .unwrap(),
        )
        .await
        .map_err(|e| BrowserError::InputFailed(e.to_string()))?;

        Ok(())
    }

    async fn inject_text(&self, text: &str) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        for ch in text.chars() {
            page.execute(
                DispatchKeyEventParams::builder()
                    .r#type(DispatchKeyEventType::Char)
                    .text(ch.to_string())
                    .build()
                    .unwrap(),
            )
            .await
            .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
        }
        Ok(())
    }

    async fn inject_keypress(&self, key: &str, modifiers: &[Modifier]) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        let cdp_modifiers = modifiers_to_cdp_flags(modifiers);
        // `key` + `modifiers` alone delivers a DOM event a page can observe
        // and nothing else: Chromium's editing layer dispatches on
        // `windowsVirtualKeyCode`, and text entry on `text`. Without them
        // Backspace deletes nothing, the arrows move no caret, and Enter
        // submits no form — all live-observed. See `crate::keymap`.
        let held = modifiers
            .iter()
            .any(|m| matches!(m, Modifier::Control | Modifier::Meta));
        let d = crate::keymap::describe_key(key, held);

        for kind in [DispatchKeyEventType::KeyDown, DispatchKeyEventType::KeyUp] {
            let mut builder = DispatchKeyEventParams::builder()
                .r#type(kind.clone())
                .key(d.key.clone())
                .modifiers(cdp_modifiers);
            if let Some(code) = d.code {
                builder = builder.code(code.to_string());
            }
            if let Some(vk) = d.virtual_key_code {
                // Chromium reads the native code on macOS for some editing
                // commands; setting both to the same value is what Puppeteer
                // does and is correct for every key this maps.
                builder = builder
                    .windows_virtual_key_code(vk)
                    .native_virtual_key_code(vk);
            }
            // `text` belongs on the keyDown only — a keyUp carrying text
            // inserts the character a second time.
            if kind == DispatchKeyEventType::KeyDown {
                if let Some(text) = &d.text {
                    builder = builder.text(text.clone()).unmodified_text(text.clone());
                }
            }
            page.execute(builder.build().unwrap())
                .await
                .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
        }

        Ok(())
    }

    /// Insert `text` at the caret, replacing the selection — paste semantics.
    ///
    /// A CDP key event can never paste: the clipboard is the browser's, not
    /// the page's, and `Input.dispatchKeyEvent` has no access to it, so a
    /// synthesised Cmd+V delivers a key event and nothing arrives. The host
    /// reads its own pasteboard and sends the string here instead.
    ///
    /// `Input.insertText` rather than per-character key events because that
    /// IS the paste: one insertion, replacing the selection, without firing
    /// N keydown handlers a page might treat as N separate keystrokes.
    async fn insert_text(&self, text: &str) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        page.execute(InsertTextParams::new(text.to_string()))
            .await
            .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
        Ok(())
    }

    async fn inject_scroll(&self, delta_y: i32) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        page.execute(
            DispatchMouseEventParams::builder()
                .r#type(DispatchMouseEventType::MouseWheel)
                .x(self.viewport_width as f64 / 2.0)
                .y(self.viewport_height as f64 / 2.0)
                .delta_x(0.0)
                .delta_y(delta_y as f64)
                .build()
                .unwrap(),
        )
        .await
        .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
        Ok(())
    }

    async fn click_element(&self, node_id: &str) -> Result<(), BrowserError> {
        let backend_node_id = self.resolve_backend_node_id(node_id)?;
        let (cx, cy) = self.get_element_center(backend_node_id).await?;
        self.inject_click(cx, cy).await
    }

    async fn type_into_element(&self, node_id: &str, text: &str) -> Result<(), BrowserError> {
        let backend_node_id = self.resolve_backend_node_id(node_id)?;
        self.focus_by_backend_node_id(backend_node_id).await?;
        self.inject_text(text).await
    }

    async fn focus_element(&self, node_id: &str) -> Result<(), BrowserError> {
        let backend_node_id = self.resolve_backend_node_id(node_id)?;
        self.focus_by_backend_node_id(backend_node_id).await
    }

    async fn is_page_loaded(&self) -> Result<bool, BrowserError> {
        let page = self.get_page().await?;
        let state = page
            .evaluate("document.readyState")
            .await
            .map_err(|e| BrowserError::PlatformInternal(e.to_string()))?
            .into_value::<String>()
            .unwrap_or_default();
        Ok(state == "complete")
    }

    async fn wait_until(
        &self,
        condition: &WaitCondition,
        timeout_ms: u64,
    ) -> Result<bool, BrowserError> {
        let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);

        // Snapshot the URL at entry for UrlChanged comparisons.
        // Errors here turn into an empty baseline rather than
        // failing the wait — a missing baseline matches anything.
        let entry_url = self.get_current_url().unwrap_or_default();

        loop {
            let met = match condition {
                WaitCondition::PageLoaded => self.is_page_loaded().await?,
                WaitCondition::UrlChanged => {
                    let now = self.get_current_url().unwrap_or_default();
                    !now.is_empty() && now != entry_url
                }
                WaitCondition::A11yContainsText { text } => {
                    let needle = text.to_lowercase();
                    let nodes = self.get_accessibility_tree().await?;
                    nodes.iter().any(|n| {
                        n.name
                            .as_ref()
                            .map(|name| name.to_lowercase().contains(&needle))
                            .unwrap_or(false)
                    })
                }
                WaitCondition::ElementWithName {
                    name_contains,
                    role,
                } => {
                    self.element_exists_a11y(name_contains, role.as_deref())
                        .await?
                }
            };
            if met {
                return Ok(true);
            }
            if tokio::time::Instant::now() >= deadline {
                return Ok(false);
            }
            tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
        }
    }

    async fn element_exists_a11y(
        &self,
        name_contains: &str,
        role: Option<&str>,
    ) -> Result<bool, BrowserError> {
        let nodes = self.get_accessibility_tree().await?;
        Ok(nodes.iter().any(|n| {
            let name_match = n
                .name
                .as_ref()
                .map(|name| name.to_lowercase().contains(&name_contains.to_lowercase()))
                .unwrap_or(false);
            if !name_match {
                return false;
            }
            match role {
                Some(r) => n.role.to_lowercase() == r.to_lowercase(),
                None => true,
            }
        }))
    }

    async fn set_cookies(
        &self,
        cookies: &[crate::models::CookieParam],
    ) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        for cookie in cookies {
            let mut cdp_cookie = chromiumoxide::cdp::browser_protocol::network::CookieParam::new(
                &cookie.name,
                &cookie.value,
            );
            cdp_cookie.domain = Some(cookie.domain.clone());
            cdp_cookie.path = Some(cookie.path.clone());
            if cookie.secure {
                cdp_cookie.secure = Some(true);
            }
            if cookie.http_only {
                cdp_cookie.http_only = Some(true);
            }
            page.set_cookie(cdp_cookie)
                .await
                .map_err(|e| BrowserError::PlatformInternal(format!("set_cookie failed: {}", e)))?;
        }
        Ok(())
    }

    async fn set_local_storage(
        &self,
        origin: &str,
        items: &[(String, String)],
    ) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        // localStorage is origin-scoped — require the page already be at
        // the target origin so we don't silently navigate behind the
        // caller's back. Callers replay via `set_local_storage` after a
        // `navigate` to the matching origin (or before first `navigate`
        // if the caller wants the script to navigate elsewhere).
        let current = self.get_current_url().unwrap_or_default();
        if !current_origin_matches(&current, origin) {
            return Err(BrowserError::PlatformInternal(format!(
                "set_local_storage: page must be at origin '{}' first (currently '{}'). \
                 Add a `navigate` op before set_local_storage, or call set_local_storage \
                 before any navigate (pre-page state).",
                origin, current
            )));
        }

        // Don't swallow JSON encoding errors — `serde_json::to_string` on a
        // &str can fail in theory; treat that as a platform bug, not a
        // silent empty string.
        for (key, value) in items {
            let k = serde_json::to_string(key)
                .map_err(|e| BrowserError::PlatformInternal(format!("encode key: {}", e)))?;
            let v = serde_json::to_string(value)
                .map_err(|e| BrowserError::PlatformInternal(format!("encode value: {}", e)))?;
            let js = format!("localStorage.setItem({}, {})", k, v);
            page.evaluate(js).await.map_err(|e| {
                BrowserError::PlatformInternal(format!("localStorage.setItem failed: {}", e))
            })?;
        }
        Ok(())
    }

    async fn set_extra_headers(&self, headers: &[(String, String)]) -> Result<(), BrowserError> {
        let page = self.get_page().await?;
        // Enable network domain first
        page.execute(chromiumoxide::cdp::browser_protocol::network::EnableParams::default())
            .await
            .map_err(|e| BrowserError::PlatformInternal(format!("network enable failed: {}", e)))?;

        let header_obj: serde_json::Value = headers
            .iter()
            .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
            .collect::<serde_json::Map<String, serde_json::Value>>()
            .into();
        let params = chromiumoxide::cdp::browser_protocol::network::SetExtraHttpHeadersParams::new(
            chromiumoxide::cdp::browser_protocol::network::Headers::new(header_obj),
        );
        page.execute(params).await.map_err(|e| {
            BrowserError::PlatformInternal(format!("set_extra_headers failed: {}", e))
        })?;
        Ok(())
    }

    async fn shutdown(&self) -> Result<(), BrowserError> {
        // Order matters:
        //   1. Close every open tab's page (frees CDP resources).
        //   2. Abort the CDP event handler task so the channel
        //      isn't held open while we try to close the Browser.
        //   3. Send Browser.close via CDP, then wait on the Child.
        //
        // Each step is bounded by a short timeout so a hung or
        // crashed Chrome can't wedge shutdown. If `close`/`wait`
        // time out the explicit kill in `Drop` (or the next call
        // path) still terminates the process by PID.
        let pages: Vec<Page> = self
            .tabs
            .write()
            .map(|mut tabs| tabs.close_all())
            .unwrap_or_default();
        // Concurrently, so step 1's budget is 2s TOTAL rather than 2s per tab.
        // The tab strip made "several open tabs" ordinary and a sequential walk
        // turned the stated bound into N x 2s — `list_tabs` was given exactly
        // this treatment for exactly this reason.
        futures::future::join_all(
            pages
                .into_iter()
                .map(|page| async move { timeout(Duration::from_secs(2), page.close()).await }),
        )
        .await;
        if let Some(h) = self.handler_task.lock().ok().and_then(|mut g| g.take()) {
            h.abort();
        }
        if let Some(mut browser) = self.browser.write().await.take() {
            // Best-effort graceful close, then reap. If `close` is
            // unresponsive (Chrome already dead, CDP channel torn
            // down, etc.) fall back to `kill` so we never leave a
            // running subprocess behind.
            let close_ok = timeout(Duration::from_secs(2), browser.close())
                .await
                .map(|r| r.is_ok())
                .unwrap_or(false);
            if !close_ok {
                let _ = timeout(Duration::from_secs(2), browser.kill()).await;
            }
            let _ = timeout(Duration::from_secs(2), browser.wait()).await;
        }
        Ok(())
    }
}

impl Drop for ChromiumBackend {
    /// Synchronous backstop for the `shutdown()` happy path.
    ///
    /// `shutdown()` is async, so callers who let a `ChromiumBackend`
    /// drop on a panic, an early return, or process exit never get a
    /// chance to run it. chromiumoxide's own `Browser::Drop` relies
    /// on tokio's `kill_on_drop`, which only fires while the tokio
    /// runtime is alive — which it usually isn't during teardown.
    ///
    /// macOS does not deliver a parent-death signal, so any Chrome
    /// subprocess still alive at this moment would be reparented to
    /// launchd (PPID=1) and leak forever. We avoid that by SIGKILL'ing
    /// the captured PID directly.
    fn drop(&mut self) {
        // Abort the CDP event-pump task. `abort()` is non-blocking;
        // the task is detached after this and won't be observable.
        if let Some(h) = self.handler_task.get_mut().ok().and_then(|g| g.take()) {
            h.abort();
        }
        // SIGKILL the Chrome subprocess if we still have its PID.
        // `kill(pid, 0)` is a liveness probe — if it errors with
        // ESRCH the process is already gone and we skip the signal.
        #[cfg(unix)]
        if let Some(pid) = self.chrome_pid {
            // SAFETY: `kill(2)` is a syscall with no aliasing or
            // memory-safety concerns. We only read errno via the
            // return value.
            unsafe {
                if libc::kill(pid as libc::pid_t, 0) == 0 {
                    libc::kill(pid as libc::pid_t, libc::SIGKILL);
                }
            }
        }
        // On non-Unix targets we rely on tokio's `kill_on_drop`,
        // which on Windows uses TerminateProcess synchronously
        // from the Child's Drop. The orphan pattern that motivated
        // this fix is macOS-specific.
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The entry ids CDP hands back are opaque and NOT positional — they are
    /// allocated per navigation — so the test data deliberately uses ids that
    /// are neither contiguous nor equal to their index. Selecting by index and
    /// then reading the id out is the whole job; getting that backwards would
    /// navigate to some unrelated entry.
    // ---- the cross-process profile fallback ----------------------------

    /// The literal message Chromium emits when another live instance holds
    /// the profile — the case the in-process claim registry structurally
    /// cannot see, because the other holder is another PROCESS (two
    /// supervised agents both derive `$CAR_HOME/browser-profile`).
    const SINGLETON_LOCK_FAILURE: &str = "Failed to create /Users/x/.car/browser-profile/SingletonLock: File exists (17) Aborting now to avoid profile corruption.";

    #[test]
    fn a_singleton_lock_failure_is_recognised_as_the_profile_being_in_use() {
        assert!(is_profile_in_use(SINGLETON_LOCK_FAILURE));
        // The path and errno text vary between platforms and users; the
        // fragments matched do not.
        assert!(is_profile_in_use(
            "Failed to create /tmp/p/SingletonLock: File exists (17)"
        ));
        assert!(is_profile_in_use(
            "The profile appears to be in use by another Chromium process already"
        ));
        assert!(is_profile_in_use("Cannot create a profile directory"));
    }

    /// Deliberately narrow: falling back on an unrelated failure would
    /// silently strand a user's sign-ins in a directory nothing reads.
    #[test]
    fn unrelated_launch_failures_do_not_trigger_the_fallback() {
        for error in [
            "Failed to launch Chrome: No such file or directory (os error 2)",
            "Config error: could not find chrome executable",
            "Connection closed before the DevTools handshake completed",
            "Timed out waiting for the browser to start",
        ] {
            assert!(!is_profile_in_use(error), "must not react to: {error}");
        }
    }

    /// The retry decision, stated as the launch path applies it: retry once,
    /// only when the directory was SHARED (a launch already on a throwaway
    /// dir has no better dir to move to — retrying would just loop) and only
    /// on the in-use signature.
    #[test]
    fn only_a_shared_profile_hitting_the_lock_falls_back() {
        // (was_ephemeral, error) -> should fall back
        let decide = |was_ephemeral: bool, error: &str| !was_ephemeral && is_profile_in_use(error);

        assert!(
            decide(false, SINGLETON_LOCK_FAILURE),
            "a shared profile held by another process falls back to a throwaway one"
        );
        assert!(
            !decide(true, SINGLETON_LOCK_FAILURE),
            "already on a throwaway dir: a second lock failure is not contention, and              retrying would loop"
        );
        assert!(
            !decide(false, "Failed to launch Chrome: No such file or directory"),
            "a missing Chrome is not a profile collision"
        );
    }

    const IDS: [i64; 4] = [7, 12, 30, 31];

    /// A tab that has never been navigated: its only entry is the birth
    /// `about:blank`, so nothing is behind it.
    #[test]
    fn a_fresh_tab_can_go_neither_way() {
        assert_eq!(history_floor(Some("about:blank")), 1);
        assert!(!can_go_back_at(0, 1, Some("about:blank")));
    }

    /// The bug live verification caught: ONE navigation already reported
    /// `can_go_back: true`, and pressing Back landed on the blank page.
    /// The outcome says Back "enables after a second navigation".
    #[test]
    fn one_navigation_does_not_enable_back() {
        // entries = [about:blank, page1], currentIndex = 1
        assert!(!can_go_back_at(1, 2, Some("about:blank")));
        assert_eq!(
            adjacent_entry_id(1, &[7, 12], HistoryStep::Back, 1),
            None,
            "and Back has nowhere to go rather than landing on about:blank"
        );
    }

    #[test]
    fn a_second_navigation_enables_back_onto_the_first_real_page() {
        // entries = [about:blank, page1, page2], currentIndex = 2
        assert!(can_go_back_at(2, 3, Some("about:blank")));
        assert_eq!(
            adjacent_entry_id(2, &[7, 12, 30], HistoryStep::Back, 1),
            Some(12),
            "back lands on the FIRST REAL page, not the birth entry"
        );
    }

    /// Only entry zero is the birth entry. Someone who deliberately
    /// navigates to about:blank later has genuinely been there.
    #[test]
    fn a_deliberate_later_about_blank_is_a_real_entry() {
        // entries = [page1, about:blank], currentIndex = 1
        assert_eq!(history_floor(Some("http://example.test/")), 0);
        assert!(can_go_back_at(1, 2, Some("http://example.test/")));
        assert_eq!(
            adjacent_entry_id(1, &[7, 12], HistoryStep::Back, 0),
            Some(7)
        );
    }

    /// Mirrors `fetch_nav_state`'s derivation so the wire-visible
    /// `can_go_back` is what these cases actually assert on.
    fn can_go_back_at(current_index: i64, _len: usize, first_url: Option<&str>) -> bool {
        let floor = history_floor(first_url);
        usize::try_from(current_index).is_ok_and(|i| i > floor)
    }

    #[test]
    fn back_picks_the_previous_entry_id() {
        assert_eq!(adjacent_entry_id(2, &IDS, HistoryStep::Back, 0), Some(12));
        assert_eq!(adjacent_entry_id(1, &IDS, HistoryStep::Back, 0), Some(7));
    }

    #[test]
    fn forward_picks_the_next_entry_id() {
        assert_eq!(
            adjacent_entry_id(0, &IDS, HistoryStep::Forward, 0),
            Some(12)
        );
        assert_eq!(
            adjacent_entry_id(2, &IDS, HistoryStep::Forward, 0),
            Some(31)
        );
    }

    /// The nav bar disables Back on the first entry; this is what the backend
    /// answers if a call arrives anyway (a race, or a client bug).
    #[test]
    fn back_from_the_first_entry_has_nowhere_to_go() {
        assert_eq!(adjacent_entry_id(0, &IDS, HistoryStep::Back, 0), None);
    }

    #[test]
    fn forward_from_the_last_entry_has_nowhere_to_go() {
        assert_eq!(adjacent_entry_id(3, &IDS, HistoryStep::Forward, 0), None);
    }

    #[test]
    fn an_empty_history_has_nothing_in_either_direction() {
        assert_eq!(adjacent_entry_id(0, &[], HistoryStep::Back, 0), None);
        assert_eq!(adjacent_entry_id(0, &[], HistoryStep::Forward, 0), None);
        assert_eq!(history_floor(None), 0);
    }

    /// Same guard `fetch_nav_state` carries: `currentIndex` is only documented
    /// to be in range, and indexing a slice on trust is how a malformed
    /// response becomes a panic instead of a clean error.
    #[test]
    fn an_out_of_range_current_index_is_refused_not_indexed() {
        assert_eq!(adjacent_entry_id(-1, &IDS, HistoryStep::Back, 0), None);
        assert_eq!(adjacent_entry_id(-1, &IDS, HistoryStep::Forward, 0), None);
        assert_eq!(adjacent_entry_id(9, &IDS, HistoryStep::Back, 0), None);
        assert_eq!(adjacent_entry_id(9, &IDS, HistoryStep::Forward, 0), None);
        assert_eq!(
            adjacent_entry_id(i64::MAX, &IDS, HistoryStep::Forward, 0),
            None
        );
    }

    #[test]
    fn each_direction_names_what_was_missing() {
        assert_eq!(HistoryStep::Back.nothing_there(), "no page to go back to");
        assert_eq!(
            HistoryStep::Forward.nothing_there(),
            "no page to go forward to"
        );
    }

    // ---- needs_a_tab_before_navigating: the empty-state reopen decision --
    //
    // Same style as `tabs.rs`'s own tests: a synthetic `FakePage` standing
    // in for `chromiumoxide::Page`, so the decision `navigate()` makes is
    // provable without a live Chromium.

    type FakePage = &'static str;

    #[test]
    fn a_fresh_empty_registry_needs_a_tab_before_navigating() {
        let (reg, _rx) = TabRegistry::<FakePage>::new();
        assert!(needs_a_tab_before_navigating(&reg));
    }

    #[test]
    fn an_open_tab_needs_no_reopening_before_navigating() {
        let (mut reg, _rx) = TabRegistry::<FakePage>::new();
        reg.open("page-a", "http://a", "A");
        assert!(!needs_a_tab_before_navigating(&reg));
    }

    #[test]
    fn closing_the_last_tab_needs_a_tab_again() {
        // The exact scenario the finding names: the user closes the drawer's
        // last tab (the empty state), then navigates — this is what must
        // trip the reopen rather than fail with "Page closed".
        let (mut reg, _rx) = TabRegistry::<FakePage>::new();
        let only = reg.open("page-a", "http://a", "A");
        reg.close(only);
        assert!(needs_a_tab_before_navigating(&reg));
    }

    #[test]
    fn closing_a_background_tab_still_needs_no_reopening() {
        let (mut reg, _rx) = TabRegistry::<FakePage>::new();
        let first = reg.open("page-a", "http://a", "A");
        reg.open("page-b", "http://b", "B");
        reg.close(first);
        assert!(!needs_a_tab_before_navigating(&reg));
    }
}