Skip to main content

forge_browser/
tabs.rs

1//! The Playwright driver's tab list, as the service reads it.
2//!
3//! **The CDP target id is the one name a page has that the browser and the
4//! driver agree on.** Upstream's list is positional and renumbers on every
5//! close, so the service keys its own small tab numbers by the page target
6//! and drives and selects by it.
7
8use std::collections::{BTreeMap, HashMap, HashSet};
9use std::time::{Duration, Instant};
10
11use forge_primitives::SessionSlot;
12use forge_primitives::browser::{BrowserTabDialog, BrowserTabRow, BrowserTabState};
13use serde::Deserialize;
14
15/// One tab as the driver lists it.
16#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
17pub struct DriverTab {
18    #[serde(rename = "i")]
19    pub index: usize,
20    /// The page's CDP target id.
21    pub target: String,
22    pub title: String,
23    pub url: String,
24    pub current: bool,
25}
26
27const EXPORT: &str = "FORGE-TABS ";
28
29/// The export line's rows, or why the listing cannot be trusted.
30pub fn parse_export(text: &str) -> Result<Vec<DriverTab>, String> {
31    let line = text.lines().find(|line| line.starts_with(EXPORT)).ok_or_else(|| {
32        "the driver's tab list carried no FORGE-TABS export, so no tab can be named".to_owned()
33    })?;
34    serde_json::from_str(&line[EXPORT.len()..])
35        .map_err(|why| format!("the driver's tab export did not parse: {why}"))
36}
37
38/// The listing with the export line removed.
39pub fn strip_export(text: &str) -> String {
40    let mut listing = String::new();
41    for line in text.lines().filter(|line| !line.starts_with(EXPORT)) {
42        listing.push_str(line);
43        listing.push('\n');
44    }
45    listing
46}
47
48/// The pages the driver lists that no tab claims. **A page the driver made
49/// for itself is not a host tab**, and it is closed by its target so the
50/// listing cannot grow pages nothing paints.
51pub fn orphan_targets(tabs: &TabRegistry, listing: &[DriverTab]) -> Vec<String> {
52    let known: HashSet<String> = tabs
53        .numbers()
54        .into_iter()
55        .filter_map(|number| tabs.target(number).map(str::to_owned))
56        .collect();
57    listing
58        .iter()
59        .filter(|row| !row.target.is_empty() && !known.contains(&row.target))
60        .map(|row| row.target.clone())
61        .collect()
62}
63
64/// The profile's tabs, keyed by the host's own tab number. **The registry
65/// never mints one**: the host's numbers are what frames, mouse input and
66/// `TabClosed` already carry, and a second counter would drift from them.
67#[derive(Default)]
68pub struct TabRegistry {
69    tabs: BTreeMap<u16, Tab>,
70    current: HashMap<SessionSlot, u16>,
71    /// Every number this registry has ever taken. **A retired number is
72    /// never handed out again**, so a hand-off written against it can never
73    /// come to mean another tab.
74    retired: HashSet<u16>,
75    /// Tabs whose page a listing could not name. **Their pages are not
76    /// orphans**: the prune closes nothing while one is unresolved, or a live
77    /// document would be closed because its row could not be paired.
78    unresolved: HashSet<u16>,
79    /// Numbers the host has reported ended, including ones no row ever
80    /// took. **A birth for one of these is not waiting for a page**: a popup
81    /// the host gave up closed before its page existed, and a birth kept for
82    /// it would be re-deferred for ever - a driver recovery on every pass.
83    closed: HashSet<u16>,
84}
85
86pub(crate) struct Tab {
87    pub(crate) owner: SessionSlot,
88    /// When the tab was taken for its owner, as a refusal by name reports it.
89    since: Instant,
90    /// The tab whose page opened this one; `None` for a tab a session owns
91    /// outright. A popup closing hands its session back here.
92    opener: Option<u16>,
93    /// Whether this tab was opened as a popup. **Kept when `opener` is
94    /// rewritten**: an opener that closes first hands its children `None`,
95    /// and the popup is still a popup while its page is open.
96    is_popup: bool,
97    /// The page's CDP target id: the name the browser and the driver share.
98    pub(crate) target: String,
99    title: String,
100    url: String,
101    busy: bool,
102}
103
104impl TabRegistry {
105    /// Take the host's tab `number` for `owner`. A number the registry has
106    /// seen before is a refusal: the host never reuses one, so a duplicate
107    /// is a bug in the pairing, not a tab to overwrite.
108    pub fn adopt(
109        &mut self,
110        number: u16,
111        owner: SessionSlot,
112        target: &str,
113        title: &str,
114        url: &str,
115    ) -> Result<u16, String> {
116        if self.tabs.contains_key(&number) {
117            return Err(format!("tab {number} is already open"));
118        }
119        if self.retired.contains(&number) {
120            return Err(format!("tab {number} was already used by this host"));
121        }
122        self.retired.insert(number);
123        self.current.insert(owner.clone(), number);
124        self.tabs.insert(
125            number,
126            Tab {
127                owner,
128                since: Instant::now(),
129                opener: None,
130                is_popup: false,
131                target: target.to_owned(),
132                title: title.to_owned(),
133                url: url.to_owned(),
134                busy: false,
135            },
136        );
137        Ok(number)
138    }
139
140    /// A tab the page opened: it belongs to whoever owns `opener`.
141    pub fn create_under(
142        &mut self,
143        opener: u16,
144        number: u16,
145        target: &str,
146        title: &str,
147        url: &str,
148    ) -> Result<u16, String> {
149        let owner = self
150            .owner(opener)
151            .cloned()
152            .ok_or_else(|| format!("the popup's opener tab {opener} is not open"))?;
153        let adopted = self.adopt(number, owner, target, title, url)?;
154        if let Some(tab) = self.tabs.get_mut(&adopted) {
155            tab.opener = Some(opener);
156            tab.is_popup = true;
157        }
158        Ok(adopted)
159    }
160
161    /// Remove one tab and answer the session that owned it. **The unresolved
162    /// flag goes even for a row already gone**: a close landing between a
163    /// listing and its mark must not leave the flag on a retired number.
164    ///
165    /// **A popup closing hands its session back to the opener.** An OAuth
166    /// popup closes itself the moment it is done, and a session left with no
167    /// tab would open a fresh blank page on its next call instead of the page
168    /// the sign-in started from.
169    pub fn close(&mut self, tab: u16) -> Option<SessionSlot> {
170        self.unresolved.remove(&tab);
171        // **The number is ended whether or not a row took it**, so a birth
172        // still waiting for this tab's page is dropped rather than deferred.
173        self.closed.insert(tab);
174        let removed = self.tabs.remove(&tab)?;
175        // **A popup opened by a popup keeps its chain.** The row that named
176        // this tab as its opener takes this tab's own opener, so a middle
177        // popup closing leaves the leaf pointing at the page still there
178        // rather than at a number nobody holds.
179        for other in self.tabs.values_mut() {
180            if other.opener == Some(tab) {
181                other.opener = removed.opener;
182            }
183        }
184        let opener = removed.opener.filter(|opener| self.tabs.contains_key(opener));
185        match opener {
186            Some(opener) => {
187                for number in self.current.values_mut() {
188                    if *number == tab {
189                        *number = opener;
190                    }
191                }
192            }
193            None => self.current.retain(|_, number| *number != tab),
194        }
195        Some(removed.owner)
196    }
197
198    /// Remember that a listing could not name this tab's page, so no page is
199    /// pruned as an orphan while the row is unresolved. **Only an open row is
200    /// marked**: a number with no row has no page to protect.
201    pub fn mark_unresolved(&mut self, tab: u16) {
202        if self.tabs.contains_key(&tab) {
203            self.unresolved.insert(tab);
204        }
205    }
206
207    /// The tabs whose page a listing could not name.
208    pub fn unresolved_tabs(&self) -> Vec<u16> {
209        self.unresolved.iter().copied().collect()
210    }
211
212    /// Whether the host has reported this number ended. **A birth for one is
213    /// answered rather than kept**: nothing will ever list its page.
214    pub fn was_closed(&self, tab: u16) -> bool {
215        self.closed.contains(&tab)
216    }
217
218    pub fn close_owned(
219        &mut self,
220        session: &SessionSlot,
221        tab: u16,
222    ) -> Result<Option<SessionSlot>, String> {
223        self.owned(session, tab)?;
224        Ok(self.close(tab))
225    }
226
227    pub fn current(&self, session: &SessionSlot) -> Option<u16> {
228        self.current.get(session).copied().filter(|tab| self.tabs.contains_key(tab))
229    }
230
231    /// Forget a session's current tab without closing it: a replacement
232    /// keeps the slot but inherits none of the occupant's place.
233    pub fn forget_current(&mut self, session: &SessionSlot) {
234        self.current.remove(session);
235    }
236
237    pub fn select(&mut self, session: &SessionSlot, tab: u16) -> Result<(), String> {
238        self.owned(session, tab)?;
239        self.current.insert(session.clone(), tab);
240        Ok(())
241    }
242
243    pub fn target(&self, tab: u16) -> Option<&str> {
244        self.tabs.get(&tab).map(|tab| tab.target.as_str())
245    }
246
247    /// The tab a page target names, or `None` when that page is gone.
248    ///
249    /// **The identity an open resolves**: a tab number survives a host
250    /// replacement in name only, and the successor's first tab takes number 1
251    /// again.
252    pub fn number_of(&self, target: &str) -> Option<u16> {
253        // **An empty target is not a page**, and a tab whose page a listing
254        // could not name holds one; resolving it would open the wrong tab
255        // for a caller that named nothing.
256        if target.is_empty() {
257            return None;
258        }
259        self.tabs.iter().find(|(_, tab)| tab.target == target).map(|(number, _)| *number)
260    }
261
262    /// The tabs as one line, for a refusal that names what is open now.
263    pub fn listing(&self) -> String {
264        if self.tabs.is_empty() {
265            return "no tabs are open".to_owned();
266        }
267        self.tabs
268            .iter()
269            .map(|(number, tab)| format!("{number}: {}", tab.url))
270            .collect::<Vec<_>>()
271            .join(", ")
272    }
273
274    /// A tab's title and url, for the listing the tools answer with.
275    pub fn title_url(&self, tab: u16) -> Option<(&str, &str)> {
276        self.tabs.get(&tab).map(|tab| (tab.title.as_str(), tab.url.as_str()))
277    }
278
279    /// The tab whose page is `target`, if this session owns it. **The page id
280    /// is the tab's address**: the host's number restarts with every host, so
281    /// an id no row carries is refused rather than resolved to whatever holds
282    /// that number today.
283    pub fn owned_target(&self, session: &SessionSlot, target: &str) -> Result<u16, String> {
284        if target.is_empty() {
285            return Err("a tab is addressed by its page id, and this one is empty".to_owned());
286        }
287        let (number, tab) = self
288            .tabs
289            .iter()
290            .find(|(_, tab)| tab.target == target)
291            .map(|(number, tab)| (*number, tab))
292            .ok_or_else(|| format!("no tab has the page id '{target}'"))?;
293        if &tab.owner != session {
294            return Err(format!(
295                "the tab with page id '{target}' belongs to {}",
296                tab.owner.display()
297            ));
298        }
299        Ok(number)
300    }
301
302    pub fn owner(&self, tab: u16) -> Option<&SessionSlot> {
303        self.tabs.get(&tab).map(|tab| &tab.owner)
304    }
305
306    /// The sessions other than `except` holding a tab, each with how long it
307    /// has held its oldest one: what a host end must not take with it.
308    pub fn other_owners(&self, except: &SessionSlot) -> Vec<(SessionSlot, Duration)> {
309        self.other_owners_at(except, Instant::now())
310    }
311
312    /// [`Self::other_owners`] against a clock the caller owns, so a test
313    /// needs no instant older than the machine's boot.
314    pub fn other_owners_at(
315        &self,
316        except: &SessionSlot,
317        now: Instant,
318    ) -> Vec<(SessionSlot, Duration)> {
319        let mut held: Vec<(SessionSlot, Instant)> = Vec::new();
320        for tab in self.tabs.values() {
321            if &tab.owner == except {
322                continue;
323            }
324            match held.iter_mut().find(|(owner, _)| owner == &tab.owner) {
325                Some((_, since)) => *since = (*since).min(tab.since),
326                None => held.push((tab.owner.clone(), tab.since)),
327            }
328        }
329        held.into_iter()
330            .map(|(owner, since)| (owner, now.saturating_duration_since(since)))
331            .collect()
332    }
333
334    pub fn mark_busy(&mut self, tab: u16, busy: bool) {
335        if let Some(entry) = self.tabs.get_mut(&tab) {
336            entry.busy = busy;
337        }
338    }
339
340    /// Replace a row's title and url, answering whether either changed.
341    pub fn update(&mut self, tab: u16, title: &str, url: &str) -> bool {
342        let Some(entry) = self.tabs.get_mut(&tab) else { return false };
343        let changed = entry.title != title || entry.url != url;
344        title.clone_into(&mut entry.title);
345        url.clone_into(&mut entry.url);
346        changed
347    }
348
349    pub(crate) fn owned(&self, session: &SessionSlot, tab: u16) -> Result<&Tab, String> {
350        let entry = self.tabs.get(&tab).ok_or_else(|| format!("tab {tab} is not open"))?;
351        if &entry.owner != session {
352            return Err(format!("tab {tab} belongs to {}", entry.owner.display()));
353        }
354        Ok(entry)
355    }
356
357    /// The rows the strip draws. **A held tab outranks a busy one**: the
358    /// person is being asked about it, which is the fact worth seeing. **A
359    /// tab a person is driving is in use**, like one a call is driving: the
360    /// spec's in-use is either.
361    pub fn rows(
362        &self,
363        held: &HashSet<u16>,
364        controlled: &HashSet<u16>,
365        dialogs: &HashMap<u16, BrowserTabDialog>,
366    ) -> Vec<BrowserTabRow> {
367        self.tabs
368            .iter()
369            .map(|(number, tab)| BrowserTabRow {
370                number: *number,
371                title: tab.title.clone(),
372                url: tab.url.clone(),
373                owner: tab.owner.clone(),
374                opener: tab.opener,
375                state: if held.contains(number) {
376                    BrowserTabState::Held
377                } else if tab.busy || controlled.contains(number) {
378                    BrowserTabState::InUse
379                } else {
380                    BrowserTabState::Idle
381                },
382                dialog: dialogs.get(number).cloned(),
383            })
384            .collect()
385    }
386
387    pub fn is_empty(&self) -> bool {
388        self.tabs.is_empty()
389    }
390
391    pub fn numbers(&self) -> Vec<u16> {
392        self.tabs.keys().copied().collect()
393    }
394
395    /// The age of the youngest popup tab, or `None` when no popup is open.
396    /// **A popup's target hangs a fresh driver's connect whether or not its
397    /// document has started**, so the host is kept while one is inside its
398    /// window; past it, the popup is the wedge rather than a sign-in in
399    /// progress. **`is_popup`, not `opener`**: an opener that closes first
400    /// hands its children `None`, and the popup is still a popup.
401    pub fn popup_age(&self, now: Instant) -> Option<Duration> {
402        self.tabs
403            .values()
404            .filter(|tab| tab.is_popup)
405            .map(|tab| now.saturating_duration_since(tab.since))
406            .min()
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::{DriverTab, TabRegistry, orphan_targets, parse_export, strip_export};
413    use forge_primitives::SessionSlot;
414    use forge_primitives::browser::{BrowserTabDialog, BrowserTabState};
415    use std::collections::{HashMap, HashSet};
416    use std::time::{Duration, Instant};
417
418    #[test]
419    fn the_hosts_number_is_the_tabs_name() {
420        let seat = SessionSlot::lead("O", "p");
421        let mut registry = TabRegistry::default();
422        let one = registry.adopt(1, seat.clone(), "t1", "one", "http://a/").expect("the first tab");
423        let two =
424            registry.adopt(2, seat.clone(), "t2", "two", "http://b/").expect("the second tab");
425        assert_eq!((one, two), (1, 2));
426        assert!(
427            registry.adopt(2, seat.clone(), "t3", "three", "http://c/").is_err(),
428            "a number is never reused",
429        );
430        registry.close(one);
431        assert_eq!(registry.owner(two), Some(&seat), "a page keeps its number");
432        assert!(
433            registry.adopt(1, seat, "t4", "four", "http://d/").is_err(),
434            "a closed number stays retired",
435        );
436    }
437
438    #[test]
439    fn only_an_owner_selects_or_closes_its_tab() {
440        let lead = SessionSlot::lead("O", "p");
441        let worker = SessionSlot::worker("O", "p", "w");
442        let mut registry = TabRegistry::default();
443        let mine = registry.adopt(1, lead.clone(), "t1", "one", "u").expect("the tab");
444        assert_eq!(registry.current(&lead), Some(mine), "a new tab is its owner's current");
445        assert!(registry.select(&worker, mine).is_err(), "a foreign tab is refused");
446        assert!(registry.close_owned(&worker, mine).is_err(), "and not closed");
447        registry.select(&lead, mine).expect("its owner selects it");
448        assert_eq!(registry.current(&lead), Some(mine));
449        registry.close(mine);
450        assert_eq!(registry.current(&lead), None, "closing the current tab clears the pointer");
451    }
452
453    /// **The holder's clock starts at its oldest tab.** One entry per foreign
454    /// seat and the caller left out: a newest-tab answer would tell a person a
455    /// five-minute-old holder is a minute old, and counting the caller as a
456    /// holder would refuse every end.
457    #[test]
458    fn other_owners_names_each_foreign_seat_once_with_its_oldest_tab() {
459        let lead = SessionSlot::lead("O", "p");
460        let worker = SessionSlot::worker("O", "p", "w");
461        let other = SessionSlot::worker("O", "p", "x");
462        let mut registry = TabRegistry::default();
463        registry.adopt(1, lead.clone(), "T1", "", "").expect("the caller's tab");
464        registry.adopt(2, worker.clone(), "T2", "", "").expect("the worker's first");
465        registry.adopt(3, worker.clone(), "T3", "", "").expect("the worker's second");
466        registry.adopt(4, other.clone(), "T4", "", "").expect("another seat's tab");
467        // **The clock is the test's own**, so no instant older than the
468        // machine's boot is ever needed.
469        let base = Instant::now();
470        let now = base + Duration::from_secs(5 * 60);
471        registry.tabs.get_mut(&2).expect("the worker's first").since = base;
472        registry.tabs.get_mut(&3).expect("the worker's second").since =
473            base + Duration::from_secs(4 * 60);
474        registry.tabs.get_mut(&4).expect("another seat's tab").since =
475            base + Duration::from_secs(4 * 60);
476
477        let holders = registry.other_owners_at(&lead, now);
478        assert_eq!(
479            holders.len(),
480            2,
481            "one entry per foreign seat, the caller left out: {holders:?}"
482        );
483        assert_eq!(holders[0].0, worker);
484        assert_eq!(
485            holders[0].1,
486            Duration::from_secs(5 * 60),
487            "the oldest tab is the worker's clock, not its newest: {:?}",
488            holders[0].1,
489        );
490        assert_eq!(holders[1].0, other);
491        assert_eq!(
492            holders[1].1,
493            Duration::from_secs(60),
494            "the other seat's clock is its own tab's age: {:?}",
495            holders[1].1,
496        );
497        assert!(
498            registry.other_owners_at(&worker, now).iter().all(|(seat, _)| seat != &worker),
499            "a seat is never its own holder",
500        );
501    }
502
503    /// **The youngest popup sets the age.** A keep window is a popup's own
504    /// age, so an old popup beside a fresh sign-in must not age the fresh one
505    /// out and end the host under it. An ordinary tab is no popup at all.
506    #[test]
507    fn the_youngest_popup_sets_the_age() {
508        let seat = SessionSlot::lead("O", "p");
509        let mut registry = TabRegistry::default();
510        registry.adopt(1, seat.clone(), "opener", "", "").expect("the opener's row");
511        registry.create_under(1, 2, "old", "", "").expect("the old popup's row");
512        registry.create_under(1, 3, "fresh", "", "").expect("the fresh popup's row");
513        let now = Instant::now();
514        let old = now.checked_sub(Duration::from_secs(10 * 60)).expect("ten minutes back");
515        let fresh = now.checked_sub(Duration::from_secs(10)).expect("ten seconds back");
516        registry.tabs.get_mut(&2).expect("the old popup").since = old;
517        registry.tabs.get_mut(&3).expect("the fresh popup").since = fresh;
518
519        assert_eq!(
520            registry.popup_age(now),
521            Some(Duration::from_secs(10)),
522            "the freshest popup is the one a keep window is measured from",
523        );
524
525        let mut plain = TabRegistry::default();
526        plain.adopt(1, seat, "t1", "", "").expect("an ordinary tab");
527        assert_eq!(plain.popup_age(now), None, "an ordinary tab has no age");
528    }
529
530    /// **A page target outlives a tab number.** The replace case is the one
531    /// this exists for: a successor host's first tab is number 1 again.
532    #[test]
533    fn a_page_target_resolves_to_its_tab_and_a_gone_one_names_the_listing() {
534        let lead = SessionSlot::lead("O", "p");
535        let mut registry = TabRegistry::default();
536        registry.adopt(1, lead.clone(), "t1", "one", "http://a/").expect("the tab");
537        // **The page, not just the only tab**: a second page proves the
538        // lookup resolves the target rather than answering the first row.
539        registry.adopt(2, lead.clone(), "t2", "two", "http://b/").expect("the second tab");
540        // **A row whose page a listing could not name carries an empty
541        // target**, and it must not answer a caller that named nothing.
542        registry.adopt(3, lead, "", "three", "http://c/").expect("a row with no page named");
543        assert_eq!(registry.number_of("t1"), Some(1));
544        assert_eq!(registry.number_of("t2"), Some(2));
545        assert_eq!(registry.number_of("t9"), None);
546        assert_eq!(registry.number_of(""), None, "a page nobody named is not a page");
547        assert_eq!(registry.listing(), "1: http://a/, 2: http://b/, 3: http://c/");
548    }
549
550    #[test]
551    fn rows_say_who_owns_each_tab_and_what_state_it_is_in() {
552        let lead = SessionSlot::lead("O", "p");
553        let worker = SessionSlot::worker("O", "p", "w");
554        let mut registry = TabRegistry::default();
555        let held = registry.adopt(1, lead.clone(), "t1", "one", "http://a/").expect("the held tab");
556        let busy =
557            registry.adopt(2, worker.clone(), "t2", "two", "http://b/").expect("the busy tab");
558        registry.mark_busy(busy, true);
559        let dialogs = HashMap::from([(
560            held,
561            BrowserTabDialog { kind: "confirm".to_owned(), message: "Leave?".to_owned() },
562        )]);
563        let rows = registry.rows(&HashSet::from([held]), &HashSet::new(), &dialogs);
564        assert_eq!(rows[0].state, BrowserTabState::Held);
565        assert_eq!(rows[1].state, BrowserTabState::InUse);
566        assert_eq!(rows[0].owner, lead);
567        assert_eq!(rows[1].owner, worker);
568        assert_eq!(rows[0].dialog.as_ref().map(|dialog| dialog.kind.as_str()), Some("confirm"));
569        assert_eq!(rows[1].dialog, None, "a tab with no dialog carries none");
570
571        // **A person's control is a use too.** A tab nobody's call is driving
572        // reads in use while a viewer holds its role.
573        let driven =
574            registry.adopt(3, worker.clone(), "t3", "three", "http://c/").expect("the driven tab");
575        let rows = registry.rows(&HashSet::from([held]), &HashSet::from([driven]), &dialogs);
576        assert_eq!(rows[2].state, BrowserTabState::InUse, "the controlled tab is in use");
577
578        // A neighbour's close moves no number and leaves the held tab held.
579        registry.close(busy);
580        registry.close(driven);
581        let rows = registry.rows(&HashSet::from([held]), &HashSet::new(), &dialogs);
582        assert_eq!(rows.len(), 1);
583        assert_eq!(rows[0].number, held, "the held tab keeps the number the person was shown");
584        assert_eq!(rows[0].state, BrowserTabState::Held);
585    }
586
587    /// **A page the driver made for itself is an orphan; a host tab is
588    /// not.** The orphan is closed by target, and a row whose target the
589    /// driver could not read is left alone rather than closed blind.
590    #[test]
591    fn only_a_driver_made_page_is_an_orphan() {
592        let seat = SessionSlot::lead("O", "p");
593        let mut registry = TabRegistry::default();
594        registry.adopt(1, seat, "T1", "one", "http://a/").expect("the host tab");
595        let listing = [
596            tab_row(0, "T1"),
597            tab_row(1, "T2"),
598            DriverTab {
599                index: 2,
600                target: String::new(),
601                title: String::new(),
602                url: String::new(),
603                current: false,
604            },
605        ];
606        assert_eq!(orphan_targets(&registry, &listing), vec!["T2".to_owned()]);
607    }
608
609    /// **A close clears the unresolved flag, and a number with no row is
610    /// never marked**: a close landing between a listing and its mark must
611    /// not hold the prune on a retired number.
612    #[test]
613    fn an_unresolved_flag_goes_with_its_row() {
614        let seat = SessionSlot::lead("O", "p");
615        let mut registry = TabRegistry::default();
616        let one = registry.adopt(1, seat, "T1", "one", "u").expect("the tab");
617        registry.mark_unresolved(one);
618        assert_eq!(registry.unresolved_tabs(), vec![one]);
619        registry.close(one);
620        assert!(registry.unresolved_tabs().is_empty(), "a close clears the flag");
621
622        registry.mark_unresolved(9);
623        assert!(registry.unresolved_tabs().is_empty(), "a number with no row is not marked");
624        registry.mark_unresolved(one);
625        assert!(registry.unresolved_tabs().is_empty(), "a retired number is never marked");
626    }
627
628    fn tab_row(index: usize, target: &str) -> DriverTab {
629        DriverTab {
630            index,
631            target: target.to_owned(),
632            title: String::new(),
633            url: String::new(),
634            current: false,
635        }
636    }
637
638    /// **A tab is addressed by its page id, never its number.** The host's
639    /// numbers restart with every host, so only the page id names one tab for
640    /// its whole life; an id no row carries is refused rather than resolved
641    /// to whatever holds that number today.
642    #[test]
643    fn a_tab_is_addressed_by_its_page_id() {
644        let lead = SessionSlot::lead("O", "p");
645        let worker = SessionSlot::worker("O", "p", "w");
646        let mut registry = TabRegistry::default();
647        let mine = registry.adopt(1, lead.clone(), "T1", "one", "http://a/").expect("the tab");
648        registry.adopt(2, worker.clone(), "T2", "two", "http://b/").expect("the worker's tab");
649
650        assert_eq!(registry.owned_target(&lead, "T1"), Ok(mine), "the id finds its own tab");
651        assert_eq!(
652            registry.owned_target(&lead, "T2"),
653            Err(format!("the tab with page id 'T2' belongs to {}", worker.display())),
654            "another session's id is refused by name",
655        );
656        assert_eq!(
657            registry.owned_target(&lead, "gone"),
658            Err("no tab has the page id 'gone'".to_owned()),
659            "an id no row carries is a refusal, never the tab that holds that number now",
660        );
661        assert!(
662            registry.owned_target(&lead, "").is_err(),
663            "an empty id addresses nothing: a row whose page is unnamed is not a wildcard",
664        );
665    }
666
667    #[test]
668    fn a_popup_belongs_to_its_openers_owner() {
669        let worker = SessionSlot::worker("O", "p", "w");
670        let mut registry = TabRegistry::default();
671        let opener = registry.adopt(1, worker.clone(), "t1", "one", "u").expect("the opener");
672        let popup =
673            registry.create_under(opener, 2, "t2", "two", "http://b/").expect("the opener exists");
674        assert_eq!(
675            registry.owner(popup),
676            Some(&worker),
677            "the popup is the opener's, not the caller's"
678        );
679        assert!(
680            registry.create_under(99, 3, "t3", "three", "http://c/").is_err(),
681            "an unknown opener is refused",
682        );
683    }
684
685    /// **A popup closing hands the session back to its opener**, and a popup
686    /// outlives the opener that closed while it was open. An OAuth popup
687    /// closes itself the moment it is done, and the next call must land on
688    /// the page the sign-in started from rather than a fresh blank tab.
689    #[test]
690    fn a_popup_closing_returns_the_session_to_its_opener() {
691        let worker = SessionSlot::worker("O", "p", "w");
692        let mut registry = TabRegistry::default();
693        let opener = registry.adopt(1, worker.clone(), "t1", "one", "u").expect("the opener");
694        let popup = registry.create_under(opener, 2, "t2", "two", "http://b/").expect("the popup");
695        assert_eq!(registry.current(&worker), Some(popup), "the popup is where the session is");
696        registry.close(popup);
697        assert_eq!(registry.current(&worker), Some(opener), "the session is back on the opener");
698
699        let popup =
700            registry.create_under(opener, 3, "t3", "three", "http://c/").expect("a second popup");
701        registry.close(opener);
702        assert_eq!(registry.current(&worker), Some(popup), "the popup keeps its place");
703        assert_eq!(registry.owner(popup), Some(&worker), "and its owner");
704        assert!(
705            registry.popup_age(Instant::now()).is_some(),
706            "**the popup is still a popup after its opener goes**, or the attach-timeout hold \
707             stops covering a sign-in page that is open",
708        );
709
710        // **A chain survives its middle.** A popup opened by a popup hands the
711        // session back past the one that has gone, not to a number nobody
712        // holds.
713        let root = registry.adopt(4, worker.clone(), "t4", "four", "u").expect("a root");
714        let middle = registry.create_under(root, 5, "t5", "five", "http://e/").expect("the middle");
715        let leaf = registry.create_under(middle, 6, "t6", "six", "http://f/").expect("the leaf");
716        assert_eq!(registry.current(&worker), Some(leaf), "the leaf is where the session is");
717        registry.close(middle);
718        assert_eq!(registry.current(&worker), Some(leaf), "the middle closing leaves the leaf");
719        registry.close(leaf);
720        assert_eq!(
721            registry.current(&worker),
722            Some(root),
723            "the leaf hands the session back past the middle",
724        );
725    }
726
727    /// **The export is Playwright's own line plus one machine line.** The
728    /// agent never sees it: the service re-renders the list in forge's words.
729    #[test]
730    fn the_export_names_each_tab_target_and_position() {
731        let text = "FORGE-TABS [{\"i\":0,\"target\":\"T1\",\"title\":\"one\",\
732                    \"url\":\"http://a/\",\"current\":true},{\"i\":1,\
733                    \"target\":\"T2\",\"title\":\"two\",\"url\":\"http://b/\",\"current\":false}]\n\
734                    - 0: (current) [one](http://a/)\n- 1: [two](http://b/)\n";
735        let tabs = parse_export(text).expect("the export parses");
736        assert_eq!(tabs.len(), 2);
737        assert_eq!(tabs[1].index, 1);
738        assert_eq!(tabs[1].target, "T2", "the target is where CDP drives it");
739        assert_eq!(tabs[1].title, "two");
740        assert!(tabs[0].current, "the first tab is the current one");
741        assert!(
742            parse_export("- 0: [one](http://a/)\n").is_err(),
743            "a listing with no export is refused"
744        );
745    }
746
747    /// **The line and its raw text do not both reach the agent.** The caller
748    /// drops the export before the parts are returned.
749    #[test]
750    fn the_export_is_stripped_from_the_agent_visible_text() {
751        let text = "FORGE-TABS []\n- 0: (current) [one](http://a/)\n";
752        assert_eq!(strip_export(text), "- 0: (current) [one](http://a/)\n");
753    }
754}